feat: More admin options for organizations (#12424)
* Add more features in org admin * Pr feedback addressed
This commit is contained in:
parent
e3905f631f
commit
5886792285
12
.env.example
12
.env.example
|
@ -249,6 +249,18 @@ PROJECT_ID_VERCEL=
|
||||||
TEAM_ID_VERCEL=
|
TEAM_ID_VERCEL=
|
||||||
# Get it from: https://vercel.com/account/tokens
|
# Get it from: https://vercel.com/account/tokens
|
||||||
AUTH_BEARER_TOKEN_VERCEL=
|
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
|
# - APPLE CALENDAR
|
||||||
# Used for E2E tests on Apple Calendar
|
# Used for E2E tests on Apple Calendar
|
||||||
|
|
|
@ -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;
|
|
@ -2081,9 +2081,11 @@
|
||||||
"admin_org_notification_email_cta": "Go to Organizations Admin Settings",
|
"admin_org_notification_email_cta": "Go to Organizations Admin Settings",
|
||||||
"org_has_been_processed": "Org has been processed",
|
"org_has_been_processed": "Org has been processed",
|
||||||
"org_error_processing": "There has been an error processing this organization",
|
"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",
|
"unverified": "Unverified",
|
||||||
|
"verified": "Verified",
|
||||||
"dns_missing": "DNS Missing",
|
"dns_missing": "DNS Missing",
|
||||||
|
"dns_configured": "DNS Configured",
|
||||||
"mark_dns_configured": "Mark as DNS configured",
|
"mark_dns_configured": "Mark as DNS configured",
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
"your_organization_updated_sucessfully": "Your organization updated successfully",
|
"your_organization_updated_sucessfully": "Your organization updated successfully",
|
||||||
|
@ -2156,6 +2158,12 @@
|
||||||
"enterprise_description": "Upgrade to Enterprise to create your Organization",
|
"enterprise_description": "Upgrade to Enterprise to create your Organization",
|
||||||
"create_your_org": "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",
|
"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",
|
"troubleshooter_tooltip": "Open the troubleshooter and figure out what is wrong with your schedule",
|
||||||
"need_help": "Need help?",
|
"need_help": "Need help?",
|
||||||
"troubleshooter": "Troubleshooter",
|
"troubleshooter": "Troubleshooter",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import type { IncomingMessage } from "http";
|
import type { IncomingMessage } from "http";
|
||||||
|
|
||||||
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
|
import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
|
@ -90,6 +91,10 @@ export function getOrgDomainConfigFromHostname({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subdomainSuffix() {
|
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(".");
|
const urlSplit = WEBAPP_URL.replace("https://", "")?.replace("http://", "").split(".");
|
||||||
return urlSplit.length === 3 ? urlSplit.slice(1).join(".") : urlSplit.join(".");
|
return urlSplit.length === 3 ? urlSplit.slice(1).join(".") : urlSplit.join(".");
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,3 @@ export function extractDomainFromEmail(email: string) {
|
||||||
} catch (ignore) {}
|
} catch (ignore) {}
|
||||||
return out.split(".")[0];
|
return out.split(".")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const extractDomainFromWebsiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(
|
|
||||||
"https://",
|
|
||||||
""
|
|
||||||
)?.replace("http://", "") as string;
|
|
||||||
|
|
|
@ -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;
|
|
@ -1,14 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import NoSSR from "@calcom/core/components/NoSSR";
|
import NoSSR from "@calcom/core/components/NoSSR";
|
||||||
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
|
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
|
||||||
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Meta, DropdownActions, showToast, Table, Badge } from "@calcom/ui";
|
import {
|
||||||
import { X, Check, CheckCheck } from "@calcom/ui/components/icon";
|
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 { getLayout } from "../../../../../settings/layouts/SettingsLayout";
|
||||||
|
import { subdomainSuffix } from "../../../../organizations/lib/orgDomains";
|
||||||
|
|
||||||
const { Body, Cell, ColumnTitle, Header, Row } = Table;
|
const { Body, Cell, ColumnTitle, Header, Row } = Table;
|
||||||
|
|
||||||
|
@ -17,19 +28,21 @@ function AdminOrgTable() {
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const [data] = trpc.viewer.organizations.adminGetAll.useSuspenseQuery();
|
const [data] = trpc.viewer.organizations.adminGetAll.useSuspenseQuery();
|
||||||
const verifyMutation = trpc.viewer.organizations.adminVerify.useMutation({
|
const verifyMutation = trpc.viewer.organizations.adminVerify.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async (_data, variables) => {
|
||||||
showToast(t("org_has_been_processed"), "success");
|
showToast(t("org_has_been_processed"), "success");
|
||||||
await utils.viewer.organizations.adminGetAll.invalidate();
|
await invalidateQueries(utils, variables);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
showToast(t("org_error_processing"), "error");
|
showToast(t("org_error_processing"), "error");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const updateMutation = trpc.viewer.organizations.update.useMutation({
|
const updateMutation = trpc.viewer.organizations.adminUpdate.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async (_data, variables) => {
|
||||||
showToast(t("org_has_been_processed"), "success");
|
showToast(t("org_has_been_processed"), "success");
|
||||||
await utils.viewer.organizations.adminGetAll.invalidate();
|
await invalidateQueries(utils, {
|
||||||
|
orgId: variables.id,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error(err.message);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Table>
|
<Table>
|
||||||
<Header>
|
<Header>
|
||||||
<ColumnTitle widthClassNames="w-auto">{t("organization")}</ColumnTitle>
|
<ColumnTitle widthClassNames="w-auto">{t("organization")}</ColumnTitle>
|
||||||
<ColumnTitle widthClassNames="w-auto">{t("owner")}</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">
|
<ColumnTitle widthClassNames="w-auto">
|
||||||
<span className="sr-only">{t("edit")}</span>
|
<span className="sr-only">{t("edit")}</span>
|
||||||
</ColumnTitle>
|
</ColumnTitle>
|
||||||
|
@ -56,7 +96,7 @@ function AdminOrgTable() {
|
||||||
<span className="text-default">{org.name}</span>
|
<span className="text-default">{org.name}</span>
|
||||||
<br />
|
<br />
|
||||||
<span className="text-muted">
|
<span className="text-muted">
|
||||||
{org.slug}.{extractDomainFromWebsiteUrl}
|
{org.slug}.{subdomainSuffix()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Cell>
|
</Cell>
|
||||||
|
@ -67,67 +107,110 @@ function AdminOrgTable() {
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell>
|
<Cell>
|
||||||
<div className="space-x-2">
|
<div className="space-x-2">
|
||||||
{!org.metadata?.isOrganizationVerified && <Badge variant="blue">{t("unverified")}</Badge>}
|
{!org.metadata?.isOrganizationVerified ? (
|
||||||
{!org.metadata?.isOrganizationConfigured && <Badge variant="red">{t("dns_missing")}</Badge>}
|
<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>
|
</div>
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell widthClassNames="w-auto">
|
<Cell widthClassNames="w-auto">
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
{(!org.metadata?.isOrganizationVerified || !org.metadata?.isOrganizationConfigured) && (
|
<DropdownActions
|
||||||
<DropdownActions
|
actions={[
|
||||||
actions={[
|
...(!org.metadata?.isOrganizationVerified
|
||||||
...(!org.metadata?.isOrganizationVerified
|
? [
|
||||||
? [
|
{
|
||||||
{
|
id: "verify",
|
||||||
id: "accept",
|
label: t("verify"),
|
||||||
label: t("accept"),
|
onClick: () => {
|
||||||
onClick: () => {
|
verifyMutation.mutate({
|
||||||
verifyMutation.mutate({
|
orgId: org.id,
|
||||||
orgId: org.id,
|
});
|
||||||
status: "ACCEPT",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: Check,
|
|
||||||
},
|
},
|
||||||
{
|
icon: Check,
|
||||||
id: "reject",
|
},
|
||||||
label: t("reject"),
|
]
|
||||||
onClick: () => {
|
: []),
|
||||||
verifyMutation.mutate({
|
...(!org.metadata?.isOrganizationConfigured
|
||||||
orgId: org.id,
|
? [
|
||||||
status: "DENY",
|
{
|
||||||
});
|
id: "dns",
|
||||||
},
|
label: t("mark_dns_configured"),
|
||||||
icon: X,
|
onClick: () => {
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: org.id,
|
||||||
|
metadata: {
|
||||||
|
isOrganizationConfigured: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
]
|
icon: CheckCheck,
|
||||||
: []),
|
},
|
||||||
...(!org.metadata?.isOrganizationConfigured
|
]
|
||||||
? [
|
: []),
|
||||||
{
|
{
|
||||||
id: "dns",
|
id: "edit",
|
||||||
label: t("mark_dns_configured"),
|
label: t("edit"),
|
||||||
onClick: () => {
|
href: `/settings/admin/organizations/${org.id}/edit`,
|
||||||
updateMutation.mutate({
|
icon: Edit,
|
||||||
orgId: org.id,
|
},
|
||||||
metadata: {
|
...(!org.slug
|
||||||
isOrganizationConfigured: true,
|
? [
|
||||||
},
|
{
|
||||||
});
|
id: "publish",
|
||||||
},
|
label: t("publish"),
|
||||||
icon: CheckCheck,
|
onClick: () => {
|
||||||
|
publishOrg(org);
|
||||||
},
|
},
|
||||||
]
|
icon: BookOpenCheck,
|
||||||
: []),
|
},
|
||||||
]}
|
]
|
||||||
/>
|
: []),
|
||||||
)}
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: t("delete"),
|
||||||
|
onClick: () => {
|
||||||
|
setOrgToDelete(org.id);
|
||||||
|
},
|
||||||
|
icon: Trash,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Cell>
|
</Cell>
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
</Body>
|
</Body>
|
||||||
</Table>
|
</Table>
|
||||||
|
<DeleteOrgDialog
|
||||||
|
orgId={orgToDelete}
|
||||||
|
onClose={() => setOrgToDelete(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!orgToDelete) return;
|
||||||
|
deleteMutation.mutate({
|
||||||
|
orgId: orgToDelete,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -147,3 +230,53 @@ const AdminOrgList = () => {
|
||||||
AdminOrgList.getLayout = getLayout;
|
AdminOrgList.getLayout = getLayout;
|
||||||
|
|
||||||
export default AdminOrgList;
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ import {
|
||||||
import { ExternalLink, Link as LinkIcon, Trash2 } from "@calcom/ui/components/icon";
|
import { ExternalLink, Link as LinkIcon, Trash2 } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
|
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-]*$");
|
const regex = new RegExp("^[a-zA-Z0-9-]*$");
|
||||||
|
|
||||||
|
@ -225,9 +225,7 @@ const OtherTeamProfileView = () => {
|
||||||
label={t("team_url")}
|
label={t("team_url")}
|
||||||
value={value}
|
value={value}
|
||||||
addOnLeading={
|
addOnLeading={
|
||||||
team?.parent
|
team?.parent ? `${team.parent.slug}.${subdomainSuffix()}/` : `${WEBAPP_URL}/team/`
|
||||||
? `${team.parent.slug}.${extractDomainFromWebsiteUrl}/`
|
|
||||||
: `${WEBAPP_URL}/team/`
|
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
form.clearErrors("slug");
|
form.clearErrors("slug");
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
|
|
||||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
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 { ArrowRight } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { useOrgBranding } from "../../organizations/context/provider";
|
import { useOrgBranding } from "../../organizations/context/provider";
|
||||||
|
import { subdomainSuffix } from "../../organizations/lib/orgDomains";
|
||||||
import type { NewTeamFormValues } from "../lib/types";
|
import type { NewTeamFormValues } from "../lib/types";
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
|
@ -129,7 +129,7 @@ export const CreateANewTeamForm = () => {
|
||||||
addOnLeading={`${
|
addOnLeading={`${
|
||||||
orgBranding
|
orgBranding
|
||||||
? `${orgBranding.fullDomain.replace("https://", "").replace("http://", "")}/`
|
? `${orgBranding.fullDomain.replace("https://", "").replace("http://", "")}/`
|
||||||
: `${extractDomainFromWebsiteUrl}/team/`
|
: `${subdomainSuffix()}/team/`
|
||||||
}`}
|
}`}
|
||||||
value={value}
|
value={value}
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
|
|
|
@ -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);
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,7 +1,8 @@
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
||||||
|
import { IS_PRODUCTION } from "./constants";
|
||||||
|
|
||||||
const log = logger.getSubLogger({ prefix: [`[[redactError]`] });
|
const log = logger.getSubLogger({ prefix: [`[[redactError]`] });
|
||||||
|
|
||||||
|
@ -19,8 +20,8 @@ export const redactError = <T extends Error | unknown>(error: T) => {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
log.debug("Type of Error: ", error.constructor);
|
log.debug("Type of Error: ", error.constructor);
|
||||||
if (shouldRedact(error)) {
|
if (shouldRedact(error) && IS_PRODUCTION) {
|
||||||
log.error("Error: ", safeStringify(error));
|
log.error("Error: ", JSON.stringify(error));
|
||||||
return new Error("An error occured while querying the database.");
|
return new Error("An error occured while querying the database.");
|
||||||
}
|
}
|
||||||
return error;
|
return error;
|
||||||
|
|
|
@ -6,6 +6,9 @@ import authedProcedure, {
|
||||||
} from "../../../procedures/authedProcedure";
|
} from "../../../procedures/authedProcedure";
|
||||||
import { importHandler, router } from "../../../trpc";
|
import { importHandler, router } from "../../../trpc";
|
||||||
import { ZAddBulkTeams } from "./addBulkTeams.schema";
|
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 { ZAdminVerifyInput } from "./adminVerify.schema";
|
||||||
import { ZBulkUsersDelete } from "./bulkDeleteUsers.schema.";
|
import { ZBulkUsersDelete } from "./bulkDeleteUsers.schema.";
|
||||||
import { ZCreateInputSchema } from "./create.schema";
|
import { ZCreateInputSchema } from "./create.schema";
|
||||||
|
@ -64,14 +67,6 @@ export const viewerOrganizationsRouter = router({
|
||||||
const handler = await importHandler(namespaced("getMembers"), () => import("./getMembers.handler"));
|
const handler = await importHandler(namespaced("getMembers"), () => import("./getMembers.handler"));
|
||||||
return handler(opts);
|
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) => {
|
listMembers: authedProcedure.input(ZListMembersSchema).query(async (opts) => {
|
||||||
const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler"));
|
const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler"));
|
||||||
return handler(opts);
|
return handler(opts);
|
||||||
|
@ -114,7 +109,7 @@ export const viewerOrganizationsRouter = router({
|
||||||
const handler = await importHandler(namespaced("getOtherTeam"), () => import("./getOtherTeam.handler"));
|
const handler = await importHandler(namespaced("getOtherTeam"), () => import("./getOtherTeam.handler"));
|
||||||
return handler(opts);
|
return handler(opts);
|
||||||
}),
|
}),
|
||||||
listOtherTeams: authedOrgAdminProcedure.query(async (opts) => {
|
listOtherTeams: authedProcedure.query(async (opts) => {
|
||||||
const handler = await importHandler(
|
const handler = await importHandler(
|
||||||
namespaced("listOtherTeams"),
|
namespaced("listOtherTeams"),
|
||||||
() => import("./listOtherTeams.handler")
|
() => import("./listOtherTeams.handler")
|
||||||
|
@ -125,4 +120,25 @@ export const viewerOrganizationsRouter = router({
|
||||||
const handler = await importHandler(namespaced("deleteTeam"), () => import("./deleteTeam.handler"));
|
const handler = await importHandler(namespaced("deleteTeam"), () => import("./deleteTeam.handler"));
|
||||||
return handler(opts);
|
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);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ZAdminDeleteInput = z.object({
|
||||||
|
orgId: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAdminDeleteInput = z.infer<typeof ZAdminDeleteInput>;
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ZAdminGet = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAdminGet = z.infer<typeof ZAdminGet>;
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>;
|
|
@ -42,7 +42,7 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
|
||||||
|
|
||||||
const acceptedEmailDomain = foundOrg.members[0].user.email.split("@")[1];
|
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({
|
await prisma.team.update({
|
||||||
where: {
|
where: {
|
||||||
|
@ -50,9 +50,8 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
metadata: {
|
metadata: {
|
||||||
...metaDataParsed,
|
...existingMetadataParsed,
|
||||||
isOrganizationVerified: true,
|
isOrganizationVerified: true,
|
||||||
orgAutoAcceptEmail: acceptedEmailDomain,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const statusSchema = z.enum(["ACCEPT", "DENY"] as const);
|
|
||||||
|
|
||||||
export const ZAdminVerifyInput = z.object({
|
export const ZAdminVerifyInput = z.object({
|
||||||
orgId: z.number(),
|
orgId: z.number(),
|
||||||
status: statusSchema,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TAdminVerifyInput = z.infer<typeof ZAdminVerifyInput>;
|
export type TAdminVerifyInput = z.infer<typeof ZAdminVerifyInput>;
|
||||||
|
|
|
@ -5,9 +5,9 @@ import { totp } from "otplib";
|
||||||
import { sendOrganizationEmailVerification } from "@calcom/emails";
|
import { sendOrganizationEmailVerification } from "@calcom/emails";
|
||||||
import { sendAdminOrganizationNotification } from "@calcom/emails";
|
import { sendAdminOrganizationNotification } from "@calcom/emails";
|
||||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
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 { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||||
import { IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
|
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 { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { prisma } from "@calcom/prisma";
|
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) => {
|
export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
||||||
const { slug, name, adminEmail, adminUsername, check } = input;
|
const { slug, name, adminEmail, adminUsername, check } = input;
|
||||||
|
|
||||||
|
@ -93,12 +69,9 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
||||||
let isOrganizationConfigured = false;
|
let isOrganizationConfigured = false;
|
||||||
|
|
||||||
if (check === false) {
|
if (check === false) {
|
||||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
isOrganizationConfigured = await createDomain(slug);
|
||||||
if (process.env.VERCEL) {
|
|
||||||
// We only want to proceed to register the subdomain for the org in Vercel
|
if (!isOrganizationConfigured) {
|
||||||
// within a Vercel context
|
|
||||||
isOrganizationConfigured = await vercelCreateDomain(slug);
|
|
||||||
} else {
|
|
||||||
// Otherwise, we proceed to send an administrative email to admins regarding
|
// Otherwise, we proceed to send an administrative email to admins regarding
|
||||||
// the need to configure DNS registry to support the newly created org
|
// the need to configure DNS registry to support the newly created org
|
||||||
const instanceAdmins = await prisma.user.findMany({
|
const instanceAdmins = await prisma.user.findMany({
|
||||||
|
|
|
@ -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({
|
const teamsInOrgIamNotPartOf = await prisma.team.findMany({
|
||||||
where: {
|
where: {
|
||||||
parent: {
|
parent: {
|
||||||
id: ctx.user?.organization?.id,
|
id: user?.organization?.id,
|
||||||
},
|
},
|
||||||
members: {
|
members: {
|
||||||
none: {
|
none: {
|
||||||
userId: ctx.user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { subdomainSuffix } from "@calcom/ee/organizations/lib/orgDomains";
|
|
||||||
import { cancelTeamSubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
|
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 { isTeamOwner } from "@calcom/lib/server/queries/teams";
|
||||||
import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager";
|
import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
@ -18,41 +18,6 @@ type DeleteOptions = {
|
||||||
input: TDeleteInputSchema;
|
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) => {
|
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||||
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
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);
|
const deletedTeamMetadata = teamMetadataSchema.parse(deletedTeam.metadata);
|
||||||
|
|
||||||
if (IS_PRODUCTION)
|
if (deletedTeamMetadata?.isOrganization && deletedTeam.slug) deleteDomain(deletedTeam.slug);
|
||||||
deleteVercelDomain({
|
|
||||||
slug: deletedTeam.slug,
|
|
||||||
isOrganization: deletedTeamMetadata?.isOrganization,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync Services: Close.cm
|
// Sync Services: Close.cm
|
||||||
closeComDeleteTeam(deletedTeam);
|
closeComDeleteTeam(deletedTeam);
|
||||||
|
|
|
@ -344,6 +344,11 @@
|
||||||
"ZOHOCRM_CLIENT_SECRET",
|
"ZOHOCRM_CLIENT_SECRET",
|
||||||
"ZOOM_CLIENT_ID",
|
"ZOOM_CLIENT_ID",
|
||||||
"ZOOM_CLIENT_SECRET",
|
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user