Skip to content

Commit 6f531d0

Browse files
feat: Single domain setup when only one org in the system (calcom#18383)
Co-authored-by: Omar López <zomars@me.com>
1 parent d7b723c commit 6f531d0

8 files changed

+208
-138
lines changed

.env.example

+5
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,8 @@ REPLEXICA_API_KEY=
415415

416416
# 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.
417417
DIRECTORY_IDS_TO_LOG=
418+
419+
420+
# Set this when Cal.com is used to serve only one organization's booking pages
421+
# Read more about it in the README.md
422+
NEXT_PUBLIC_SINGLE_ORG_SLUG=

README.md

+9
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,15 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
485485

486486
- 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.
487487

488+
## Single Org Mode
489+
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).
490+
- 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`
491+
- 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`.
492+
- 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`.
493+
- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`.
494+
495+
Note: It causes root to serve the dashboard and not the organization profile page which shows all bookable users in the organization.
496+
488497
## Integrations
489498

490499
### Obtaining the Google API Credentials

apps/web/getNextjsOrgRewriteConfig.js

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
const isSingleOrgModeEnabled = !!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG;
2+
const orgSlugCaptureGroupName = "orgSlug";
3+
/**
4+
* Returns the leftmost subdomain from a given URL.
5+
* It needs the URL domain to have atleast two dots.
6+
* app.cal.com -> app
7+
* app.company.cal.com -> app
8+
* app.company.com -> app
9+
*/
10+
const getLeftMostSubdomain = (url) => {
11+
if (!url.startsWith("http:") && !url.startsWith("https:")) {
12+
// Make it a valid URL. Mabe we can simply return null and opt-out from orgs support till the use a URL scheme.
13+
url = `https://${url}`;
14+
}
15+
const _url = new URL(url);
16+
const regex = new RegExp(/^([a-z]+\:\/{2})?((?<subdomain>[\w-.]+)\.[\w-]+\.\w+)$/);
17+
//console.log(_url.hostname, _url.hostname.match(regex));
18+
return _url.hostname.match(regex)?.groups?.subdomain || null;
19+
};
20+
21+
const getRegExpNotMatchingLeftMostSubdomain = (url) => {
22+
const leftMostSubdomain = getLeftMostSubdomain(url);
23+
const subdomain = leftMostSubdomain ? `(?!${leftMostSubdomain})[^.]+` : "[^.]+";
24+
return subdomain;
25+
};
26+
27+
// 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
28+
const getRegExpThatMatchesAllOrgDomains = (exports.getRegExpThatMatchesAllOrgDomains = ({ webAppUrl }) => {
29+
if (isSingleOrgModeEnabled) {
30+
console.log("Single-Org-Mode enabled - Consider all domains to be org domains");
31+
// It works in combination with next.config.js where in this case we use orgSlug=NEXT_PUBLIC_SINGLE_ORG_SLUG
32+
return `.*`;
33+
}
34+
const subdomainRegExp = getRegExpNotMatchingLeftMostSubdomain(webAppUrl);
35+
return `^(?<${orgSlugCaptureGroupName}>${subdomainRegExp})\\.(?!vercel\.app).*`;
36+
});
37+
38+
const nextJsOrgRewriteConfig = {
39+
// :orgSlug is special value which would get matching group from the regex in orgHostPath
40+
orgSlug: process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG || `:${orgSlugCaptureGroupName}`,
41+
orgHostPath: getRegExpThatMatchesAllOrgDomains({
42+
webAppUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`,
43+
}),
44+
// We disable root path rewrite because we want to serve dashboard on root path
45+
disableRootPathRewrite: isSingleOrgModeEnabled,
46+
};
47+
48+
exports.nextJsOrgRewriteConfig = nextJsOrgRewriteConfig;

apps/web/getSubdomainRegExp.js

-15
This file was deleted.

apps/web/next.config.js

+91-79
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ const { withSentryConfig } = require("@sentry/nextjs");
77
const { version } = require("./package.json");
88
const { i18n } = require("./next-i18next.config");
99
const {
10-
orgHostPath,
10+
nextJsOrgRewriteConfig,
1111
orgUserRoutePath,
1212
orgUserTypeRoutePath,
1313
orgUserTypeEmbedRoutePath,
1414
} = require("./pagesAndRewritePaths");
15-
1615
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
1716
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
1817
const isOrganizationsEnabled =
@@ -119,55 +118,60 @@ if (process.env.ANALYZE === "true") {
119118
}
120119

121120
plugins.push(withAxiom);
121+
const orgDomainMatcherConfig = {
122+
root: nextJsOrgRewriteConfig.disableRootPathRewrite
123+
? null
124+
: {
125+
has: [
126+
{
127+
type: "host",
128+
value: nextJsOrgRewriteConfig.orgHostPath,
129+
},
130+
],
131+
source: "/",
132+
},
122133

123-
const matcherConfigRootPath = {
124-
has: [
125-
{
126-
type: "host",
127-
value: orgHostPath,
128-
},
129-
],
130-
source: "/",
131-
};
132-
133-
const matcherConfigRootPathEmbed = {
134-
has: [
135-
{
136-
type: "host",
137-
value: orgHostPath,
138-
},
139-
],
140-
source: "/embed",
141-
};
134+
rootEmbed: nextJsOrgRewriteConfig.disableRootEmbedPathRewrite
135+
? null
136+
: {
137+
has: [
138+
{
139+
type: "host",
140+
value: nextJsOrgRewriteConfig.orgHostPath,
141+
},
142+
],
143+
source: "/embed",
144+
},
142145

143-
const matcherConfigUserRoute = {
144-
has: [
145-
{
146-
type: "host",
147-
value: orgHostPath,
148-
},
149-
],
150-
source: orgUserRoutePath,
151-
};
146+
user: {
147+
has: [
148+
{
149+
type: "host",
150+
value: nextJsOrgRewriteConfig.orgHostPath,
151+
},
152+
],
153+
source: orgUserRoutePath,
154+
},
152155

153-
const matcherConfigUserTypeRoute = {
154-
has: [
155-
{
156-
type: "host",
157-
value: orgHostPath,
158-
},
159-
],
160-
source: orgUserTypeRoutePath,
161-
};
156+
userType: {
157+
has: [
158+
{
159+
type: "host",
160+
value: nextJsOrgRewriteConfig.orgHostPath,
161+
},
162+
],
163+
source: orgUserTypeRoutePath,
164+
},
162165

163-
const matcherConfigUserTypeEmbedRoute = {
164-
has: [
165-
{
166-
type: "host",
167-
value: orgHostPath,
168-
},
169-
],
170-
source: orgUserTypeEmbedRoutePath,
166+
userTypeEmbed: {
167+
has: [
168+
{
169+
type: "host",
170+
value: nextJsOrgRewriteConfig.orgHostPath,
171+
},
172+
],
173+
source: orgUserTypeEmbedRoutePath,
174+
},
171175
};
172176

173177
/** @type {import("next").NextConfig} */
@@ -287,6 +291,7 @@ const nextConfig = {
287291
return config;
288292
},
289293
async rewrites() {
294+
const { orgSlug } = nextJsOrgRewriteConfig;
290295
const beforeFiles = [
291296
{
292297
source: "/forms/:formQuery*",
@@ -322,29 +327,33 @@ const nextConfig = {
322327
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
323328
...(isOrganizationsEnabled
324329
? [
330+
orgDomainMatcherConfig.root
331+
? {
332+
...orgDomainMatcherConfig.root,
333+
destination: `/team/${orgSlug}?isOrgProfile=1`,
334+
}
335+
: null,
336+
orgDomainMatcherConfig.rootEmbed
337+
? {
338+
...orgDomainMatcherConfig.rootEmbed,
339+
destination: `/team/${orgSlug}/embed?isOrgProfile=1`,
340+
}
341+
: null,
325342
{
326-
...matcherConfigRootPath,
327-
destination: "/team/:orgSlug?isOrgProfile=1",
343+
...orgDomainMatcherConfig.user,
344+
destination: `/org/${orgSlug}/:user`,
328345
},
329346
{
330-
...matcherConfigRootPathEmbed,
331-
destination: "/team/:orgSlug/embed?isOrgProfile=1",
347+
...orgDomainMatcherConfig.userType,
348+
destination: `/org/${orgSlug}/:user/:type`,
332349
},
333350
{
334-
...matcherConfigUserRoute,
335-
destination: "/org/:orgSlug/:user",
336-
},
337-
{
338-
...matcherConfigUserTypeRoute,
339-
destination: "/org/:orgSlug/:user/:type",
340-
},
341-
{
342-
...matcherConfigUserTypeEmbedRoute,
343-
destination: "/org/:orgSlug/:user/:type/embed",
351+
...orgDomainMatcherConfig.userTypeEmbed,
352+
destination: `/org/${orgSlug}/:user/:type/embed`,
344353
},
345354
]
346355
: []),
347-
];
356+
].filter(Boolean);
348357

349358
let afterFiles = [
350359
{
@@ -388,6 +397,7 @@ const nextConfig = {
388397
};
389398
},
390399
async headers() {
400+
const { orgSlug } = nextJsOrgRewriteConfig;
391401
// This header can be set safely as it ensures the browser will load the resources even when COEP is set.
392402
// 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
393403
const CORP_CROSS_ORIGIN_HEADER = {
@@ -474,45 +484,47 @@ const nextConfig = {
474484
],
475485
...(isOrganizationsEnabled
476486
? [
487+
orgDomainMatcherConfig.root
488+
? {
489+
...orgDomainMatcherConfig.root,
490+
headers: [
491+
{
492+
key: "X-Cal-Org-path",
493+
value: `/team/${orgSlug}`,
494+
},
495+
],
496+
}
497+
: null,
477498
{
478-
...matcherConfigRootPath,
479-
headers: [
480-
{
481-
key: "X-Cal-Org-path",
482-
value: "/team/:orgSlug",
483-
},
484-
],
485-
},
486-
{
487-
...matcherConfigUserRoute,
499+
...orgDomainMatcherConfig.user,
488500
headers: [
489501
{
490502
key: "X-Cal-Org-path",
491-
value: "/org/:orgSlug/:user",
503+
value: `/org/${orgSlug}/:user`,
492504
},
493505
],
494506
},
495507
{
496-
...matcherConfigUserTypeRoute,
508+
...orgDomainMatcherConfig.userType,
497509
headers: [
498510
{
499511
key: "X-Cal-Org-path",
500-
value: "/org/:orgSlug/:user/:type",
512+
value: `/org/${orgSlug}/:user/:type`,
501513
},
502514
],
503515
},
504516
{
505-
...matcherConfigUserTypeEmbedRoute,
517+
...orgDomainMatcherConfig.userTypeEmbed,
506518
headers: [
507519
{
508520
key: "X-Cal-Org-path",
509-
value: "/org/:orgSlug/:user/:type/embed",
521+
value: `/org/${orgSlug}/:user/:type/embed`,
510522
},
511523
],
512524
},
513525
]
514526
: []),
515-
];
527+
].filter(Boolean);
516528
},
517529
async redirects() {
518530
const redirects = [
@@ -592,7 +604,7 @@ const nextConfig = {
592604
{
593605
type: "header",
594606
key: "host",
595-
value: orgHostPath,
607+
value: nextJsOrgRewriteConfig.orgHostPath,
596608
},
597609
],
598610
destination: "/event-types?openPlain=true",

apps/web/pagesAndRewritePaths.js

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const glob = require("glob");
2-
const { getSubdomainRegExp } = require("./getSubdomainRegExp");
2+
const { nextJsOrgRewriteConfig } = require("./getNextjsOrgRewriteConfig");
33
/** Needed to rewrite public booking page, gets all static pages but [user] */
44
// Pages found here are excluded from redirects in beforeFiles in next.config.js
55
let pages = (exports.pages = glob
@@ -35,10 +35,7 @@ let pages = (exports.pages = glob
3535
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
3636
// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked
3737

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

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

0 commit comments

Comments
 (0)