Merge branch 'feat/organizations' into feat/organizations-banner

This commit is contained in:
Leo Giovanetti 2023-06-07 18:41:57 -03:00
commit 5a9caa4cc6
23 changed files with 219 additions and 28 deletions

View File

@ -32,11 +32,11 @@ PRISMA_GENERATE_DATAPROXY=
# ***********************************************************************************************************
# - SHARED **************************************************************************************************
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
NEXT_PUBLIC_WEBAPP_URL='http://app.cal.local:3000'
# Change to 'http://localhost:3001' if running the website simultaneously
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
NEXT_PUBLIC_CONSOLE_URL='http://localhost:3004'
NEXT_PUBLIC_EMBED_LIB_URL='http://localhost:3000/embed/embed.js'
NEXT_PUBLIC_EMBED_LIB_URL='http://app.cal.local:3000/embed/embed.js'
# To enable SAML login, set both these variables
# @see https://github.com/calcom/cal.com/tree/main/packages/features/ee#setting-up-saml-login
@ -58,7 +58,7 @@ PGSSLMODE=
# @see: https://github.com/calendso/calendso/issues/263
# @see: https://next-auth.js.org/configuration/options#nextauth_url
# Required for Vercel hosting - set NEXTAUTH_URL to equal your NEXT_PUBLIC_WEBAPP_URL
NEXTAUTH_URL='http://localhost:3000'
NEXTAUTH_URL='http://app.cal.local:3000'
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
# You can use: `openssl rand -base64 32` to generate one
NEXTAUTH_SECRET=
@ -183,3 +183,8 @@ CSP_POLICY=
EDGE_CONFIG=
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
# Vercel
PROJECT_ID_VERCEL=
TEAM_ID_VERCEL=
AUTH_BEARER_TOKEN_VERCEL=

View File

