feat: More admin options for organizations (#12424)

* Add more features in org admin

* Pr feedback addressed
This commit is contained in:
Hariom Balhara 2023-12-19 18:31:22 +05:30 committed by GitHub
parent e3905f631f
commit 5886792285
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1082 additions and 164 deletions

View File

@ -249,6 +249,18 @@ PROJECT_ID_VERCEL=
TEAM_ID_VERCEL=
# Get it from: https://vercel.com/account/tokens
AUTH_BEARER_TOKEN_VERCEL=
# Add the main domain that you want to use for testing vercel domain management for organizations. This is necessary because WEBAPP_URL of local isn't a valid public domain
# Would create org1.example.com for an org with slug org1
# LOCAL_TESTING_DOMAIN_VERCEL="example.com"
## Set it to 1 if you use cloudflare to manage your DNS and would like us to manage the DNS for you for organizations
# CLOUDFLARE_DNS=1
## Get it from: https://dash.cloudflare.com/profile/api-tokens. Select Edit Zone template and choose a zone(your domain)
# AUTH_BEARER_TOKEN_CLOUDFLARE=
## Zone ID can be found in the Overview tab of your domain in Cloudflare
# CLOUDFLARE_ZONE_ID=
## It should usually work with the default value. This is the DNS CNAME record content to point to Vercel domain
# CLOUDFLARE_VERCEL_CNAME=cname.vercel-dns.com
# - APPLE CALENDAR
# Used for E2E tests on Apple Calendar

View File

@ -0,0 +1,9 @@
import OrgEditView from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = OrgEditView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -2081,9 +2081,11 @@
"admin_org_notification_email_cta": "Go to Organizations Admin Settings",
"org_has_been_processed": "Org has been processed",
"org_error_processing": "There has been an error processing this organization",
"orgs_page_description": "A list of all organizations. Accepting an organization will allow all users with that email domain to sign up WITHOUT email verifciation.",
"orgs_page_description": "A list of all organizations. Accepting an organization will allow all users with that email domain to sign up WITHOUT email verification.",
"unverified": "Unverified",
"verified": "Verified",
"dns_missing": "DNS Missing",
"dns_configured": "DNS Configured",
"mark_dns_configured": "Mark as DNS configured",
"value": "Value",
"your_organization_updated_sucessfully": "Your organization updated successfully",
@ -2156,6 +2158,12 @@
"enterprise_description": "Upgrade to Enterprise to create your Organization",
"create_your_org": "Create your Organization",
"create_your_org_description": "Upgrade to Enterprise and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"admin_delete_organization_description": "<ul><li>Teams that are member of this organization will also be deleted along with their event-types</li><li>Users that were part of the organization will not be deleted and their event-types will also remain intact.</li><li>Usernames would be changed to allow them to exist outside the organization</li></ul>",
"admin_delete_organization_title": "Delete organization?",
"published": "Published",
"unpublished": "Unpublished",
"publish": "Publish",
"org_publish_error": "Organization could not be published",
"troubleshooter_tooltip": "Open the troubleshooter and figure out what is wrong with your schedule",
"need_help": "Need help?",
"troubleshooter": "Troubleshooter",

View File

@ -1,6 +1,7 @@
import type { Prisma } from "@prisma/client";
import type { IncomingMessage } from "http";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import slugify from "@calcom/lib/slugify";
@ -90,6 +91,10 @@ export function getOrgDomainConfigFromHostname({
}
export function subdomainSuffix() {
if (!IS_PRODUCTION && process.env.LOCAL_TESTING_DOMAIN_VERCEL) {
// Allow testing with a valid domain so that we can test with deployment services like Vercel and Cloudflare locally.
return process.env.LOCAL_TESTING_DOMAIN_VERCEL;
}
const urlSplit = WEBAPP_URL.replace("https://", "")?.replace("http://", "").split(".");
return urlSplit.length === 3 ? urlSplit.slice(1).join(".") : urlSplit.join(".");
}

View File

@ -6,8 +6,3 @@ export function extractDomainFromEmail(email: string) {
} catch (ignore) {}
return out.split(".")[0];
}
export const extractDomainFromWebsiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(
"https://",
""
)?.replace("http://", "") as string;

View File

@ -0,0 +1,110 @@
import type { Team } from "@prisma/client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import NoSSR from "@calcom/core/components/NoSSR";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import { Button, Form, Meta, TextField, showToast } from "@calcom/ui";
import { getLayout } from "../../../../../settings/layouts/SettingsLayout";
import LicenseRequired from "../../../../common/components/LicenseRequired";
const paramsSchema = z.object({ id: z.coerce.number() });
const OrgEditPage = () => {
const params = useParamsWithFallback();
const parsedParams = paramsSchema.safeParse(params);
if (!parsedParams.success) return <div>Invalid id</div>;
return <OrgEditView orgId={parsedParams.data.id} />;
};
const OrgEditView = ({ orgId }: { orgId: number }) => {
const [org] = trpc.viewer.organizations.adminGet.useSuspenseQuery({ id: orgId });
return (
<LicenseRequired>
<Meta
title={`Editing organization: ${org.name}`}
description="Here you can edit a current organization."
/>
<NoSSR>
<OrgForm org={org} />
</NoSSR>
</LicenseRequired>
);
};
type FormValues = {
name: Team["name"];
slug: Team["slug"];
metadata: z.infer<typeof teamMetadataSchema>;
};
const OrgForm = ({
org,
}: {
org: FormValues & {
id: Team["id"];
};
}) => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const mutation = trpc.viewer.organizations.adminUpdate.useMutation({
onSuccess: async () => {
await Promise.all([
utils.viewer.organizations.adminGetAll.invalidate(),
utils.viewer.organizations.adminGet.invalidate({
id: org.id,
}),
]);
showToast(t("org_has_been_processed"), "success");
router.replace(`/settings/admin/organizations`);
},
onError: (err) => {
console.error(err.message);
showToast(t("org_error_processing"), "error");
},
});
const form = useForm<FormValues>({
defaultValues: org,
});
const onSubmit = (values: FormValues) => {
mutation.mutate({
id: org.id,
...values,
});
};
return (
<Form form={form} className="space-y-4" handleSubmit={onSubmit}>
<TextField label="Name" placeholder="example" required {...form.register("name")} />
<TextField label="Slug" placeholder="example" required {...form.register("slug")} />
<p className="text-default mt-2 text-sm">
Changing the slug would delete the previous organization domain and DNS and setup new domain and DNS
for the organization.
</p>
<TextField
label="Domain for which invitations are auto-accepted"
placeholder="abc.com"
required
{...form.register("metadata.orgAutoAcceptEmail")}
/>
<Button type="submit" color="primary" loading={mutation.isLoading}>
{t("save")}
</Button>
</Form>
);
};
OrgEditPage.getLayout = getLayout;
export default OrgEditPage;

