Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Single domain setup when only one org in the system #18383

Merged
merged 4 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -415,3 +415,8 @@ REPLEXICA_API_KEY=

# Comma-separated list of DSyncData.directoryId to log SCIM API requests for. It can be enabled temporarily for debugging the requests being sent to SCIM server.
DIRECTORY_IDS_TO_LOG=


# Set this when Cal.com is used to serve only one organization's booking pages
# Read more about it in the README.md
NEXT_PUBLIC_SINGLE_ORG_SLUG=
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,15 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c

- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.

## Single Org Mode
If you want to have booker.yourcompany.com to be the domain used for both dashboard(e.g. https://booker.yourcompany.com/event-types) and booking pages(e.g. https://booker.yourcompany.com/john.joe/15min).
- Set the `NEXT_PUBLIC_SINGLE_ORG_SLUG` environment variable to the slug of the organization you want to use. `NEXT_PUBLIC_SINGLE_ORG_SLUG=booker`
- Set the `NEXT_PUBLIC_WEBAPP_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBAPP_URL=https://booker.yourcompany.com`.
- Set the `NEXT_PUBLIC_WEBSITE_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBSITE_URL=https://booker.yourcompany.com`.
- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`.

Note: It causes root to serve the dashboard and not the organization profile page which shows all bookable users in the organization.

## Integrations

### Obtaining the Google API Credentials
Expand Down
48 changes: 48 additions & 0 deletions apps/web/getNextjsOrgRewriteConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const isSingleOrgModeEnabled = !!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG;
const orgSlugCaptureGroupName = "orgSlug";
/**
* Returns the leftmost subdomain from a given URL.
* It needs the URL domain to have atleast two dots.
* app.cal.com -> app
* app.company.cal.com -> app
* app.company.com -> app
*/
const getLeftMostSubdomain = (url) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed getDefaultSubdomain -> getLeftMostSubdomain

if (!url.startsWith("http:") && !url.startsWith("https:")) {
// Make it a valid URL. Mabe we can simply return null and opt-out from orgs support till the use a URL scheme.
url = `https://${url}`;
}
const _url = new URL(url);
const regex = new RegExp(/^([a-z]+\:\/{2})?((?<subdomain>[\w-.]+)\.[\w-]+\.\w+)$/);
//console.log(_url.hostname, _url.hostname.match(regex));
return _url.hostname.match(regex)?.groups?.subdomain || null;
};

const getRegExpNotMatchingLeftMostSubdomain = (url) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed getSubdomainRegExp -> getRegExpNotMatchingLeftMostSubdomain

const leftMostSubdomain = getLeftMostSubdomain(url);
const subdomain = leftMostSubdomain ? `(?!${leftMostSubdomain})[^.]+` : "[^.]+";
return subdomain;
};

// For app.cal.com, it will match all domains that are not starting with "app". Technically we would want to match domains like acme.cal.com, dunder.cal.com and not app.cal.com
const getRegExpThatMatchesAllOrgDomains = (exports.getRegExpThatMatchesAllOrgDomains = ({ webAppUrl }) => {
if (isSingleOrgModeEnabled) {
console.log("Single-Org-Mode enabled - Consider all domains to be org domains");
// It works in combination with next.config.js where in this case we use orgSlug=NEXT_PUBLIC_SINGLE_ORG_SLUG
return `.*`;
}
const subdomainRegExp = getRegExpNotMatchingLeftMostSubdomain(webAppUrl);
return `^(?<${orgSlugCaptureGroupName}>${subdomainRegExp})\\.(?!vercel\.app).*`;
});

