feat: autolink org on provider signup (#11267)
* feat: autolink org on provider signup * Adding env var in example * slugifying inferred org username * Updated org README * Optional env var for autolinking added to readme * Typo * md syntax correction * Verifying user when autolinked * Also verifying users invited to org * Applying feedback * Scoping to Google for now * Updating comment * Updating readme with Google scoping
This commit is contained in:
parent
84408025ed
commit
86af383f6c
|
@ -213,6 +213,10 @@ NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||||
# use organizations
|
# use organizations
|
||||||
ORGANIZATIONS_ENABLED=
|
ORGANIZATIONS_ENABLED=
|
||||||
|
|
||||||
|
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
|
||||||
|
# existing organizations based on email domain address
|
||||||
|
ORGANIZATIONS_AUTOLINK=
|
||||||
|
|
||||||
# Vercel Config to create subdomains for organizations
|
# Vercel Config to create subdomains for organizations
|
||||||
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
|
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
|
||||||
PROJECT_ID_VERCEL=
|
PROJECT_ID_VERCEL=
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { isENVDev } from "@calcom/lib/env";
|
||||||
import { randomString } from "@calcom/lib/random";
|
import { randomString } from "@calcom/lib/random";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
|
||||||
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { ErrorCode } from "./ErrorCode";
|
import { ErrorCode } from "./ErrorCode";
|
||||||
|
@ -31,6 +31,8 @@ const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET } =
|
||||||
JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {};
|
JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {};
|
||||||
const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true";
|
const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true";
|
||||||
const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED);
|
const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED);
|
||||||
|
const ORGANIZATIONS_AUTOLINK =
|
||||||
|
process.env.ORGANIZATIONS_AUTOLINK === "1" || process.env.ORGANIZATIONS_AUTOLINK === "true";
|
||||||
|
|
||||||
const usernameSlug = (username: string) => slugify(username) + "-" + randomString(6).toLowerCase();
|
const usernameSlug = (username: string) => slugify(username) + "-" + randomString(6).toLowerCase();
|
||||||
|
|
||||||
|
@ -54,6 +56,33 @@ export const checkIfUserBelongsToActiveTeam = <T extends UserTeams>(user: T) =>
|
||||||
return metadata.success && metadata.data?.subscriptionId;
|
return metadata.success && metadata.data?.subscriptionId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const checkIfUserShouldBelongToOrg = async (idP: IdentityProvider, email: string) => {
|
||||||
|
const [orgUsername, apexDomain] = email.split("@");
|
||||||
|
if (!ORGANIZATIONS_AUTOLINK || idP !== "GOOGLE") return { orgUsername, orgId: undefined };
|
||||||
|
const existingOrg = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
path: ["isOrganizationVerified"],
|
||||||
|
equals: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
path: ["orgAutoAcceptEmail"],
|
||||||
|
equals: apexDomain,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { orgUsername, orgId: existingOrg?.id };
|
||||||
|
};
|
||||||
|
|
||||||
const providers: Provider[] = [
|
const providers: Provider[] = [
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
id: "credentials",
|
id: "credentials",
|
||||||
|
@ -735,16 +764,26 @@ export const AUTH_OPTIONS: AuthOptions = {
|
||||||
return "/auth/error?error=use-identity-login";
|
return "/auth/error?error=use-identity-login";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Associate with organization if enabled by flag and idP is Google (for now)
|
||||||
|
const { orgUsername, orgId } = await checkIfUserShouldBelongToOrg(idP, user.email);
|
||||||
|
|
||||||
const newUser = await prisma.user.create({
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
// Slugify the incoming name and append a few random characters to
|
// Slugify the incoming name and append a few random characters to
|
||||||
// prevent conflicts for users with the same name.
|
// prevent conflicts for users with the same name.
|
||||||
username: usernameSlug(user.name),
|
username: orgId ? slugify(orgUsername) : usernameSlug(user.name),
|
||||||
emailVerified: new Date(Date.now()),
|
emailVerified: new Date(Date.now()),
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
identityProvider: idP,
|
identityProvider: idP,
|
||||||
identityProviderId: account.providerAccountId,
|
identityProviderId: account.providerAccountId,
|
||||||
|
...(orgId && {
|
||||||
|
verified: true,
|
||||||
|
organization: { connect: { id: orgId } },
|
||||||
|
teams: {
|
||||||
|
create: { role: MembershipRole.MEMBER, accepted: true, team: { connect: { id: orgId } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -4,34 +4,31 @@ From the [Original RFC](https://github.com/calcom/cal.com/issues/7142):
|
||||||
|
|
||||||
> We want to create organisations within Cal.com to enable people to easily and effectively manage multiple teams. An organisation will live above the current teams layer.
|
> We want to create organisations within Cal.com to enable people to easily and effectively manage multiple teams. An organisation will live above the current teams layer.
|
||||||
|
|
||||||
## Quick start
|
## App setup
|
||||||
|
|
||||||
1. Set `ORGANIZATIONS_ENABLED=1` in .env
|
1. Log in as admin and in Settings, turn on Organizations feature flag under Features section. Organizations feature has an operational feature flag in order to turn on the entire feature.
|
||||||
1. Add `app.cal.local` to your host file, either:
|
|
||||||
a. `sudo npx hostile app.cal.local`
|
|
||||||
b. Add it yourself
|
|
||||||
1. Add `acme.cal.local` to host file (I use this as an org name for testing public URLS)
|
|
||||||
1. Visit `/settings/organizations/new` → follow setup steps with the slug matching the org slug from step 3
|
|
||||||
1. You should now be in ORG context.
|
|
||||||
1. You may or may not need the feature flag enabled.
|
|
||||||
|
|
||||||
## Environment variables
|
2. Set the following environment variables as described:
|
||||||
|
1. **`CALCOM_LICENSE_KEY`**: Since Organizations is an EE feature, a license key should be present, either as this environment variable or visiting as an Admin `/auth/setup`. To get a license key you should visit Cal Console ([prod](https://console.cal.com) or [dev](https://console.cal.dev))
|
||||||
|
2. **`NEXT_PUBLIC_WEBAPP_URL`**: In case of local development, this variable should be set to `https://app.cal.local:3000` to be able to handle subdomains correctly in terms of authentication and cookies
|
||||||
|
3. **`NEXTAUTH_URL`**: Should be equal to `NEXT_PUBLIC_WEBAPP_URL` which is `https://app.cal.local:3000`
|
||||||
|
4. **`NEXTAUTH_COOKIE_DOMAIN`**: In case of local development, this variable should be set to `.cal.local` to be able to accept session cookies in subdomains as well otherwise it should be set to the corresponding environment such as `.cal.dev`, `.cal.qa` or `.cal.com`. If you choose another subdomain, the value for this should match the apex domain of `NEXT_PUBLIC_WEBAPP_URL` with a leading dot (`.`)
|
||||||
|
5. **`ORGANIZATIONS_ENABLED`**: Should be set to `1` or `true`
|
||||||
|
6. **`STRIPE_ORG_MONTHLY_PRICE_ID`**: For dev and all testing should be set to your own testing key. Or ask for the shared key if you're a core member.
|
||||||
|
7. **`ORGANIZATIONS_AUTOLINK`**: Optional. Set to `1` or `true` to let new signed-up users using Google external provider join the corresponding organization based on the email domain name.
|
||||||
|
|
||||||
`CALCOM_LICENSE_KEY`: Since Organizations is an EE feature, a license key should be present, either as this environment variable or visiting as an Admin `/auth/setup`
|
3. Add `app.cal.local` to your host file, either:
|
||||||
`NEXT_PUBLIC_WEBAPP_URL`: In case of local development, this variable should be set to `https://app.cal.local:3000` to be able to handle subdomains
|
1. `sudo npx hostile app.cal.local`
|
||||||
`NEXTAUTH_URL`: Should be equal to `NEXT_PUBLIC_WEBAPP_URL`
|
2. Add it yourself
|
||||||
`NEXTAUTH_COOKIE_DOMAIN`: In case of local development, this variable should be set to `.cal.local` to be able to accept session cookies in subdomains as well otherwise it should be set to the corresponding environment such as `.cal.dev`, `.cal.qa` or `.cal.com`
|
|
||||||
`ORGANIZATIONS_ENABLED`: Should be set to `1`
|
|
||||||
`STRIPE_ORG_MONTHLY_PRICE_ID`: For dev and all testing should be set to your own testing key. Or ask for the shared key if you're a core member.
|
|
||||||
|
|
||||||
## Feature flag
|
4. Add `acme.cal.local` to host file given that the org create for it will be `acme`, otherwise do this for whatever slug will be assigned to the org. This is needed to test org-related public URLs, such as sub-teams, members and event-types.
|
||||||
|
|
||||||
Organizations has an operational feature flag in order to turn on the entire feature, be sure to log in as Admin and visit Features section in Settings to turn on/off this feature.
|
5. Be sure to be logged in with any type of user and visit `/settings/organizations/new` and follow setup steps with the slug matching the org slug from step 3
|
||||||
|
|
||||||
## Domain setup
|
6. Log in as admin and go to Settings and under Organizations you will need to accept the newly created organization in order to be operational
|
||||||
|
|
||||||
When a new organization is created, a subdomain can be used with Cal App to show public profiles for the organization per se, their teams and their members.
|
7. After finishing the org creation, you will be automatically logged in as the owner of the organization, and the app will be shown in organization mode
|
||||||
|
|
||||||
When working locally, the app works under the subdomain `app.cal.local:3000` and any organization works under `acme.cal.local:3000` which will need to have acme.cal.local mapped in your system hosts file to point to `127.0.0.1` to be able to see the mentioned public profiles.
|
## DNS setup
|
||||||
|
|
||||||
When working in any other environment, the subdomain registration works with Vercel API, to assign the organization subdomain like acme.cal.dev to work with the app. Depending on whether the domain's DNS such as cal.dev in the previous example is being managed by Vercel or an external service such as Cloudflare, the subdomain registration may need manual steps. This is going to be automated in the near future.
|
When a new organization is created, other than not being verified up until the admin accepts it in settings as explained in step 6, a flag gets created that marks the organization as missing DNS setup. That flag get auto-checked by the system upon organization creation when the Cal instance is deployed in Vercel and the subdomain registration was successful. Logging in as admin and going to Settings > Organizations section, you will see that flag as a badge, designed to give admins a glimpe on what is pending in terms of making an organization work. Alongside the mentioned badge, an email gets sent to admins in order to warn them there is a pending action about setting up DNS for the newly created organization to work.
|
||||||
|
|
|
@ -15,7 +15,7 @@ const OrganizationAvatar = ({ size, imageSrc, alt, organizationSlug, ...rest }:
|
||||||
indicator={
|
indicator={
|
||||||
organizationSlug ? (
|
organizationSlug ? (
|
||||||
<div
|
<div
|
||||||
className={classNames("absolute bottom-0 right-0 z-10", size === "lg" ? "h-3 w-3" : "h-10 w-10")}>
|
className={classNames("absolute bottom-0 right-0 z-10", size === "lg" ? "h-6 w-6" : "h-10 w-10")}>
|
||||||
<img
|
<img
|
||||||
src={`/org/${organizationSlug}/avatar.png`}
|
src={`/org/${organizationSlug}/avatar.png`}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
|
|
|
@ -148,7 +148,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
||||||
organization: {
|
organization: {
|
||||||
create: {
|
create: {
|
||||||
name,
|
name,
|
||||||
...(IS_TEAM_BILLING_ENABLED ? { slug } : {}),
|
...(!IS_TEAM_BILLING_ENABLED ? { slug } : {}),
|
||||||
metadata: {
|
metadata: {
|
||||||
...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}),
|
...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}),
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
|
|
|
@ -148,6 +148,7 @@ export async function createNewUserConnectToOrgIfExists({
|
||||||
const createdUser = await prisma.user.create({
|
const createdUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: usernameOrEmail,
|
email: usernameOrEmail,
|
||||||
|
verified: true,
|
||||||
invitedTo: input.teamId,
|
invitedTo: input.teamId,
|
||||||
organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org
|
organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org
|
||||||
teams: {
|
teams: {
|
||||||
|
|
|
@ -258,6 +258,7 @@
|
||||||
"NEXTAUTH_URL",
|
"NEXTAUTH_URL",
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
"ORGANIZATIONS_ENABLED",
|
"ORGANIZATIONS_ENABLED",
|
||||||
|
"ORGANIZATIONS_AUTOLINK",
|
||||||
"PAYMENT_FEE_FIXED",
|
"PAYMENT_FEE_FIXED",
|
||||||
"PAYMENT_FEE_PERCENTAGE",
|
"PAYMENT_FEE_PERCENTAGE",
|
||||||
"PLAYWRIGHT_HEADLESS",
|
"PLAYWRIGHT_HEADLESS",
|
||||||
|
|
Loading…
Reference in New Issue
Block a user