@ -3,6 +3,7 @@ import { debounce, noop } from "lodash";
import type { RefCallback } from "react";
import { useEffect, useMemo, useState } from "react";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
@ -31,6 +32,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
usernameRef,
onSuccessMutation,
onErrorMutation,
user,
} = props;
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
const [markAsError, setMarkAsError] = useState(false);
@ -108,6 +110,12 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
});
};
const usernamePrefix = user.organization
? user.organization.slug
? `${user.organization.slug}.${subdomainSuffix()}`
: `${user.organization.metadata.requestedSlug}.${subdomainSuffix()}`
: process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "");
return (
<div>
<div className="flex rounded-md">
@ -116,9 +124,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
ref={usernameRef}
name="username"
value={inputUsernameValue}
addOnLeading={
<>{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/</>
}
addOnLeading={<>{usernamePrefix}/</>}
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"

View File

@ -3,6 +3,7 @@ import { collectEvents } from "next-collect/server";
import type { NextMiddleware } from "next/server";
import { NextResponse, userAgent } from "next/server";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { isIpInBanlist } from "@calcom/lib/getIP";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
@ -10,6 +11,15 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry
const middleware: NextMiddleware = async (req) => {
const url = req.nextUrl;
const requestHeaders = new Headers(req.headers);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.get("host") ?? "");
// Make sure we are in the presence of an organization
if (isValidOrgDomain && url.pathname === "/") {
// In the presence of an organization, cover its profile page at "/"
// rewrites for org profile page using team profile page
url.pathname = `/org/${currentOrgDomain}`;
return NextResponse.rewrite(url);
}
if (!url.pathname.startsWith("/api")) {
//
@ -70,6 +80,25 @@ const middleware: NextMiddleware = async (req) => {
requestHeaders.set("x-csp-enforce", "true");
}
if (isValidOrgDomain) {
// Match /:slug to determine if it corresponds to org subteam slug or org user slug
const [first, slug, ...rest] = url.pathname.split("/");
// In the presence of an organization, if not team profile, a user or team is being accessed
if (first === "" && rest.length === 0) {
// Fetch the corresponding subteams for the entered organization
const getSubteams = await fetch(`${WEBAPP_URL}/api/organizations/${currentOrgDomain}/subteams`);
if (getSubteams.ok) {
const data = await getSubteams.json();
// Treat entered slug as a team if found in the subteams fetched
if (data.slugs.includes(slug)) {
// Rewriting towards /team/:slug to bring up the team profile within the org
url.pathname = `/team/${slug}`;
return NextResponse.rewrite(url);
}
}
}
}
return NextResponse.next({
request: {
headers: requestHeaders,
@ -79,6 +108,8 @@ const middleware: NextMiddleware = async (req) => {
export const config = {
matcher: [
"/",
"/:path*",
"/api/collect-events/:path*",
"/api/auth/:path*",
"/apps/routing_forms/:path*",

View File

@ -170,6 +170,10 @@ const nextConfig = {
},
async rewrites() {
return [
{
source: "/org/:slug",
destination: "/team/:slug",
},
{
source: "/:user/avatar.png",
destination: "/api/user/avatar?username=:user",

View File

@ -10,6 +10,7 @@ import {
useEmbedStyles,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
import { WEBAPP_URL } from "@calcom/lib/constants";
@ -255,6 +256,7 @@ const getEventTypesWithHiddenFromDB = async (userId: number) => {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const crypto = await import("crypto");
const { isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
const usernameList = getUsernameList(context.query.user as string);
const dataFetchStart = Date.now();
@ -272,6 +274,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
bio: true,
brandColor: true,
darkBrandColor: true,
organizationId: true,
theme: true,
away: true,
verified: true,
@ -284,7 +287,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
avatar: `${WEBAPP_URL}/${user.username}/avatar.png`,
}));
if (!users.length) {
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
return {
notFound: true,
} as {

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/organizations/api/subteams";

View File

@ -34,7 +34,7 @@ type RecurringInfo = {
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
const descriptionByStatus: Record<BookingListingStatus, string> = {
const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
upcoming: "upcoming_bookings",
recurring: "recurring_bookings",
past: "past_bookings",

View File

@ -9,6 +9,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { Button, StepCard, Steps } from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@ -202,6 +203,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
darkBrandColor: true,
metadata: true,
timeFormat: true,
organization: {
select: {
slug: true,
metadata: true,
},
},
allowDynamicBooking: true,
defaultScheduleId: true,
completedOnboarding: true,
@ -233,6 +240,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...(await serverSideTranslations(context.locale ?? "", ["common"])),
user: {
...user,
...(user.organization && {
organization: {
...user.organization,
metadata: teamMetadataSchema.parse(user.organization.metadata),
},
}),
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
},
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,

View File

@ -193,7 +193,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
isUnpublished: true,
team: unpublishedTeam,
team: { ...unpublishedTeam, createdAt: null },
trpcState: ssr.dehydrate(),
},
} as const;

View File

@ -0,0 +1,24 @@
{
"awaiting_payment_subject": "Esperant el pagament: {{title}} el {{date}}",
"help": "Ajuda",
"price": "Preu",
"paid": "Pagat",
"refunded": "Reembossat",
"payment": "Pagament",
"missing_card_fields": "Falten camps de la targeta",
"pay_now": "Paga ara",
"codebase_has_to_stay_opensource": "El codi font ha de seguir com codi obert, tant si s'ha modificat com si no",
"cannot_repackage_codebase": "No pots tornar a empaquetar ni vendre el codi font",
"acquire_license": "Adquireix una llicència comercial per eliminar aquests termes mitjançant un correu electrònic",
"terms_summary": "Resum dels termes",
"open_env": "Obre .env i accepta la nostra llicència",
"env_changed": "He canviat el meu .env",
"accept_license": "Accepta la llicència",
"still_waiting_for_approval": "Un esdeveniment encara està pendent d'aprovació",
"event_is_still_waiting": "La sol·licitud d'esdeveniment encara està pendent: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "No hi ha més resultats",
"no_results": "No hi ha resultats",
"load_more_results": "Carrega més resultats",
"integration_meeting_id": "{{integrationName}} Identificador de la reunió: {{meetingId}}",
"confirmed_event_type_subject": "Confirmat: {{eventType}} amb {{name}} el {{date}}"
}

View File

@ -11,7 +11,16 @@
"calcom_explained_new_user": "Završite podešavanje vašeg {{appName}} naloga! Na par koraka ste od rešavanja svih problema sa rasporedom.",
"have_any_questions": "Imate pitanja? Tu smo da pomognemo.",
"reset_password_subject": "{{appName}}: Upustva za resetovanje lozinke",
"verify_email_subject": "{{appName}}: Potvrdi svoj nalog",
"check_your_email": "Proveri imejl",
"verify_email_page_body": "Poslali smo imejl na {{email}}. Važno je da potvrdite svoju imejl adresu da biste obezbedili najbolju isporuku imejla i kalendara od aplikacije {{appName}}.",
"verify_email_banner_body": "Potvrdite svoju imejl adresu da biste obezbedili najbolju isporuku imejla i kalendara od aplikacije {{appName}}.",
"verify_email_banner_button": "Pošaljite imejl",
"verify_email_email_header": "Potvrdite svoju imejl adresu",
"verify_email_email_button": "Potvrdi imejl",
"verify_email_email_body": "Potvrdite svoju imejl adresu klikom na dugme ispod.",
"verify_email_email_link_text": "U slučaju da ne volite da klikćete na dugmad, evo linka:",
"email_sent": "Imejl je uspešno poslat",
"event_declined_subject": "Odbijeno: {{title}} datuma {{date}}",
"event_cancelled_subject": "Otkazano: {{title}} datuma {{date}}",
"event_request_declined": "Vaš zahtev za događaj je odbijen",

View File

@ -0,0 +1,36 @@
import type { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
const querySchema = z.object({
org: z.string({ required_error: "org slug is required" }),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) throw new HttpError({ statusCode: 400, message: parsedQuery.error.message });
const {
data: { org: slug },
} = parsedQuery;
if (!slug) if (!slug) return res.status(400).json({ message: "Org is needed" });
const org = await prisma.team.findFirst({ where: { slug }, select: { children: true, metadata: true } });
if (!org) return res.status(400).json({ message: "Org doesn't exist" });
const metadata = teamMetadataSchema.parse(org?.metadata);
if (!metadata?.isOrganization) return res.status(400).json({ message: "Team is not an org" });
return res.status(200).json({ slugs: org.children.map((ch) => ch.slug) });
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@ -5,6 +5,7 @@ import { useState } from "react";
import useDigitInput from "react-digit-input";
import { Controller, useForm } from "react-hook-form";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
@ -77,6 +78,7 @@ export const VerifyCodeDialog = ({
open={isOpenDialog}
onOpenChange={(open) => {
onChange("");
setError("");
setIsOpenDialog(open);
}}>
<DialogContent className="sm:max-w-md">
@ -268,10 +270,7 @@ export const CreateANewOrganizationForm = () => {
name="slug"
label={t("organization_url")}
placeholder="acme"
addOnSuffix={`.${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace("https://", "")?.replace(
"http://",
""
)}`}
addOnSuffix={`.${subdomainSuffix()}`}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value), {

View File

@ -0,0 +1,28 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
// Define which hostnames are expected for the app
export const appHostnames = ["cal.com", "cal.dev", "cal-staging.com", "cal.community", "cal.local:3000"];
export function getOrgDomain(hostname: string) {
// Find which hostname is being currently used
const currentHostname = appHostnames.find((ahn) => {
const url = new URL(WEBAPP_URL);
const hostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`;
return hostname.endsWith(`.${ahn}`);
});
// Define which is the current domain/subdomain
return hostname.replace(`.${currentHostname}` ?? "", "");
}
export function orgDomainConfig(hostname: string) {
const currentOrgDomain = getOrgDomain(hostname);
return {
currentOrgDomain,
isValidOrgDomain: currentOrgDomain !== "app" && !appHostnames.includes(currentOrgDomain),
};
}
export function subdomainSuffix() {
const urlSplit = WEBAPP_URL.replace("https://", "")?.replace("http://", "").split(".");
return urlSplit.length === 3 ? urlSplit.slice(1).join(".") : urlSplit.join(".");
}

View File

@ -131,7 +131,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
</div>
<Form form={newMemberFormMethods} handleSubmit={(values) => props.onSubmit(values)}>
<div className="space-y-6">
<div className="my-6 space-y-6">
{/* Indivdual Invite */}
{modalImportMode === "INDIVIDUAL" && (
<Controller
@ -240,9 +240,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
/>
)}
/>
</div>
<DialogFooter showDivider>
<div className="mr-auto flex">
<div className="flex">
<Button
type="button"
color="minimal"
@ -271,6 +269,8 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
</Button>
)}
</div>
</div>
<DialogFooter showDivider>
<Button
type="button"
color="minimal"

View File

@ -293,7 +293,7 @@ export default function CreateEventTypeDialog({
</div>
)}
</div>
<div className="mt-8 flex justify-end gap-x-2">
<div className="mt-10 flex justify-end gap-x-2">
<DialogClose />
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}

View File

@ -425,7 +425,7 @@ export const FormBuilder = function FormBuilder({
})
}>
<DialogContent className="max-h-none p-0" data-testid="edit-field-dialog">
<div className="h-auto max-h-[85vh] overflow-auto px-8 pt-8 pb-7">
<div className="h-auto max-h-[85vh] overflow-auto px-8 pt-8 pb-10">
<DialogHeader
title={t("add_a_booking_question")}
subtitle={t("form_builder_field_add_subtitle")}
@ -545,7 +545,7 @@ export const FormBuilder = function FormBuilder({
/>
</Form>
</div>
<DialogFooter className="relative rounded px-8 pb-6" showDivider>
<DialogFooter className="relative rounded px-8 pb-4" showDivider>
<DialogClose color="secondary">{t("cancel")}</DialogClose>
<Button data-testid="field-add-save" type="submit" form="form-builder">
{isFieldEditMode ? t("save") : t("add")}

View File

@ -342,11 +342,11 @@ function UserDropdown({ small }: { small?: boolean }) {
</span>
{!small && (
<span className="flex flex-grow items-center truncate">
<span className="flex-grow truncate text-sm">
<span className="text-emphasis mb-1 block truncate pb-1 font-medium leading-none">
<span className="flex-grow truncate text-sm leading-none">
<span className="text-emphasis mb-1 block truncate font-medium">
{user.name || "Nameless User"}
</span>
<span className="text-default truncate pb-1 font-normal leading-none">
<span className="text-default truncate pb-1 font-normal">
{user.username
? process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com"
? `cal.com/${user.username}`

View File

@ -8,7 +8,7 @@ export const WEBAPP_URL =
RAILWAY_STATIC_URL ||
HEROKU_URL ||
RENDER_URL ||
"http://localhost:3000";
"http://app.cal.local:3000";
/** @deprecated use `WEBAPP_URL` */
export const BASE_URL = WEBAPP_URL;
export const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://cal.com";

View File

@ -20,7 +20,7 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
const cookiePrefix = useSecureCookies ? "__Secure-" : "";
const defaultOptions: CookieOption["options"] = {
domain: isENVDev ? undefined : NEXTAUTH_COOKIE_DOMAIN,
domain: isENVDev ? "cal.local" : NEXTAUTH_COOKIE_DOMAIN,
// To enable cookies on widgets,
// https://stackoverflow.com/questions/45094712/iframe-not-reading-cookies-in-chrome
// But we need to set it as `lax` in development

View File

@ -3,7 +3,8 @@ import { totp } from "otplib";
import { sendOrganizationEmailVerification } from "@calcom/emails";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { IS_PRODUCTION, IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@ -20,6 +21,32 @@ type CreateOptions = {
input: TCreateInputSchema;
};
const vercelCreateDomain = async (domain: string) => {
const response = await fetch(
`https://api.vercel.com/v8/projects/${process.env.PROJECT_ID_VERCEL}/domains?teamId=${process.env.TEAM_ID_VERCEL}`,
{
body: `{\n "name": "${domain}.${subdomainSuffix()}"\n}`,
headers: {
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN_VERCEL}`,
"Content-Type": "application/json",
},
method: "POST",
}
);
const data = await response.json();
// Domain is already owned by another team but you can request delegation to access it
if (data.error?.code === "forbidden")
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_team" });
// Domain is already being used by a different project
if (data.error?.code === "domain_taken")
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_project" });
return true;
};
export const createHandler = async ({ input }: CreateOptions) => {
const { slug, name, adminEmail, adminUsername, check } = input;
@ -67,6 +94,8 @@ export const createHandler = async ({ input }: CreateOptions) => {
},
});
if (IS_PRODUCTION) await vercelCreateDomain(slug);
await prisma.membership.create({
data: {
userId: createOwnerOrg.id,

View File

@ -138,7 +138,7 @@ export function DialogFooter(props: { children: ReactNode; className?: string; s
return (
<div className={classNames("bg-default", props.className)}>
{props.showDivider && <hr className="border-subtle absolute right-0 w-full" />}
<div className={classNames("bg-default mt-6 flex justify-end space-x-2 rtl:space-x-reverse")}>
<div className={classNames("-mb-4 flex justify-end space-x-2 pt-4 rtl:space-x-reverse")}>
{props.children}
</div>
</div>

View File

@ -276,6 +276,9 @@
"ZOHOCRM_CLIENT_SECRET",
"ZOOM_CLIENT_ID",
"ZOOM_CLIENT_SECRET",
"INTERCOM_SECRET"
"INTERCOM_SECRET",
"PROJECT_ID_VERCEL",
"TEAM_ID_VERCEL",
"AUTH_BEARER_TOKEN_VERCEL"
]
}