View File

@ -1,14 +1,25 @@
"use client";
import { Trans } from "next-i18next";
import { useState } from "react";
import NoSSR from "@calcom/core/components/NoSSR";
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Meta, DropdownActions, showToast, Table, Badge } from "@calcom/ui";
import { X, Check, CheckCheck } from "@calcom/ui/components/icon";
import {
Meta,
DropdownActions,
showToast,
Table,
Badge,
ConfirmationDialogContent,
Dialog,
} from "@calcom/ui";
import { Check, CheckCheck, Trash, Edit, BookOpenCheck } from "@calcom/ui/components/icon";
import { getLayout } from "../../../../../settings/layouts/SettingsLayout";
import { subdomainSuffix } from "../../../../organizations/lib/orgDomains";
const { Body, Cell, ColumnTitle, Header, Row } = Table;
@ -17,19 +28,21 @@ function AdminOrgTable() {
const utils = trpc.useContext();
const [data] = trpc.viewer.organizations.adminGetAll.useSuspenseQuery();
const verifyMutation = trpc.viewer.organizations.adminVerify.useMutation({
onSuccess: async () => {
onSuccess: async (_data, variables) => {
showToast(t("org_has_been_processed"), "success");
await utils.viewer.organizations.adminGetAll.invalidate();
await invalidateQueries(utils, variables);
},
onError: (err) => {
console.error(err.message);
showToast(t("org_error_processing"), "error");
},
});
const updateMutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async () => {
const updateMutation = trpc.viewer.organizations.adminUpdate.useMutation({
onSuccess: async (_data, variables) => {
showToast(t("org_has_been_processed"), "success");
await utils.viewer.organizations.adminGetAll.invalidate();
await invalidateQueries(utils, {
orgId: variables.id,
});
},
onError: (err) => {
console.error(err.message);
@ -37,12 +50,39 @@ function AdminOrgTable() {
},
});
const deleteMutation = trpc.viewer.organizations.adminDelete.useMutation({
onSuccess: async (res, variables) => {
showToast(res.message, "success");
await invalidateQueries(utils, variables);
},
onError: (err) => {
console.error(err.message);
showToast(t("org_error_processing"), "error");
},
});
const publishOrg = async (org: (typeof data)[number]) => {
if (!org.metadata?.requestedSlug) {
showToast(t("org_publish_error"), "error");
console.error("metadata.requestedSlug isn't set", org.metadata?.requestedSlug);
return;
}
updateMutation.mutate({
id: org.id,
slug: org.metadata.requestedSlug,
});
};
const [orgToDelete, setOrgToDelete] = useState<number | null>(null);
return (
<div>
<Table>
<Header>
<ColumnTitle widthClassNames="w-auto">{t("organization")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("owner")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("verified")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("dns_configured")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("published")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">
<span className="sr-only">{t("edit")}</span>
</ColumnTitle>
@ -56,7 +96,7 @@ function AdminOrgTable() {
<span className="text-default">{org.name}</span>
<br />
<span className="text-muted">
{org.slug}.{extractDomainFromWebsiteUrl}
{org.slug}.{subdomainSuffix()}
</span>
</div>
</Cell>
@ -67,67 +107,110 @@ function AdminOrgTable() {
</Cell>
<Cell>
<div className="space-x-2">
{!org.metadata?.isOrganizationVerified && <Badge variant="blue">{t("unverified")}</Badge>}
{!org.metadata?.isOrganizationConfigured && <Badge variant="red">{t("dns_missing")}</Badge>}
{!org.metadata?.isOrganizationVerified ? (
<Badge variant="red">{t("unverified")}</Badge>
) : (
<Badge variant="blue">{t("verified")}</Badge>
)}
</div>
</Cell>
<Cell>
<div className="space-x-2">
{org.metadata?.isOrganizationConfigured ? (
<Badge variant="blue">{t("dns_configured")}</Badge>
) : (
<Badge variant="red">{t("dns_missing")}</Badge>
)}
</div>
</Cell>
<Cell>
<div className="space-x-2">
{!org.slug ? (
<Badge variant="red">{t("unpublished")}</Badge>
) : (
<Badge variant="green">{t("published")}</Badge>
)}
</div>
</Cell>
<Cell widthClassNames="w-auto">
<div className="flex w-full justify-end">
{(!org.metadata?.isOrganizationVerified || !org.metadata?.isOrganizationConfigured) && (
<DropdownActions
actions={[
...(!org.metadata?.isOrganizationVerified
? [
{
id: "accept",
label: t("accept"),
onClick: () => {
verifyMutation.mutate({
orgId: org.id,
status: "ACCEPT",
});
},
icon: Check,
<DropdownActions
actions={[
...(!org.metadata?.isOrganizationVerified
? [
{
id: "verify",
label: t("verify"),
onClick: () => {
verifyMutation.mutate({
orgId: org.id,
});
},
{
id: "reject",
label: t("reject"),
onClick: () => {
verifyMutation.mutate({
orgId: org.id,
status: "DENY",
});
},
icon: X,
icon: Check,
},
]
: []),
...(!org.metadata?.isOrganizationConfigured
? [
{
id: "dns",
label: t("mark_dns_configured"),
onClick: () => {
updateMutation.mutate({
id: org.id,
metadata: {
isOrganizationConfigured: true,
},
});
},
]
: []),
...(!org.metadata?.isOrganizationConfigured
? [
{
id: "dns",
label: t("mark_dns_configured"),
onClick: () => {
updateMutation.mutate({
orgId: org.id,
metadata: {
isOrganizationConfigured: true,
},
});
},
icon: CheckCheck,
icon: CheckCheck,
},
]
: []),
{
id: "edit",
label: t("edit"),
href: `/settings/admin/organizations/${org.id}/edit`,
icon: Edit,
},
...(!org.slug
? [
{
id: "publish",
label: t("publish"),
onClick: () => {
publishOrg(org);
},
]
: []),
]}
/>
)}
icon: BookOpenCheck,
},
]
: []),
{
id: "delete",
label: t("delete"),
onClick: () => {
setOrgToDelete(org.id);
},
icon: Trash,
},
]}
/>
</div>
</Cell>
</Row>
))}
</Body>
</Table>
<DeleteOrgDialog
orgId={orgToDelete}
onClose={() => setOrgToDelete(null)}
onConfirm={() => {
if (!orgToDelete) return;
deleteMutation.mutate({
orgId: orgToDelete,
});
}}
/>
</div>
);
}
@ -147,3 +230,53 @@ const AdminOrgList = () => {
AdminOrgList.getLayout = getLayout;
export default AdminOrgList;
const DeleteOrgDialog = ({
orgId,
onConfirm,
onClose,
}: {
orgId: number | null;
onConfirm: () => void;
onClose: () => void;
}) => {
const { t } = useLocale();
return (
// eslint-disable-next-line @typescript-eslint/no-empty-function -- noop
<Dialog name="delete-user" open={!!orgId} onOpenChange={(open) => (open ? () => {} : onClose())}>
<ConfirmationDialogContent
title={t("admin_delete_organization_title")}
confirmBtnText={t("delete")}
cancelBtnText={t("cancel")}
variety="danger"
onConfirm={onConfirm}>
<Trans
i18nKey="admin_delete_organization_description"
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>
Teams that are member of this organization will also be deleted along with their event-types
</li>
<li>
Users that were part of the organization will not be deleted and their event-types will also
remain intact.
</li>
<li>Usernames would be changed to allow them to exist outside the organization</li>
</ul>
</Trans>
</ConfirmationDialogContent>
</Dialog>
);
};
async function invalidateQueries(utils: ReturnType<typeof trpc.useContext>, data: { orgId: number }) {
await utils.viewer.organizations.adminGetAll.invalidate();
await utils.viewer.organizations.adminGet.invalidate({
id: data.orgId,
});
// Due to some super weird reason, just invalidate doesn't work, so do refetch as well.
await utils.viewer.organizations.adminGet.refetch({
id: data.orgId,
});
}

View File

@ -37,7 +37,7 @@ import {
import { ExternalLink, Link as LinkIcon, Trash2 } from "@calcom/ui/components/icon";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import { extractDomainFromWebsiteUrl } from "../../../organizations/lib/utils";
import { subdomainSuffix } from "../../../organizations/lib/orgDomains";
const regex = new RegExp("^[a-zA-Z0-9-]*$");
@ -225,9 +225,7 @@ const OtherTeamProfileView = () => {
label={t("team_url")}
value={value}
addOnLeading={
team?.parent
? `${team.parent.slug}.${extractDomainFromWebsiteUrl}/`
: `${WEBAPP_URL}/team/`
team?.parent ? `${team.parent.slug}.${subdomainSuffix()}/` : `${WEBAPP_URL}/team/`
}
onChange={(e) => {
form.clearErrors("slug");

View File

@ -3,7 +3,6 @@ import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -15,6 +14,7 @@ import { Alert, Button, Form, TextField } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { useOrgBranding } from "../../organizations/context/provider";
import { subdomainSuffix } from "../../organizations/lib/orgDomains";
import type { NewTeamFormValues } from "../lib/types";
const querySchema = z.object({
@ -129,7 +129,7 @@ export const CreateANewTeamForm = () => {
addOnLeading={`${
orgBranding
? `${orgBranding.fullDomain.replace("https://", "").replace("http://", "")}/`
: `${extractDomainFromWebsiteUrl}/team/`
: `${subdomainSuffix()}/team/`
}`}
value={value}
defaultValue={value}

View File

@ -0,0 +1,234 @@
import z from "zod";
import { HttpError } from "@calcom/lib/http-error";
import { safeStringify } from "@calcom/lib/safeStringify";
import logger from "../../logger";
const log = logger.getSubLogger({ prefix: ["cloudflare"] });
// TODO: This and other settings should really come from DB when admin allows configuring which deployment services to use for the organization
const IS_RECORD_PROXIED = true;
const AUTOMATIC_TTL = 1;
const ERROR_CODE_CNAME_ALREADY_EXISTS = 81053;
const ERROR_CODE_RECORD_ALREADY_EXISTS = 81057;
const ERROR_CODE_RECORD_DOES_NOT_EXIST = 81044;
const cloudflareApiForZoneUrl = `https://api.cloudflare.com/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}`;
const cloudflareDnsRecordApiResponseSchema = z
.object({
success: z.boolean().optional(),
errors: z
.array(
z
.object({
code: z.number(),
})
.passthrough()
)
.nullish(),
result: z
.object({
id: z.string(),
})
.nullish(),
})
.passthrough();
export const addDnsRecord = async (domain: string) => {
assertCloudflareEnvVars(process.env);
log.info(`Creating dns-record in Cloudflare: ${domain}`);
const data = await api(
`${cloudflareApiForZoneUrl}/dns_records`,
{
method: "POST",
body: JSON.stringify({
type: "CNAME",
proxied: IS_RECORD_PROXIED,
name: domain,
content: process.env.CLOUDFLARE_VERCEL_CNAME,
ttl: AUTOMATIC_TTL,
}),
},
cloudflareDnsRecordApiResponseSchema
);
if (!data.success) {
if (isRecordAlreadyExistError(data.errors)) {
log.info(`CNAME already exists in Cloudflare: ${domain}`);
return true;
}
const errorMessage = `Failed to create dns-record in Cloudflare: ${domain}`;
log.error(
safeStringify({
errorMessage,
response: data,
})
);
throw new HttpError({
message: errorMessage,
statusCode: 400,
});
}
log.info(`Created dns-record in Cloudflare: ${domain}`);
return true;
};
export const deleteDnsRecord = async (domain: string) => {
log.info(`Deleting dns-record in Cloudflare: ${domain}`);
assertCloudflareEnvVars(process.env);
const dnsRecordToDelete = await getDnsRecordToDelete();
await deleteDnsRecord(dnsRecordToDelete);
log.info(`Deleted dns-record in Cloudflare: ${domain}`);
return true;
async function getDnsRecordToDelete() {
// Get the dns-record id from dns_records list API
const searchResult = await api(
`${cloudflareApiForZoneUrl}/dns_records?name=${domain}`,
{
method: "GET",
},
z
.object({
success: z.boolean().optional(),
result: z
.array(
z
.object({
id: z.string(),
})
.passthrough()
)
.nullish(),
})
.passthrough()
);
if (!searchResult.success || !searchResult.result) {
log.error(
safeStringify({
errorMessage: `Failed to search for dns-record in Cloudflare for ${domain}`,
searchData: searchResult,
})
);
throw new HttpError({
message: `Something went wrong.`,
statusCode: 500,
});
}
if (searchResult.result.length > 1) {
log.error(
safeStringify({
errorMessage: `Found more than one dns-record in Cloudflare for ${domain}`,
searchData: searchResult,
})
);
throw new HttpError({
message: `Something went wrong.`,
statusCode: 400,
});
}
return searchResult.result[0];
}
async function deleteDnsRecord(dnsRecordToDelete: { id: string }) {
const deletionResult = await api(
`${cloudflareApiForZoneUrl}/dns_records/${dnsRecordToDelete.id}`,
{
method: "DELETE",
},
cloudflareDnsRecordApiResponseSchema
);
if (!deletionResult.success) {
if (isRecordNotExistingError(deletionResult.errors)) {
log.info(`CNAME already deleted: ${domain}`);
return true;
}
log.error(
`Failed to delete dns-record in Cloudflare: ${domain}`,
safeStringify({
deletionResult,
})
);
throw new HttpError({
message: "Something went wrong.",
statusCode: 400,
});
}
log.info(`Deleted dns-record in Cloudflare: ${domain}`);
return true;
}
};
async function api<T extends z.ZodType<unknown>>(
url: string,
{
method,
body,
}: {
body?: string;
method: "POST" | "GET" | "DELETE";
},
responseSchema: T
): Promise<z.infer<T>> {
const response = await fetch(url, {
method: method,
headers: {
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN_CLOUDFLARE}`,
"Content-Type": "application/json",
},
body,
});
const result = await response.json();
const dataParsed = responseSchema.safeParse(result);
if (!dataParsed.success) {
log.error(
"Error parsing",
safeStringify({
dnsAddResult: result,
})
);
throw new HttpError({
message: "Something went wrong",
statusCode: 500,
});
}
return dataParsed.data;
}
function assertCloudflareEnvVars(env: typeof process.env): asserts env is {
CLOUDFLARE_VERCEL_CNAME: string;
CLOUDFLARE_ZONE_ID: string;
AUTH_BEARER_TOKEN_CLOUDFLARE: string;
} & typeof process.env {
if (!env.CLOUDFLARE_VERCEL_CNAME) {
throw new Error("Missing env var: CLOUDFLARE_VERCEL_CNAME");
}
if (!env.CLOUDFLARE_ZONE_ID) {
throw new Error("Missing env var: CLOUDFLARE_ZONE_ID");
}
if (!env.AUTH_BEARER_TOKEN_CLOUDFLARE) {
throw new Error("Missing env var: AUTH_BEARER_TOKEN_CLOUDFLARE");
}
}
const isRecordAlreadyExistError = (errors: { code: number }[] | undefined | null) =>
errors?.every(
(error) =>
error.code === ERROR_CODE_CNAME_ALREADY_EXISTS || error.code === ERROR_CODE_RECORD_ALREADY_EXISTS
);
const isRecordNotExistingError = (errors: { code: number }[] | undefined | null) =>
errors?.every((error) => error.code === ERROR_CODE_RECORD_DOES_NOT_EXIST);

View File

@ -0,0 +1,147 @@
import z from "zod";
import { HttpError } from "@calcom/lib/http-error";
import { safeStringify } from "@calcom/lib/safeStringify";
import logger from "../../logger";
const vercelApiForProjectUrl = `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}`;
const vercelDomainApiResponseSchema = z.object({
error: z
.object({
code: z.string().nullish(),
domain: z.string().nullish(),
})
.optional(),
});
export const createDomain = async (domain: string) => {
assertVercelEnvVars(process.env);
logger.info(`Creating domain in Vercel: ${domain}`);
const response = await fetch(`${vercelApiForProjectUrl}/domains?teamId=${process.env.TEAM_ID_VERCEL}`, {
body: JSON.stringify({ name: domain }),
headers: {
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN_VERCEL}`,
"Content-Type": "application/json",
},
method: "POST",
});
const data = vercelDomainApiResponseSchema.parse(await response.json());
if (!data.error) {
return true;
}
return handleDomainCreationError(data.error);
};
export const deleteDomain = async (domain: string) => {
logger.info(`Deleting domain in Vercel: ${domain}`);
assertVercelEnvVars(process.env);
const response = await fetch(
`${vercelApiForProjectUrl}/domains/${domain}?teamId=${process.env.TEAM_ID_VERCEL}`,
{
headers: {
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN_VERCEL}`,
},
method: "DELETE",
}
);
const data = vercelDomainApiResponseSchema.parse(await response.json());
if (!data.error) {
return true;
}
return handleDomainDeletionError(data.error);
};
function handleDomainCreationError(error: { code?: string | null; domain?: string | null }) {
// Domain is already owned by another team but you can request delegation to access it
if (error.code === "forbidden") {
const errorMessage = "Domain is already owned by another team";
logger.error(
safeStringify({
errorMessage,
vercelError: error,
})
);
throw new HttpError({
message: errorMessage,
statusCode: 400,
});
}
if (error.code === "domain_taken") {
const errorMessage = "Domain is already being used by a different project";
logger.error(
safeStringify({
errorMessage,
vercelError: error,
})
);
throw new HttpError({
message: errorMessage,
statusCode: 400,
});
}
if (error.code === "domain_already_in_use") {
// Domain is already configured correctly, this is not an error when it happens during creation as it could be re-attempt to create an existing domain
return true;
}
const errorMessage = `Failed to take action for domain: ${error.domain}`;
logger.error(safeStringify({ errorMessage, vercelError: error }));
throw new HttpError({
message: errorMessage,
statusCode: 400,
});
}
function handleDomainDeletionError(error: { code?: string | null; domain?: string | null }) {
if (error.code === "not_found") {
// Domain is already deleted
return true;
}
// Domain is already owned by another team but you can request delegation to access it
if (error.code === "forbidden") {
const errorMessage = "Domain is owned by another team";
logger.error(
safeStringify({
errorMessage,
vercelError: error,
})
);
throw new HttpError({
message: errorMessage,
statusCode: 400,
});
}
const errorMessage = `Failed to take action for domain: ${error.domain}`;
logger.error(safeStringify({ errorMessage, vercelError: error }));
throw new HttpError({
message: errorMessage,
statusCode: 400,
});
}
function assertVercelEnvVars(env: typeof process.env): asserts env is {
PROJECT_ID_VERCEL: string;
TEAM_ID_VERCEL: string;
AUTH_BEARER_TOKEN_VERCEL: string;
} & typeof process.env {
if (!env.PROJECT_ID_VERCEL) {
throw new Error("Missing env var: PROJECT_ID_VERCEL");
}
// TEAM_ID_VERCEL is optional
if (!env.AUTH_BEARER_TOKEN_VERCEL) {
throw new Error("Missing env var: AUTH_BEARER_TOKEN_VERCEL");
}
}

View File

@ -0,0 +1,53 @@
import { subdomainSuffix } from "@calcom/ee/organizations/lib/orgDomains";
import { deleteDnsRecord, addDnsRecord } from "./deploymentServices/cloudflare";
import {
deleteDomain as deleteVercelDomain,
createDomain as createVercelDomain,
} from "./deploymentServices/vercel";
export const deleteDomain = async (slug: string) => {
const domain = `${slug}.${subdomainSuffix()}`;
// We must have some domain deleted
let isDomainDeleted = false;
// TODO: Ideally we should start storing the DNS and domain entries in DB for each organization
// A separate DNS record is optional but if we have it, we must have it deleted
let isDnsRecordDeleted = true;
if (process.env.VERCEL_URL) {
isDomainDeleted = await deleteVercelDomain(domain);
}
if (process.env.CLOUDFLARE_DNS) {
isDnsRecordDeleted = await deleteDnsRecord(domain);
}
return isDomainDeleted && isDnsRecordDeleted;
};
export const createDomain = async (slug: string) => {
const domain = `${slug}.${subdomainSuffix()}`;
// We must have some domain configured
let domainConfigured = false;
// A separate DNS record is optional but if we have it, we must have it configured
let dnsConfigured = true;
if (process.env.VERCEL_URL) {
domainConfigured = await createVercelDomain(domain);
}
if (process.env.CLOUDFLARE_DNS) {
dnsConfigured = await addDnsRecord(domain);
}
return domainConfigured && dnsConfigured;
};
export const renameDomain = async (oldSlug: string | null, newSlug: string) => {
// First create new domain so that if it fails we still have the old domain
await createDomain(newSlug);
if (oldSlug) {
await deleteDomain(oldSlug);
}
};

View File

@ -1,7 +1,8 @@
import { Prisma } from "@prisma/client";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { IS_PRODUCTION } from "./constants";
const log = logger.getSubLogger({ prefix: [`[[redactError]`] });
@ -19,8 +20,8 @@ export const redactError = <T extends Error | unknown>(error: T) => {
return error;
}
log.debug("Type of Error: ", error.constructor);
if (shouldRedact(error)) {
log.error("Error: ", safeStringify(error));
if (shouldRedact(error) && IS_PRODUCTION) {
log.error("Error: ", JSON.stringify(error));
return new Error("An error occured while querying the database.");
}
return error;

View File

@ -6,6 +6,9 @@ import authedProcedure, {
} from "../../../procedures/authedProcedure";
import { importHandler, router } from "../../../trpc";
import { ZAddBulkTeams } from "./addBulkTeams.schema";
import { ZAdminDeleteInput } from "./adminDelete.schema";
import { ZAdminGet } from "./adminGet.schema";
import { ZAdminUpdate } from "./adminUpdate.schema";
import { ZAdminVerifyInput } from "./adminVerify.schema";
import { ZBulkUsersDelete } from "./bulkDeleteUsers.schema.";
import { ZCreateInputSchema } from "./create.schema";
@ -64,14 +67,6 @@ export const viewerOrganizationsRouter = router({
const handler = await importHandler(namespaced("getMembers"), () => import("./getMembers.handler"));
return handler(opts);
}),
adminGetAll: authedAdminProcedure.query(async (opts) => {
const handler = await importHandler(namespaced("adminGetAll"), () => import("./adminGetAll.handler"));
return handler(opts);
}),
adminVerify: authedAdminProcedure.input(ZAdminVerifyInput).mutation(async (opts) => {
const handler = await importHandler(namespaced("adminVerify"), () => import("./adminVerify.handler"));
return handler(opts);
}),
listMembers: authedProcedure.input(ZListMembersSchema).query(async (opts) => {
const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler"));
return handler(opts);
@ -114,7 +109,7 @@ export const viewerOrganizationsRouter = router({
const handler = await importHandler(namespaced("getOtherTeam"), () => import("./getOtherTeam.handler"));
return handler(opts);
}),
listOtherTeams: authedOrgAdminProcedure.query(async (opts) => {
listOtherTeams: authedProcedure.query(async (opts) => {
const handler = await importHandler(
namespaced("listOtherTeams"),
() => import("./listOtherTeams.handler")
@ -125,4 +120,25 @@ export const viewerOrganizationsRouter = router({
const handler = await importHandler(namespaced("deleteTeam"), () => import("./deleteTeam.handler"));
return handler(opts);
}),
adminGetAll: authedAdminProcedure.query(async (opts) => {
const handler = await importHandler(namespaced("adminGetAll"), () => import("./adminGetAll.handler"));
return handler(opts);
}),
adminGet: authedAdminProcedure.input(ZAdminGet).query(async (opts) => {
const handler = await importHandler(namespaced("adminGet"), () => import("./adminGet.handler"));
return handler(opts);
}),
adminUpdate: authedAdminProcedure.input(ZAdminUpdate).mutation(async (opts) => {
const handler = await importHandler(namespaced("adminUpdate"), () => import("./adminUpdate.handler"));
return handler(opts);
}),
adminVerify: authedAdminProcedure.input(ZAdminVerifyInput).mutation(async (opts) => {
const handler = await importHandler(namespaced("adminVerify"), () => import("./adminVerify.handler"));
return handler(opts);
}),
adminDelete: authedAdminProcedure.input(ZAdminDeleteInput).mutation(async (opts) => {
const handler = await importHandler(namespaced("adminDelete"), () => import("./adminDelete.handler"));
return handler(opts);
}),
});

View File

@ -0,0 +1,80 @@
import { deleteDomain } from "@calcom/lib/domainManager/organization";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TAdminDeleteInput } from "./adminDelete.schema";
const log = logger.getSubLogger({ prefix: ["organizations/adminDelete"] });
type AdminDeleteOption = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TAdminDeleteInput;
};
export const adminDeleteHandler = async ({ input }: AdminDeleteOption) => {
const foundOrg = await prisma.team.findUnique({
where: {
id: input.orgId,
metadata: {
path: ["isOrganization"],
equals: true,
},
},
include: {
members: {
select: {
user: true,
},
},
},
});
if (!foundOrg)
throw new TRPCError({
code: "FORBIDDEN",
message: "Organization not found",
});
if (foundOrg.slug) {
await deleteDomain(foundOrg.slug);
}
await renameUsersToAvoidUsernameConflicts(foundOrg.members.map((member) => member.user));
await prisma.team.delete({
where: {
id: input.orgId,
},
});
return {
ok: true,
message: `Organization ${foundOrg.name} deleted.`,
};
};
export default adminDeleteHandler;
async function renameUsersToAvoidUsernameConflicts(users: { id: number; username: string | null }[]) {
for (const user of users) {
let currentUsername = user.username;
if (!currentUsername) {
currentUsername = "no-username";
log.warn(`User ${user.id} has no username, defaulting to ${currentUsername}`);
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
// user.id being auto-incremented, we can safely assume that the username will be unique
username: `${currentUsername}-${user.id}`,
},
});
}
}

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZAdminDeleteInput = z.object({
orgId: z.number(),
});
export type TAdminDeleteInput = z.infer<typeof ZAdminDeleteInput>;

View File

@ -0,0 +1,59 @@
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TAdminGet } from "./adminGet.schema";
type AdminGetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TAdminGet;
};
export const adminGetHandler = async ({ input }: AdminGetOptions) => {
const org = await prisma.team.findUnique({
where: {
id: input.id,
},
select: {
id: true,
name: true,
slug: true,
metadata: true,
members: {
where: {
role: "OWNER",
},
select: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
const parsedMetadata = teamMetadataSchema.parse(org.metadata);
if (!parsedMetadata?.isOrganization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
return { ...org, metadata: parsedMetadata };
};
export default adminGetHandler;

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZAdminGet = z.object({
id: z.number(),
});
export type TAdminGet = z.infer<typeof ZAdminGet>;

View File

@ -0,0 +1,94 @@
import type { Prisma } from "@prisma/client";
import { renameDomain } from "@calcom/lib/domainManager/organization";
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
import { HttpError } from "@calcom/lib/http-error";
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "../../../trpc";
import type { TAdminUpdate } from "./adminUpdate.schema";
type AdminUpdateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TAdminUpdate;
};
export const adminUpdateHandler = async ({ input }: AdminUpdateOptions) => {
const { id, ...restInput } = input;
const existingOrg = await prisma.team.findUnique({
where: {
id: id,
},
});
if (!existingOrg) {
throw new HttpError({
message: "Organization not found",
statusCode: 404,
});
}
const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema.unwrap(), existingOrg.metadata);
const data: Prisma.TeamUpdateArgs["data"] = {
...restInput,
metadata: mergeMetadata({ ...restInput.metadata }),
};
if (restInput.slug) {
await throwIfSlugConflicts({ id, slug: restInput.slug });
const isSlugChanged = restInput.slug !== existingOrg.slug;
if (isSlugChanged) {
// If slug is changed, we need to rename the domain first
// If renaming fails, we don't want to update the new slug in DB
await renameDomain(existingOrg.slug, restInput.slug);
}
data.slug = input.slug;
data.metadata = mergeMetadata({
// If we save slug, we don't need the requestedSlug anymore
requestedSlug: undefined,
...input.metadata,
});
}
const updatedOrganisation = await prisma.team.update({
where: { id },
data,
});
return updatedOrganisation;
};
export default adminUpdateHandler;
async function throwIfSlugConflicts({ id, slug }: { id: number; slug: string }) {
const organizationsWithSameSlug = await prisma.team.findMany({
where: {
slug: slug,
parentId: null,
},
});
if (organizationsWithSameSlug.length > 1) {
throw new HttpError({
message: "There can only be one organization with a given slug",
statusCode: 400,
});
}
const foundOrg = organizationsWithSameSlug[0];
if (!foundOrg) {
// No org with same slug found - So, no conflict.
return;
}
// If foundOrg isn't same as the org being edited
if (foundOrg.id !== id) {
throw new HttpError({
message: "Organization with same slug already exists",
statusCode: 400,
});
}
}

View File

@ -0,0 +1,12 @@
import { z } from "zod";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const ZAdminUpdate = z.object({
id: z.number(),
name: z.string().optional(),
slug: z.string().nullish(),
metadata: teamMetadataSchema.optional(),
});
export type TAdminUpdate = z.infer<typeof ZAdminUpdate>;

View File

@ -42,7 +42,7 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
const acceptedEmailDomain = foundOrg.members[0].user.email.split("@")[1];
const metaDataParsed = teamMetadataSchema.parse(foundOrg.metadata);
const existingMetadataParsed = teamMetadataSchema.parse(foundOrg.metadata);
await prisma.team.update({
where: {
@ -50,9 +50,8 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
},
data: {
metadata: {
...metaDataParsed,
...existingMetadataParsed,
isOrganizationVerified: true,
orgAutoAcceptEmail: acceptedEmailDomain,
},
},
});

View File

@ -1,10 +1,7 @@
import { z } from "zod";
const statusSchema = z.enum(["ACCEPT", "DENY"] as const);
export const ZAdminVerifyInput = z.object({
orgId: z.number(),
status: statusSchema,
});
export type TAdminVerifyInput = z.infer<typeof ZAdminVerifyInput>;

View File

@ -5,9 +5,9 @@ import { totp } from "otplib";
import { sendOrganizationEmailVerification } from "@calcom/emails";
import { sendAdminOrganizationNotification } from "@calcom/emails";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
import { createDomain } from "@calcom/lib/domainManager/organization";
import { getTranslation } from "@calcom/lib/server/i18n";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
@ -34,30 +34,6 @@ const getIPAddress = async (url: string): Promise<string> => {
});
};
const vercelCreateDomain = async (domain: string) => {
const response = await fetch(
`https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains?teamId=${process.env.TEAM_ID_VERCEL}`,
{
body: JSON.stringify({ name: `${domain}.${subdomainSuffix()}` }),
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") return false;
// Domain is already being used by a different project
if (data.error?.code === "domain_taken") return false;
return true;
};
export const createHandler = async ({ input, ctx }: CreateOptions) => {
const { slug, name, adminEmail, adminUsername, check } = input;
@ -93,12 +69,9 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
let isOrganizationConfigured = false;
if (check === false) {
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.VERCEL) {
// We only want to proceed to register the subdomain for the org in Vercel
// within a Vercel context
isOrganizationConfigured = await vercelCreateDomain(slug);
} else {
isOrganizationConfigured = await createDomain(slug);
if (!isOrganizationConfigured) {
// Otherwise, we proceed to send an administrative email to admins regarding
// the need to configure DNS registry to support the newly created org
const instanceAdmins = await prisma.user.findMany({

View File

@ -8,15 +8,18 @@ type ListOptions = {
};
};
export const listOtherTeamHandler = async ({ ctx }: ListOptions) => {
export const listOtherTeamHandler = async ({ ctx: { user } }: ListOptions) => {
if (!user?.organization?.isOrgAdmin) {
return [];
}
const teamsInOrgIamNotPartOf = await prisma.team.findMany({
where: {
parent: {
id: ctx.user?.organization?.id,
id: user?.organization?.id,
},
members: {
none: {
userId: ctx.user.id,
userId: user.id,
},
},
},

View File

@ -1,6 +1,6 @@
import { subdomainSuffix } from "@calcom/ee/organizations/lib/orgDomains";
import { cancelTeamSubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import { IS_PRODUCTION, IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { deleteDomain } from "@calcom/lib/domainManager/organization";
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
@ -18,41 +18,6 @@ type DeleteOptions = {
input: TDeleteInputSchema;
};
const deleteVercelDomain = async ({
slug,
isOrganization,
}: {
slug?: string | null;
isOrganization?: boolean | null;
}) => {
if (!isOrganization || !slug) {
return false;
}
const fullDomain = `${slug}.${subdomainSuffix()}`;
const response = await fetch(
`https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${fullDomain}?teamId=${process.env.TEAM_ID_VERCEL}`,
{
headers: {
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN_VERCEL}`,
},
method: "DELETE",
}
);
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 deleteHandler = async ({ ctx, input }: DeleteOptions) => {
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
@ -73,11 +38,7 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
const deletedTeamMetadata = teamMetadataSchema.parse(deletedTeam.metadata);
if (IS_PRODUCTION)
deleteVercelDomain({
slug: deletedTeam.slug,
isOrganization: deletedTeamMetadata?.isOrganization,
});
if (deletedTeamMetadata?.isOrganization && deletedTeam.slug) deleteDomain(deletedTeam.slug);
// Sync Services: Close.cm
closeComDeleteTeam(deletedTeam);

View File

@ -344,6 +344,11 @@
"ZOHOCRM_CLIENT_SECRET",
"ZOOM_CLIENT_ID",
"ZOOM_CLIENT_SECRET",
"RESEND_API_KEY"
"RESEND_API_KEY",
"LOCAL_TESTING_DOMAIN_VERCEL",
"AUTH_BEARER_TOKEN_CLOUDFLARE",
"CLOUDFLARE_ZONE_ID",
"CLOUDFLARE_VERCEL_CNAME",
"CLOUDFLARE_DNS"
]
}