diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index fcabef9abc..6a06056cbc 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -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. diff --git a/apps/web/components/PageWrapper.tsx b/apps/web/components/PageWrapper.tsx index bdd311d2a9..03b9a9c2c5 100644 --- a/apps/web/components/PageWrapper.tsx +++ b/apps/web/components/PageWrapper.tsx @@ -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", diff --git a/apps/web/components/ui/form/CheckboxField.tsx b/apps/web/components/ui/form/CheckboxField.tsx index 8298fbb5b5..a189b6d413 100644 --- a/apps/web/components/ui/form/CheckboxField.tsx +++ b/apps/web/components/ui/form/CheckboxField.tsx @@ -49,7 +49,7 @@ const CheckboxField = forwardRef( {...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" /> {description} diff --git a/apps/web/fonts/InterVariable.woff2 b/apps/web/fonts/InterVariable.woff2 new file mode 100644 index 0000000000..22a12b04e1 Binary files /dev/null and b/apps/web/fonts/InterVariable.woff2 differ diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index d9f32aeeb5..a83b3d2e2e 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -403,7 +403,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{type.team && !isManagedEventType && ( 0 && ( { - 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 */} - setUsernameTaken(value)} - data-testid="signup-usernamefield" - setPremium={(value) => setPremiumUsername(value)} - addOnLeading={ - orgSlug - ? `${getOrgFullOrigin(orgSlug, { protocol: true })}/` - : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/` - } - /> + {!isOrgInviteByLink ? ( + setUsernameTaken(value)} + data-testid="signup-usernamefield" + setPremium={(value) => setPremiumUsername(value)} + addOnLeading={ + orgSlug + ? `${getOrgFullOrigin(orgSlug, { protocol: true })}/` + : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/` + } + /> + ) : null} {/* Email */} @@ -322,7 +318,7 @@ export default function Signup({ : t("create_account")} - {/* Continue with Social Logins */} + {/* Continue with Social Logins - Only for non-invite links */} {token || (!isGoogleLoginEnabled && !isSAMLLoginEnabled) ? null : (
@@ -334,7 +330,7 @@ export default function Signup({
)} - {/* Social Logins */} + {/* Social Logins - Only for non-invite links*/} {!token && (
{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({
+ ); } diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index 2166b9dcad..5681e14e42 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -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"); +} diff --git a/apps/web/playwright/signup.e2e.ts b/apps/web/playwright/signup.e2e.ts index 65da61ebe2..884fcebcab 100644 --- a/apps/web/playwright/signup.e2e.ts +++ b/apps/web/playwright/signup.e2e.ts @@ -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"; diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 442cdad4ed..cb252d45f8 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -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, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index b1a2bde551..aa68d01ec1 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -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"), diff --git a/packages/app-store/bookerApps.metadata.generated.ts b/packages/app-store/bookerApps.metadata.generated.ts index b03b64e735..8989cb1264 100644 --- a/packages/app-store/bookerApps.metadata.generated.ts +++ b/packages/app-store/bookerApps.metadata.generated.ts @@ -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, diff --git a/packages/app-store/paypal/config.json b/packages/app-store/paypal/config.json index f6a7ce84cd..5624fd3f39 100644 --- a/packages/app-store/paypal/config.json +++ b/packages/app-store/paypal/config.json @@ -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", diff --git a/packages/app-store/roam/DESCRIPTION.md b/packages/app-store/roam/DESCRIPTION.md new file mode 100644 index 0000000000..ac49de471a --- /dev/null +++ b/packages/app-store/roam/DESCRIPTION.md @@ -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. \ No newline at end of file diff --git a/packages/app-store/roam/api/add.ts b/packages/app-store/roam/api/add.ts new file mode 100644 index 0000000000..6ab3106577 --- /dev/null +++ b/packages/app-store/roam/api/add.ts @@ -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; diff --git a/packages/app-store/roam/api/index.ts b/packages/app-store/roam/api/index.ts new file mode 100644 index 0000000000..4c0d2ead01 --- /dev/null +++ b/packages/app-store/roam/api/index.ts @@ -0,0 +1 @@ +export { default as add } from "./add"; diff --git a/packages/app-store/roam/config.json b/packages/app-store/roam/config.json new file mode 100644 index 0000000000..b73d81b6b5 --- /dev/null +++ b/packages/app-store/roam/config.json @@ -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" +} diff --git a/packages/app-store/roam/index.ts b/packages/app-store/roam/index.ts new file mode 100644 index 0000000000..d7f3602204 --- /dev/null +++ b/packages/app-store/roam/index.ts @@ -0,0 +1 @@ +export * as api from "./api"; diff --git a/packages/app-store/roam/package.json b/packages/app-store/roam/package.json new file mode 100644 index 0000000000..50b2309cec --- /dev/null +++ b/packages/app-store/roam/package.json @@ -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" +} diff --git a/packages/app-store/roam/static/1.jpg b/packages/app-store/roam/static/1.jpg new file mode 100644 index 0000000000..434e247032 Binary files /dev/null and b/packages/app-store/roam/static/1.jpg differ diff --git a/packages/app-store/roam/static/2.jpg b/packages/app-store/roam/static/2.jpg new file mode 100644 index 0000000000..4d4479bbff Binary files /dev/null and b/packages/app-store/roam/static/2.jpg differ diff --git a/packages/app-store/roam/static/3.jpg b/packages/app-store/roam/static/3.jpg new file mode 100644 index 0000000000..3577d971c7 Binary files /dev/null and b/packages/app-store/roam/static/3.jpg differ diff --git a/packages/app-store/roam/static/icon-dark.svg b/packages/app-store/roam/static/icon-dark.svg new file mode 100644 index 0000000000..37bc95ca68 --- /dev/null +++ b/packages/app-store/roam/static/icon-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/app-store/roam/static/icon.png b/packages/app-store/roam/static/icon.png new file mode 100644 index 0000000000..ec6b72acd3 Binary files /dev/null and b/packages/app-store/roam/static/icon.png differ diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index ffa7baee73..f09ab8a6b7 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -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 diff --git a/packages/features/auth/signup/handlers/selfHostedHandler.ts b/packages/features/auth/signup/handlers/selfHostedHandler.ts index 4b54669385..174e7a1507 100644 --- a/packages/features/auth/signup/handlers/selfHostedHandler.ts +++ b/packages/features/auth/signup/handlers/selfHostedHandler.ts @@ -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, }); } diff --git a/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts new file mode 100644 index 0000000000..ee48f78747 --- /dev/null +++ b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts @@ -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; +}; diff --git a/packages/features/auth/signup/utils/token.ts b/packages/features/auth/signup/utils/token.ts index da1b08967d..41e9e26128 100644 --- a/packages/features/auth/signup/utils/token.ts +++ b/packages/features/auth/signup/utils/token.ts @@ -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; } diff --git a/packages/features/ee/organizations/components/TeamInviteFromOrg.tsx b/packages/features/ee/organizations/components/TeamInviteFromOrg.tsx index dcdd0e8809..3864361e98 100644 --- a/packages/features/ee/organizations/components/TeamInviteFromOrg.tsx +++ b/packages/features/ee/organizations/components/TeamInviteFromOrg.tsx @@ -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(); }} diff --git a/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx b/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx index a9506fc6aa..3d49c61265 100644 --- a/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx +++ b/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx @@ -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(); diff --git a/packages/features/ee/workflows/pages/index.tsx b/packages/features/ee/workflows/pages/index.tsx index 41ddc24559..c0714a3898 100644 --- a/packages/features/ee/workflows/pages/index.tsx +++ b/packages/features/ee/workflows/pages/index.tsx @@ -147,7 +147,7 @@ const Filter = (props: { { 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 " /> ))} diff --git a/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx b/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx index c5286fa917..b4a32a3ac0 100644 --- a/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx +++ b/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx @@ -29,7 +29,7 @@ export const TroubleshooterSidebar = () => { const { t } = useLocale(); return ( -
+
diff --git a/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx b/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx index 639618f4c6..3215264b71 100644 --- a/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx +++ b/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx @@ -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(); }}>

{t("remove_users_from_org_confirm", { diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index a2b9ecb3d6..f929cc4998 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -290,7 +290,10 @@ export function UserListTable() { { type: "render", render: (table) => ( - row.original)} /> + row.original)} + onRemove={() => table.toggleAllPageRowsSelected(false)} + /> ), }, ]} diff --git a/packages/lib/validateUsername.ts b/packages/lib/validateUsername.ts index 74687725d6..745eedad34 100644 --- a/packages/lib/validateUsername.ts +++ b/packages/lib/validateUsername.ts @@ -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 }; } }; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index b5a2296a6f..7741375f0f 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -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; diff --git a/packages/ui/components/data-table/DataTableSelectionBar.tsx b/packages/ui/components/data-table/DataTableSelectionBar.tsx index 26c7f6797d..04f738bc8d 100644 --- a/packages/ui/components/data-table/DataTableSelectionBar.tsx +++ b/packages/ui/components/data-table/DataTableSelectionBar.tsx @@ -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 { export function DataTableSelectionBar({ table, actions }: DataTableSelectionBarProps) { const numberOfSelectedRows = table.getSelectedRowModel().rows.length; - - if (numberOfSelectedRows === 0) return null; + const isVisible = numberOfSelectedRows > 0; return ( -

-
{numberOfSelectedRows} selected
- {actions?.map((action, index) => ( - - {action.type === "action" ? ( - - ) : action.type === "render" ? ( - action.render(table) - ) : null} - - ))} -
+ + {isVisible ? ( + +
{numberOfSelectedRows} selected
+ {actions?.map((action, index) => ( + + {action.type === "action" ? ( + + ) : action.type === "render" ? ( + action.render(table) + ) : null} + + ))} +
+ ) : null} +
); } diff --git a/packages/ui/components/data-table/index.tsx b/packages/ui/components/data-table/index.tsx index 5a41ebcf4e..5bc08e04b6 100644 --- a/packages/ui/components/data-table/index.tsx +++ b/packages/ui/components/data-table/index.tsx @@ -95,7 +95,7 @@ export function DataTable({ virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0; return ( -
+
( 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", diff --git a/packages/ui/components/form/checkbox/MultiSelectCheckboxes.tsx b/packages/ui/components/form/checkbox/MultiSelectCheckboxes.tsx index 3aaf68e8ab..d06c4c57ee 100644 --- a/packages/ui/components/form/checkbox/MultiSelectCheckboxes.tsx +++ b/packages/ui/components/form/checkbox/MultiSelectCheckboxes.tsx @@ -35,7 +35,7 @@ const InputOption: React.FC>> = innerProps={props}>