const nextJsOrgRewriteConfig = {
// :orgSlug is special value which would get matching group from the regex in orgHostPath
orgSlug: process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG || `:${orgSlugCaptureGroupName}`,
orgHostPath: getRegExpThatMatchesAllOrgDomains({
webAppUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`,
}),
// We disable root path rewrite because we want to serve dashboard on root path
disableRootPathRewrite: isSingleOrgModeEnabled,
};

exports.nextJsOrgRewriteConfig = nextJsOrgRewriteConfig;
15 changes: 0 additions & 15 deletions apps/web/getSubdomainRegExp.js

This file was deleted.

170 changes: 91 additions & 79 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ const { withSentryConfig } = require("@sentry/nextjs");
const { version } = require("./package.json");
const { i18n } = require("./next-i18next.config");
const {
orgHostPath,
nextJsOrgRewriteConfig,
orgUserRoutePath,
orgUserTypeRoutePath,
orgUserTypeEmbedRoutePath,
} = require("./pagesAndRewritePaths");

if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
const isOrganizationsEnabled =
Expand Down Expand Up @@ -119,55 +118,60 @@ if (process.env.ANALYZE === "true") {
}

plugins.push(withAxiom);
const orgDomainMatcherConfig = {
root: nextJsOrgRewriteConfig.disableRootPathRewrite
? null
: {
has: [
{
type: "host",
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
source: "/",
},

const matcherConfigRootPath = {
has: [
{
type: "host",
value: orgHostPath,
},
],
source: "/",
};

const matcherConfigRootPathEmbed = {
has: [
{
type: "host",
value: orgHostPath,
},
],
Comment on lines -123 to -139
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clubbed them under a single object.

source: "/embed",
};
rootEmbed: nextJsOrgRewriteConfig.disableRootEmbedPathRewrite
? null
: {
has: [
{
type: "host",
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
source: "/embed",
},

const matcherConfigUserRoute = {
has: [
{
type: "host",
value: orgHostPath,
},
],
source: orgUserRoutePath,
};
user: {
has: [
{
type: "host",
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
source: orgUserRoutePath,
},

const matcherConfigUserTypeRoute = {
has: [
{
type: "host",
value: orgHostPath,
},
],
source: orgUserTypeRoutePath,
};
userType: {
has: [
{
type: "host",
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
source: orgUserTypeRoutePath,
},

const matcherConfigUserTypeEmbedRoute = {
has: [
{
type: "host",
value: orgHostPath,
},
],
source: orgUserTypeEmbedRoutePath,
userTypeEmbed: {
has: [
{
type: "host",
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
source: orgUserTypeEmbedRoutePath,
},
};

/** @type {import("next").NextConfig} */
Expand Down Expand Up @@ -287,6 +291,7 @@ const nextConfig = {
return config;
},
async rewrites() {
const { orgSlug } = nextJsOrgRewriteConfig;
const beforeFiles = [
{
source: "/forms/:formQuery*",
Expand Down Expand Up @@ -322,29 +327,33 @@ const nextConfig = {
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
...(isOrganizationsEnabled
? [
orgDomainMatcherConfig.root
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allows to conditionally enable rewrite to organization for root path.

We disable it for single domain setup of org

? {
...orgDomainMatcherConfig.root,
destination: `/team/${orgSlug}?isOrgProfile=1`,
}
: null,
orgDomainMatcherConfig.rootEmbed
? {
...orgDomainMatcherConfig.rootEmbed,
destination: `/team/${orgSlug}/embed?isOrgProfile=1`,
}
: null,
{
...matcherConfigRootPath,
destination: "/team/:orgSlug?isOrgProfile=1",
...orgDomainMatcherConfig.user,
destination: `/org/${orgSlug}/:user`,
},
{
...matcherConfigRootPathEmbed,
destination: "/team/:orgSlug/embed?isOrgProfile=1",
...orgDomainMatcherConfig.userType,
destination: `/org/${orgSlug}/:user/:type`,
},
{
...matcherConfigUserRoute,
destination: "/org/:orgSlug/:user",
},
{
...matcherConfigUserTypeRoute,
destination: "/org/:orgSlug/:user/:type",
},
{
...matcherConfigUserTypeEmbedRoute,
destination: "/org/:orgSlug/:user/:type/embed",
...orgDomainMatcherConfig.userTypeEmbed,
destination: `/org/${orgSlug}/:user/:type/embed`,
},
]
: []),
];
].filter(Boolean);

let afterFiles = [
{
Expand Down Expand Up @@ -388,6 +397,7 @@ const nextConfig = {
};
},
async headers() {
const { orgSlug } = nextJsOrgRewriteConfig;
// This header can be set safely as it ensures the browser will load the resources even when COEP is set.
// But this header must be set only on those resources that are safe to be loaded in a cross-origin context e.g. all embeddable pages's resources
const CORP_CROSS_ORIGIN_HEADER = {
Expand Down Expand Up @@ -474,45 +484,47 @@ const nextConfig = {
],
...(isOrganizationsEnabled
? [
orgDomainMatcherConfig.root
? {
...orgDomainMatcherConfig.root,
headers: [
{
key: "X-Cal-Org-path",
value: `/team/${orgSlug}`,
},
],
}
: null,
{
...matcherConfigRootPath,
headers: [
{
key: "X-Cal-Org-path",
value: "/team/:orgSlug",
},
],
},
{
...matcherConfigUserRoute,
...orgDomainMatcherConfig.user,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user",
value: `/org/${orgSlug}/:user`,
},
],
},
{
...matcherConfigUserTypeRoute,
...orgDomainMatcherConfig.userType,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type",
value: `/org/${orgSlug}/:user/:type`,
},
],
},
{
...matcherConfigUserTypeEmbedRoute,
...orgDomainMatcherConfig.userTypeEmbed,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type/embed",
value: `/org/${orgSlug}/:user/:type/embed`,
},
],
},
]
: []),
];
].filter(Boolean);
},
async redirects() {
const redirects = [
Expand Down Expand Up @@ -592,7 +604,7 @@ const nextConfig = {
{
type: "header",
key: "host",
value: orgHostPath,
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
destination: "/event-types?openPlain=true",
Expand Down
7 changes: 2 additions & 5 deletions apps/web/pagesAndRewritePaths.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const glob = require("glob");
const { getSubdomainRegExp } = require("./getSubdomainRegExp");
const { nextJsOrgRewriteConfig } = require("./getNextjsOrgRewriteConfig");
/** Needed to rewrite public booking page, gets all static pages but [user] */
// Pages found here are excluded from redirects in beforeFiles in next.config.js
let pages = (exports.pages = glob
Expand Down Expand Up @@ -35,10 +35,7 @@ let pages = (exports.pages = glob
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked

let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`
));
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
exports.nextJsOrgRewriteConfig = nextJsOrgRewriteConfig;

/**
* Returns a regex that matches all existing routes, virtual routes (like /forms, /router, /success etc) and nextjs special paths (_next, public)
Expand Down
Loading
Loading