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=
|
||||
# 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
|
||||
|
|
|
@ -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",
|
||||
"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",
|
||||
|
|
|
@ -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(".");
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 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;
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user