fix merge conflicts

This commit is contained in:
Ryukemeister 2023-12-19 12:22:03 +05:30
commit 1470abbbde
161 changed files with 2164 additions and 802 deletions

View File

@ -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

View File

@ -1,4 +1,4 @@
name: E2E App-Store Apps
name: E2E App-Store Apps Tests
on:
workflow_call:

View File

@ -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:

View File

@ -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:

View File

@ -1,4 +1,4 @@
name: E2E test
name: E2E tests
on:
workflow_call:

View File

@ -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:

85
.github/workflows/pre-release.yml vendored Normal file
View File

@ -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

View File

@ -44,5 +44,5 @@ jobs:
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link.

View File

@ -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(),

View File

@ -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`",

View File

@ -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 } } };

View File

@ -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);
}
}

View File

@ -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({

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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")}

View File

@ -322,6 +322,7 @@ function EventTypeSingleLayout({
StartIcon={Code}
color="secondary"
variant="icon"
namespace={eventType.slug}
tooltip={t("embed")}
tooltipSide="bottom"
tooltipOffset={4}

View File

@ -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")}

View File

@ -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);

View File

@ -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),

View File

@ -49,7 +49,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
{...rest}
ref={ref}
type="checkbox"
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default bg-default h-4 w-4 rounded"
/>
</div>
<span className="ms-2 text-sm">{description}</span>

View File

@ -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";

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.5.5",
"version": "3.6.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",

View File

@ -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);
}

View File

@ -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")}

View File

@ -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&apos;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 ">Dont 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;

View File

@ -403,7 +403,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
{type.team && !isManagedEventType && (
<UserAvatarGroup
className="relative right-3 top-1"
className="relative right-3"
size="sm"
truncateAfter={4}
users={type?.users ?? []}
@ -411,7 +411,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
)}
{isManagedEventType && type?.children && type.children?.length > 0 && (
<AvatarGroup
className="relative right-3 top-1"
className="relative right-3"
size="sm"
truncateAfter={4}
items={type?.children
@ -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}

View File

@ -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>

View File

@ -7,10 +7,12 @@ import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import type { SubmitHandler } from "react-hook-form";
import { useForm, useFormContext } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import { z } from "zod";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
@ -27,7 +29,7 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, HeadSeo, PasswordField, TextField, Form, Alert } from "@calcom/ui";
import { Button, HeadSeo, PasswordField, TextField, Form, Alert, showToast } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@ -66,21 +68,12 @@ const FEATURES = [
},
];
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain = ""] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
return username;
};
function UsernameField({
username,
setPremium,
premium,
setUsernameTaken,
orgSlug,
usernameTaken,
...props
}: React.ComponentProps<typeof TextField> & {
@ -88,6 +81,7 @@ function UsernameField({
setPremium: (value: boolean) => void;
premium: boolean;
usernameTaken: boolean;
orgSlug?: string;
setUsernameTaken: (value: boolean) => void;
}) {
const { t } = useLocale();
@ -103,7 +97,7 @@ function UsernameField({
setUsernameTaken(false);
return;
}
fetchUsername(debouncedUsername).then(({ data }) => {
fetchUsername(debouncedUsername, orgSlug ?? null).then(({ data }) => {
setPremium(data.premium);
setUsernameTaken(!data.available);
});
@ -237,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 */}
@ -276,25 +276,30 @@ export default function Signup({
await signUp(updatedValues);
}}>
{/* Username */}
<UsernameField
label={t("username")}
username={watch("username")}
premium={premiumUsername}
usernameTaken={usernameTaken}
setUsernameTaken={(value) => setUsernameTaken(value)}
data-testid="signup-usernamefield"
setPremium={(value) => setPremiumUsername(value)}
addOnLeading={
orgSlug
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
/>
{!isOrgInviteByLink ? (
<UsernameField
orgSlug={orgSlug}
label={t("username")}
username={watch("username") || ""}
premium={premiumUsername}
usernameTaken={usernameTaken}
disabled={!!orgSlug}
setUsernameTaken={(value) => setUsernameTaken(value)}
data-testid="signup-usernamefield"
setPremium={(value) => setPremiumUsername(value)}
addOnLeading={
orgSlug
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
/>
) : null}
{/* Email */}
<TextField
{...register("email")}
label={t("email")}
type="email"
disabled={prepopulateFormValues?.email}
data-testid="signup-emailfield"
/>
@ -322,7 +327,7 @@ export default function Signup({
: t("create_account")}
</Button>
</Form>
{/* Continue with Social Logins */}
{/* Continue with Social Logins - Only for non-invite links */}
{token || (!isGoogleLoginEnabled && !isSAMLLoginEnabled) ? null : (
<div className="mt-6">
<div className="relative flex items-center">
@ -334,7 +339,7 @@ export default function Signup({
</div>
</div>
)}
{/* Social Logins */}
{/* Social Logins - Only for non-invite links*/}
{!token && (
<div className="mt-6 flex flex-col gap-2 md:flex-row">
{isGoogleLoginEnabled ? (
@ -366,7 +371,7 @@ export default function Signup({
if (username) {
// If username is present we save it in query params to check for premium
const searchQueryParams = new URLSearchParams();
searchQueryParams.set("username", formMethods.getValues("username"));
searchQueryParams.set("username", username);
localStorage.setItem("username", username);
router.push(`${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`);
return;
@ -402,10 +407,14 @@ export default function Signup({
return;
}
const username = formMethods.getValues("username");
if (!username) {
showToast("error", t("username_required"));
return;
}
localStorage.setItem("username", username);
const sp = new URLSearchParams();
// @NOTE: don't remove username query param as it's required right now for stripe payment page
sp.set("username", formMethods.getValues("username"));
sp.set("username", username);
sp.set("email", formMethods.getValues("email"));
router.push(
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
@ -491,6 +500,7 @@ export default function Signup({
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}
@ -603,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);
@ -619,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;
@ -631,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,

View File

@ -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([
{

View File

@ -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"]');

View File

@ -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",

View File

@ -62,6 +62,7 @@ export const createBookingsFixture = (page: Page) => {
rescheduled,
paid,
status,
iCalUID: `${uid}@cal.com`,
},
});
const bookingFixture = createBookingFixture(booking, store.page);

View File

@ -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 },

View File

@ -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);

View File

@ -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().

View File

@ -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";

View File

@ -1,5 +1,8 @@
import type { Browser, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
import { test } from "../lib/fixtures";
import { getInviteLink } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
@ -11,106 +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@domain-${Date.now()}.com`;
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);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return null;
// Follow invite link in new window
const context = await browser.newContext();
const newPage = await context.newPage();
newPage.goto(inviteLink);
await newPage.waitForLoadState("networkidle");
// Check required fields
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
await context.close();
await newPage.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
await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`);
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL("/getting-started");
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`;
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");
await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`
);
// Check newly invited member exists and is not pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
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"
);
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
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()}@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,
});
});
});
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();
await orgOwner.apiLogin();
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,
});
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;
}

View File

@ -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",

View File

@ -2,6 +2,7 @@ import { expect } from "@playwright/test";
import { randomBytes } from "crypto";
import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
import { getEmailsReceivedByUser, localize } from "./lib/testUtils";
@ -103,6 +104,8 @@ test.describe("Signup Flow Test", async () => {
const userToCreate = users.buildForSignup({
username: "rick-jones",
password: "Password99!",
// Email intentonally kept as different from username
email: `rickjones${Math.random()}-${Date.now()}@example.com`,
});
await page.goto("/signup");
@ -120,6 +123,9 @@ test.describe("Signup Flow Test", async () => {
// Check that the URL matches the expected URL
expect(page.url()).toContain("/auth/verify-email");
const dbUser = await prisma.user.findUnique({ where: { email: userToCreate.email } });
// Verify that the username is the same as the one provided and isn't accidentally changed to email derived username - That happens only for organization member signup
expect(dbUser?.username).toBe(userToCreate.username);
});
test("Signup fields prefilled with query params", async ({ page, users }) => {
const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com";
@ -236,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");

View File

@ -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");

View File

@ -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();

View File

@ -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}`);

