Merge branch 'main' into platform

This commit is contained in:
Morgan Vernay 2023-12-14 11:54:44 +02:00
commit f847d1721f
39 changed files with 397 additions and 101 deletions

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

@ -1,5 +1,4 @@
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";
@ -18,7 +17,13 @@ export interface CalPageWrapper {
PageWrapper?: AppProps["Component"]["PageWrapper"];
}
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const interFont = localFont({
src: "../fonts/InterVariable.woff2",
variable: "--font-inter",
preload: true,
display: "swap",
});
const calFont = localFont({
src: "../fonts/CalSans-SemiBold.woff2",
variable: "--font-cal",

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>

Binary file not shown.

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

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,16 +68,6 @@ 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,
@ -276,25 +268,29 @@ 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
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 +318,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 +330,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 +362,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 +398,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 +491,7 @@ export default function Signup({
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}

View File

@ -1,5 +1,7 @@
import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
import { test } from "../lib/fixtures";
import { getInviteLink } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
@ -20,7 +22,9 @@ test.describe("Organization", () => {
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (external user)", async () => {
const invitedUserEmail = `rick@domain-${Date.now()}.com`;
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();
@ -38,21 +42,24 @@ test.describe("Organization", () => {
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return null;
assertInviteLink(inviteLink);
// Follow invite link in new window
const context = await browser.newContext();
const newPage = await context.newPage();
newPage.goto(inviteLink);
await newPage.waitForLoadState("networkidle");
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 newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
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 newPage.close();
await signupPage.close();
// Check newly invited member is not pending anymore
await page.bringToFront();
@ -80,10 +87,15 @@ test.describe("Organization", () => {
await expect(button).toBeVisible(); // email + 3 password hints
// Happy path
await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`);
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);
});
});
@ -96,21 +108,74 @@ test.describe("Organization", () => {
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();
await page.waitForLoadState("networkidle");
await expectInvitationEmailToBeReceived(
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
assertInviteLink(inviteLink);
// Check newly invited member exists and is not pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
// 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();
});
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();
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");
// Check required fields
const button = inviteLinkPage.locator("button[type=submit][disabled]");
await expect(button).toBeVisible(); // email + 3 password hints
// 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);
});
});
});
function assertInviteLink(inviteLink: string | null | undefined): asserts inviteLink is string {
if (!inviteLink) throw new Error("Invite link not found");
}

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

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

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

@ -10,13 +10,17 @@ import { HttpError } from "@calcom/lib/http-error";
import { usernameHandler, type RequestWithUsernameStatus } from "@calcom/lib/server/username";
import { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsername } from "@calcom/lib/validateUsername";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { signupSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token";
import {
findTokenByToken,
throwIfTokenExpired,
validateAndGetCorrectedUsernameForTeam,
} from "../utils/token";
async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
const {
@ -52,15 +56,33 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
await validateUsernameForTeam({ username, email, teamId: foundToken?.teamId ?? null });
username = await validateAndGetCorrectedUsernameForTeam({
username,
email,
teamId: foundToken?.teamId ?? null,
isSignup: true,
});
} else {
const usernameAndEmailValidation = await validateUsername(username, email);
const usernameAndEmailValidation = await validateAndGetCorrectedUsernameAndEmail({
username,
email,
isSignup: true,
});
if (!usernameAndEmailValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
if (!usernameAndEmailValidation.username) {
throw new HttpError({
statusCode: 422,
message: "Invalid username",
});
}
username = usernameAndEmailValidation.username;
}
// Create the customer in Stripe

View File

@ -4,16 +4,21 @@ import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsername } from "@calcom/lib/validateUsername";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token";
import {
findTokenByToken,
throwIfTokenExpired,
validateAndGetCorrectedUsernameForTeam,
} from "../utils/token";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const data = req.body;
@ -28,15 +33,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
let correctedUsername = username;
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
await validateUsernameForTeam({ username, email: userEmail, teamId: foundToken?.teamId });
correctedUsername = await validateAndGetCorrectedUsernameForTeam({
username,
email: userEmail,
teamId: foundToken?.teamId,
isSignup: true,
});
} else {
const userValidation = await validateUsername(username, userEmail);
const userValidation = await validateAndGetCorrectedUsernameAndEmail({
username,
email: userEmail,
isSignup: true,
});
if (!userValidation.isValid) {
logger.error("User validation failed", { userValidation });
return res.status(409).json({ message: "Username or email is already taken" });
}
if (!userValidation.username) {
return res.status(422).json({ message: "Invalid username" });
}
correctedUsername = userValidation.username;
}
const hashedPassword = await hashPassword(password);
@ -53,13 +73,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const user = await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
username: correctedUsername,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
username: correctedUsername,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
@ -113,7 +133,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
} else {
if (IS_PREMIUM_USERNAME_ENABLED) {
const checkUsername = await checkPremiumUsername(username);
const checkUsername = await checkPremiumUsername(correctedUsername);
if (checkUsername.premium) {
res.status(422).json({
message: "Sign up from https://cal.com/signup to claim your premium username",
@ -124,13 +144,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
username: correctedUsername,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
username: correctedUsername,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
@ -138,7 +158,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
await sendEmailVerification({
email: userEmail,
username,
username: correctedUsername,
language,
});
}

View File

@ -0,0 +1,11 @@
import slugify from "@calcom/lib/slugify";
export const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain = ""] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
return username;
};

View File

@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs";
import { HttpError } from "@calcom/lib/http-error";
import { validateUsernameInTeam } from "@calcom/lib/validateUsername";
import { validateAndGetCorrectedUsernameInTeam } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
export async function findTokenByToken({ token }: { token: string }) {
@ -35,21 +35,31 @@ export function throwIfTokenExpired(expires?: Date) {
}
}
export async function validateUsernameForTeam({
export async function validateAndGetCorrectedUsernameForTeam({
username,
email,
teamId,
isSignup,
}: {
username: string;
email: string;
teamId: number | null;
isSignup: boolean;
}) {
if (!teamId) return;
const teamUserValidation = await validateUsernameInTeam(username, email, teamId);
if (!teamId) return username;
const teamUserValidation = await validateAndGetCorrectedUsernameInTeam(username, email, teamId, isSignup);
if (!teamUserValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
if (!teamUserValidation.username) {
throw new HttpError({
statusCode: 422,
message: "Invalid username",
});
}
return teamUserValidation.username;
}

View File

@ -92,7 +92,7 @@ function UserToInviteItem({
id={`${member.user.id}`}
checked={isSelected}
type="checkbox"
className="text-primary-600 focus:ring-primary-500 border-default hover:bg-subtle inline-flex h-4 w-4 place-self-center justify-self-end rounded checked:bg-gray-800"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default hover:bg-subtle inline-flex h-4 w-4 place-self-center justify-self-end rounded checked:bg-gray-800"
onChange={() => {
onChange();
}}

View File

@ -113,6 +113,7 @@ const MembersView = () => {
const isLoading = isTeamLoading || isOrgListLoading;
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
onSuccess: () => {
utils.viewer.organizations.getMembers.invalidate();
utils.viewer.organizations.listOtherTeams.invalidate();
utils.viewer.teams.list.invalidate();
utils.viewer.organizations.listOtherTeamMembers.invalidate();

View File

@ -147,7 +147,7 @@ const Filter = (props: {
<input
id="yourWorkflows"
type="checkbox"
className="text-primary-600 focus:ring-primary-500 border-default inline-flex h-4 w-4 place-self-center justify-self-end rounded "
className="text-emphasis focus:ring-emphasis dark:text-muted border-default inline-flex h-4 w-4 place-self-center justify-self-end rounded "
checked={!!checked.userId}
onChange={(e) => {
if (e.target.checked) {
@ -211,7 +211,7 @@ const Filter = (props: {
}
}
}}
className="text-primary-600 focus:ring-primary-500 border-default inline-flex h-4 w-4 place-self-center justify-self-end rounded "
className="text-emphasis focus:ring-emphasis dark:text-muted border-default inline-flex h-4 w-4 place-self-center justify-self-end rounded "
/>
</div>
))}

View File

@ -29,7 +29,7 @@ export const TroubleshooterSidebar = () => {
const { t } = useLocale();
return (
<div className="relative z-10 hidden w-full flex-col gap-6 py-6 pl-4 pr-6 sm:flex md:pl-0">
<div className="relative z-10 hidden h-[100dvh] w-full flex-col gap-6 overflow-y-scroll py-6 pl-4 pr-6 sm:flex md:pl-0">
<BackButtonInSidebar name={t("troubleshooter")} />
<EventTypeSelect />
<EventScheduleItem />

View File

@ -8,9 +8,10 @@ import type { User } from "../UserListTable";
interface Props {
users: User[];
onRemove: () => void;
}
export function DeleteBulkUsers({ users }: Props) {
export function DeleteBulkUsers({ users, onRemove }: Props) {
const { t } = useLocale();
const selectedRows = users; // Get selected rows from table
const utils = trpc.useContext();
@ -37,6 +38,7 @@ export function DeleteBulkUsers({ users }: Props) {
deleteMutation.mutateAsync({
userIds: selectedRows.map((user) => user.id),
});
onRemove();
}}>
<p className="mt-5">
{t("remove_users_from_org_confirm", {

View File

@ -290,7 +290,10 @@ export function UserListTable() {
{
type: "render",
render: (table) => (
<DeleteBulkUsers users={table.getSelectedRowModel().flatRows.map((row) => row.original)} />
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
),
},
]}

View File

@ -1,7 +1,45 @@
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const validateUsername = async (username: string, email: string, organizationId?: number) => {
export const getUsernameForOrgMember = async ({
email,
orgAutoAcceptEmail,
isSignup,
username,
}: {
username?: string;
email: string;
orgAutoAcceptEmail?: string;
isSignup: boolean;
}) => {
if (isSignup) {
// We ensure that the username is always derived from the email during signup.
return getOrgUsernameFromEmail(email, orgAutoAcceptEmail || "");
}
if (!username) {
throw new Error("Username is required");
}
// Right now it's not possible to change username in an org by the user but when that's allowed we would simply accept the provided username
return username;
};
export const validateAndGetCorrectedUsernameAndEmail = async ({
username,
email,
organizationId,
orgAutoAcceptEmail,
isSignup,
}: {
username: string;
email: string;
organizationId?: number;
orgAutoAcceptEmail?: string;
isSignup: boolean;
}) => {
if (username.includes("+")) {
return { isValid: false, username: undefined, email };
}
// There is an existingUser if, within an org context or not, the username matches
// OR if the email matches AND either the email is verified
// or both username and password are set
@ -31,10 +69,24 @@ export const validateUsername = async (username: string, email: string, organiza
email: true,
},
});
return { isValid: !existingUser, email: existingUser?.email };
let validatedUsername = username;
if (organizationId) {
validatedUsername = await getUsernameForOrgMember({
email,
orgAutoAcceptEmail,
isSignup,
});
}
return { isValid: !existingUser, username: validatedUsername, email: existingUser?.email };
};
export const validateUsernameInTeam = async (username: string, email: string, teamId: number) => {
export const validateAndGetCorrectedUsernameInTeam = async (
username: string,
email: string,
teamId: number,
isSignup: boolean
) => {
try {
const team = await prisma.team.findFirst({
where: {
@ -49,15 +101,22 @@ export const validateUsernameInTeam = async (username: string, email: string, te
const teamData = { ...team, metadata: teamMetadataSchema.parse(team?.metadata) };
if (teamData.metadata?.isOrganization || teamData.parentId) {
const orgMetadata = teamData.metadata;
// Organization context -> org-context username check
const orgId = teamData.parentId || teamId;
return validateUsername(username, email, orgId);
return validateAndGetCorrectedUsernameAndEmail({
username,
email,
organizationId: orgId,
orgAutoAcceptEmail: orgMetadata?.orgAutoAcceptEmail || "",
isSignup,
});
} else {
// Regular team context -> regular username check
return validateUsername(username, email);
return validateAndGetCorrectedUsernameAndEmail({ username, email, isSignup });
}
} catch (error) {
console.error(error);
return { isValid: false, email: undefined };
return { isValid: false, username: undefined, email: undefined };
}
};

View File

@ -610,9 +610,9 @@ export const emailSchemaRefinement = (value: string) => {
};
export const signupSchema = z.object({
username: z.string().refine((value) => !value.includes("+"), {
message: "String should not contain a plus symbol (+).",
}),
// Username is marked optional here because it's requirement depends on if it's the Organization invite or a team invite which isn't easily done in zod
// It's better handled beyond zod in `validateAndGetCorrectedUsernameAndEmail`
username: z.string().optional(),
email: z.string().email(),
password: z.string().superRefine((data, ctx) => {
const isStrict = false;

View File

@ -1,4 +1,5 @@
import type { Table } from "@tanstack/react-table";
import { motion, AnimatePresence } from "framer-motion";
import { Fragment } from "react";
import type { SVGComponent } from "@calcom/types/SVGComponent";
@ -24,23 +25,30 @@ interface DataTableSelectionBarProps<TData> {
export function DataTableSelectionBar<TData>({ table, actions }: DataTableSelectionBarProps<TData>) {
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
if (numberOfSelectedRows === 0) return null;
const isVisible = numberOfSelectedRows > 0;
return (
<div className="bg-brand-default text-brand item-center absolute bottom-0 left-1/2 flex -translate-x-1/2 gap-4 rounded-lg p-2">
<div className="text-brand-subtle my-auto px-2">{numberOfSelectedRows} selected</div>
{actions?.map((action, index) => (
<Fragment key={index}>
{action.type === "action" ? (
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon}>
{action.label}
</Button>
) : action.type === "render" ? (
action.render(table)
) : null}
</Fragment>
))}
</div>
<AnimatePresence>
{isVisible ? (
<motion.div
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 20 }}
exit={{ opacity: 0, y: 0 }}
className="bg-brand-default text-brand item-center fixed bottom-6 left-1/4 hidden gap-4 rounded-lg p-2 md:flex lg:left-1/2">
<div className="text-brand-subtle my-auto px-2">{numberOfSelectedRows} selected</div>
{actions?.map((action, index) => (
<Fragment key={index}>
{action.type === "action" ? (
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon}>
{action.label}
</Button>
) : action.type === "render" ? (
action.render(table)
) : null}
</Fragment>
))}
</motion.div>
) : null}
</AnimatePresence>
);
}

View File

@ -95,7 +95,7 @@ export function DataTable<TData, TValue>({
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
return (
<div className="relative space-y-4">
<div className="space-y-4">
<DataTableToolbar
table={table}
filterableItems={filterableItems}

View File

@ -80,7 +80,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
disabled={disabled}
id={rest.id ? rest.id : id}
className={classNames(
"text-primary-600 focus:ring-primary-500 border-default bg-default focus:bg-default active:bg-default h-4 w-4 rounded checked:hover:bg-gray-600 focus:outline-none focus:ring-0 ltr:mr-2 rtl:ml-2",
"text-emphasis focus:ring-emphasis dark:text-muted border-default bg-default focus:bg-default active:bg-default h-4 w-4 rounded checked:hover:bg-gray-600 focus:outline-none focus:ring-0 ltr:mr-2 rtl:ml-2",
!error && disabled
? "cursor-not-allowed bg-gray-300 checked:bg-gray-300 hover:bg-gray-300 hover:checked:bg-gray-300"
: "hover:bg-subtle hover:border-emphasis checked:bg-gray-800",

View File

@ -35,7 +35,7 @@ const InputOption: React.FC<OptionProps<unknown, boolean, GroupBase<unknown>>> =
innerProps={props}>
<input
type="checkbox"
className="text-primary-600 focus:ring-primary-500 border-default h-4 w-4 rounded ltr:mr-2 rtl:ml-2"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default h-4 w-4 rounded ltr:mr-2 rtl:ml-2"
checked={isSelected}
readOnly
/>