View File

@ -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 were checking for conflicts",
"availabilty_schedules":"Availability schedules",

View File

@ -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 {

View File

@ -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;
}

View File

@ -46,6 +46,7 @@ import plausible_config_json from "./plausible/config.json";
import qr_code_config_json from "./qr_code/config.json";
import raycast_config_json from "./raycast/config.json";
import riverside_config_json from "./riverside/config.json";
import roam_config_json from "./roam/config.json";
import routing_forms_config_json from "./routing-forms/config.json";
import salesforce_config_json from "./salesforce/config.json";
import sendgrid_config_json from "./sendgrid/config.json";
@ -123,6 +124,7 @@ export const appStoreMetadata = {
qr_code: qr_code_config_json,
raycast: raycast_config_json,
riverside: riverside_config_json,
roam: roam_config_json,
"routing-forms": routing_forms_config_json,
salesforce: salesforce_config_json,
sendgrid: sendgrid_config_json,

View File

@ -46,6 +46,7 @@ export const apiHandlers = {
qr_code: import("./qr_code/api"),
raycast: import("./raycast/api"),
riverside: import("./riverside/api"),
roam: import("./roam/api"),
"routing-forms": import("./routing-forms/api"),
salesforce: import("./salesforce/api"),
sendgrid: import("./sendgrid/api"),

View File

@ -22,6 +22,7 @@ import office365video_config_json from "./office365video/config.json";
import ping_config_json from "./ping/config.json";
import plausible_config_json from "./plausible/config.json";
import riverside_config_json from "./riverside/config.json";
import roam_config_json from "./roam/config.json";
import shimmervideo_config_json from "./shimmervideo/config.json";
import signal_config_json from "./signal/config.json";
import sirius_video_config_json from "./sirius_video/config.json";
@ -56,6 +57,7 @@ export const appStoreMetadata = {
ping: ping_config_json,
plausible: plausible_config_json,
riverside: riverside_config_json,
roam: roam_config_json,
shimmervideo: shimmervideo_config_json,
signal: signal_config_json,
sirius_video: sirius_video_config_json,

View File

@ -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(

View File

@ -3,7 +3,7 @@
"slug": "paypal",
"type": "paypal_payment",
"logo": "icon.svg",
"url": "https://example.com/link",
"url": "https://paypal.com",
"variant": "payment",
"categories": ["payment"],
"publisher": "Cal.com",

View File

@ -0,0 +1,16 @@
---
items:
- 1.jpg
- 2.jpg
- 3.jpg
---
{DESCRIPTION}
Roam makes companies:
- more productive with shorter meetings
- more connected with a map that gives a feeling of working together without meeting
- & Roam saves companies money with our all-in-one bundle
When the whole company is in one HQ, productivity is high, people feel connected to the company. Calendars are emptied. Culture comes back. And companies using Roam benefit from our all-in-one approach to cut tools and save money.

View File

@ -0,0 +1,16 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
};
export default handler;

View File

@ -0,0 +1 @@
export { default as add } from "./add";

View File

@ -0,0 +1,25 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Roam",
"slug": "roam",
"type": "roam_conferencing",
"logo": "icon.png",
"url": "https://ro.am",
"variant": "conferencing",
"categories": ["conferencing"],
"publisher": "Roam HQ, Inc.",
"email": "support@ro.am",
"appData": {
"location": {
"type": "integrations:{SLUG}_video",
"label": "{TITLE}",
"linkType": "static",
"organizerInputPlaceholder": "https://ro.am/r/#/p/yHwFBQrRTMuptqKYo_wu8A/huzRiHnR-np4RGYKV-c0pQ",
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?ro.am\\/[a-zA-Z0-9]*"
}
},
"description": "Roam is Your Whole Company in one HQ\r",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-location-video-static"
}

View File

@ -0,0 +1 @@
export * as api from "./api";

View File

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/roam",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Roam is Your Whole Company in one HQ"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

View File

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18.5C5.30558 18.5 1.5 14.6944 1.5 10C1.5 5.30558 5.30558 1.5 10 1.5C14.6944 1.5 18.5 5.30558 18.5 10C18.5 14.6944 14.6944 18.5 10 18.5ZM10 20C15.5228 20 20 15.5228 20 10C20 4.47715 15.5228 0 10 0C4.47715 0 0 4.47715 0 10C0 15.5228 4.47715 20 10 20Z" fill="black"/>
<path d="M14.1032 2.55413C13.5411 2.42252 12.9552 2.35291 12.3529 2.35291C8.12959 2.35291 4.70589 5.77661 4.70589 9.99996C4.70589 14.2233 8.12959 17.6471 12.3529 17.6471C12.9552 17.6471 13.5411 17.5774 14.1032 17.4458C16.72 16.0007 18.4938 13.2162 18.5 10.0168C18.4909 13.404 15.7423 16.1471 12.3529 16.1471C8.95802 16.1471 6.20589 13.3949 6.20589 9.99996C6.20589 6.60504 8.95802 3.85291 12.3529 3.85291C15.7423 3.85291 18.4909 6.59595 18.5 9.98315C18.4938 6.78378 16.72 3.99926 14.1032 2.55413Z" fill="black" fill-opacity="0.5"/>
<path d="M15.6034 15.2182C17.3383 14.1353 18.4941 12.2112 18.5 10.0167V10.0085C18.4953 12.1 16.7984 13.794 14.7059 13.794C12.6104 13.794 10.9118 12.0953 10.9118 9.99993C10.9118 7.9045 12.6104 6.20581 14.7059 6.20581C16.7984 6.20581 18.4953 7.89985 18.5 9.99133V9.98312C18.4941 7.78863 17.3383 5.86452 15.6034 4.78157C15.3116 4.73176 15.0118 4.70581 14.7059 4.70581C11.782 4.70581 9.41176 7.07607 9.41176 9.99993C9.41176 12.9237 11.782 15.294 14.7059 15.294C15.0118 15.294 15.3117 15.2681 15.6034 15.2182Z" fill="black" fill-opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@ -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}`,

View File

@ -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 }) => {

View File

@ -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 {

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More