feat: admin org list now shows all, dns check added (#10875)
* chore: admin org list now shows all, dns check added * Updating email template * type fixespull/10902/head^2
parent
68ec946c59
commit
e6e6f09547
|
@ -1,9 +1,9 @@
|
|||
import UnverifiedOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/unverifiedOrgPage";
|
||||
import AdminOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage";
|
||||
|
||||
import type { CalPageWrapper } from "@components/PageWrapper";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
const Page = UnverifiedOrgsPage as CalPageWrapper;
|
||||
const Page = AdminOrgsPage as CalPageWrapper;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
export default Page;
|
||||
|
|
|
@ -2010,6 +2010,20 @@
|
|||
"member_removed": "Member removed",
|
||||
"my_availability": "My Availability",
|
||||
"team_availability": "Team Availability",
|
||||
"admin_org_notification_email_subject": "New organization created: pending action",
|
||||
"hi_admin": "Hi Administrator",
|
||||
"admin_org_notification_email_title": "An organization requires DNS setup",
|
||||
"admin_org_notification_email_body_part1": "An organization with slug \"{{orgSlug}}\" was created.<br /><br />Please be sure to configure your DNS registry to point the subdomain corresponding to the new organization to where the main app is running. Otherwise the organization will not work.<br /><br />Here are just the very basic options to configure a subdomain to point to their app so it loads the organization profile page.<br /><br />You can do it either with the A Record:",
|
||||
"admin_org_notification_email_body_part2": "Or the CNAME record:",
|
||||
"admin_org_notification_email_body_part3": "Once you configure the subdomain, please mark the DNS configuration as done in Organizations Admin Settings.",
|
||||
"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.",
|
||||
"unverified": "Unverified",
|
||||
"dns_missing": "DNS Missing",
|
||||
"mark_dns_configured": "Mark as DNS configured",
|
||||
"value": "Value",
|
||||
"your_organization_updated_sucessfully": "Your organization updated successfully",
|
||||
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
|
|
|
@ -9,6 +9,8 @@ import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
|||
|
||||
import type { EmailVerifyLink } from "./templates/account-verify-email";
|
||||
import AccountVerifyEmail from "./templates/account-verify-email";
|
||||
import type { OrganizationNotification } from "./templates/admin-organization-notification";
|
||||
import AdminOrganizationNotification from "./templates/admin-organization-notification";
|
||||
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
|
||||
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
|
||||
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
|
||||
|
@ -376,3 +378,7 @@ export const sendDailyVideoRecordingEmails = async (calEvent: CalendarEvent, dow
|
|||
export const sendOrganizationEmailVerification = async (sendOrgInput: OrganizationEmailVerify) => {
|
||||
await sendEmail(() => new OrganizationEmailVerification(sendOrgInput));
|
||||
};
|
||||
|
||||
export const sendAdminOrganizationNotification = async (input: OrganizationNotification) => {
|
||||
await sendEmail(() => new AdminOrganizationNotification(input));
|
||||
};
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
import { Trans, type TFunction } from "next-i18next";
|
||||
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { BaseEmailHtml, CallToAction } from "../components";
|
||||
|
||||
type AdminOrganizationNotification = {
|
||||
language: TFunction;
|
||||
orgSlug: string;
|
||||
webappIPAddress: string;
|
||||
};
|
||||
|
||||
const dnsTable = (type: string, name: string, value: string, t: TFunction) => (
|
||||
<table
|
||||
role="presentation"
|
||||
border={0}
|
||||
cellSpacing="0"
|
||||
cellPadding="0"
|
||||
style={{
|
||||
verticalAlign: "top",
|
||||
marginTop: "10px",
|
||||
borderRadius: "6px",
|
||||
borderCollapse: "separate",
|
||||
border: "solid black 1px",
|
||||
}}
|
||||
width="100%">
|
||||
<tbody>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
backgroundColor: "black",
|
||||
color: "white",
|
||||
fontSize: "14px",
|
||||
lineHeight: "24px",
|
||||
}}>
|
||||
<td
|
||||
align="center"
|
||||
width="33%"
|
||||
style={{ borderTopLeftRadius: "5px", borderRight: "1px solid white" }}>
|
||||
{t("type")}
|
||||
</td>
|
||||
<td align="center" width="33%" style={{ borderRight: "1px solid white" }}>
|
||||
{t("name")}
|
||||
</td>
|
||||
<td align="center" style={{ borderTopRightRadius: "5px" }}>
|
||||
{t("value")}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr style={{ lineHeight: "24px" }}>
|
||||
<td align="center" style={{ borderBottomLeftRadius: "5px", borderRight: "1px solid black" }}>
|
||||
{type}
|
||||
</td>
|
||||
<td align="center" style={{ borderRight: "1px solid black" }}>
|
||||
{name}
|
||||
</td>
|
||||
<td align="center" style={{ borderBottomRightRadius: "5px" }}>
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
export const AdminOrganizationNotificationEmail = ({
|
||||
orgSlug,
|
||||
webappIPAddress,
|
||||
language,
|
||||
}: AdminOrganizationNotification) => {
|
||||
const webAppUrl = WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "");
|
||||
return (
|
||||
<BaseEmailHtml
|
||||
subject={language("admin_org_notification_email_subject", { appName: APP_NAME })}
|
||||
callToAction={
|
||||
<CallToAction
|
||||
label={language("admin_org_notification_email_cta")}
|
||||
href={`${WEBAPP_URL}/settings/admin/organizations`}
|
||||
endIconName="white-arrow-right"
|
||||
/>
|
||||
}>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "24px",
|
||||
lineHeight: "38px",
|
||||
}}>
|
||||
<>{language("admin_org_notification_email_title")}</>
|
||||
</p>
|
||||
<p style={{ fontWeight: 400 }}>
|
||||
<>{language("hi_admin")}!</>
|
||||
</p>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
<Trans i18nKey="admin_org_notification_email_body_part1" t={language} values={{ orgSlug }}>
|
||||
An organization with slug {`"${orgSlug}"`} was created.
|
||||
<br />
|
||||
<br />
|
||||
Please be sure to configure your DNS registry to point the subdomain corresponding to the new
|
||||
organization to where the main app is running. Otherwise the organization will not work.
|
||||
<br />
|
||||
<br />
|
||||
Here are just the very basic options to configure a subdomain to point to their app so it loads the
|
||||
organization profile page.
|
||||
<br />
|
||||
<br />
|
||||
You can do it either with the A Record:
|
||||
</Trans>
|
||||
</p>
|
||||
{dnsTable("A", orgSlug, webappIPAddress, language)}
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
{language("admin_org_notification_email_body_part2")}
|
||||
</p>
|
||||
{dnsTable("CNAME", orgSlug, webAppUrl, language)}
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
{language("admin_org_notification_email_body_part3")}
|
||||
</p>
|
||||
</BaseEmailHtml>
|
||||
);
|
||||
};
|
|
@ -29,3 +29,4 @@ export * from "@calcom/app-store/routing-forms/emails/components";
|
|||
export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail";
|
||||
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";
|
||||
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
|
||||
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import BaseEmail from "./_base-email";
|
||||
|
||||
export type OrganizationNotification = {
|
||||
t: TFunction;
|
||||
instanceAdmins: { email: string }[];
|
||||
ownerEmail: string;
|
||||
orgSlug: string;
|
||||
webappIPAddress: string;
|
||||
};
|
||||
|
||||
export default class AdminOrganizationNotification extends BaseEmail {
|
||||
input: OrganizationNotification;
|
||||
|
||||
constructor(input: OrganizationNotification) {
|
||||
super();
|
||||
this.name = "SEND_ADMIN_ORG_NOTIFICATION";
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
return {
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: this.input.instanceAdmins.map((admin) => admin.email).join(","),
|
||||
subject: `${this.input.t("admin_org_notification_email_subject")}`,
|
||||
html: renderEmail("AdminOrganizationNotificationEmail", {
|
||||
orgSlug: this.input.orgSlug,
|
||||
webappIPAddress: this.input.webappIPAddress,
|
||||
language: this.input.t,
|
||||
}),
|
||||
text: this.getTextBody(),
|
||||
};
|
||||
}
|
||||
|
||||
protected getTextBody(): string {
|
||||
return `${this.input.t("hi_admin")}, ${this.input.t("admin_org_notification_email_title").toLowerCase()}
|
||||
${this.input.t("admin_org_notification_email_body")}`.trim();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
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 { getLayout } from "../../../../../settings/layouts/SettingsLayout";
|
||||
|
||||
const { Body, Cell, ColumnTitle, Header, Row } = Table;
|
||||
|
||||
function AdminOrgTable() {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const [data] = trpc.viewer.organizations.adminGetAll.useSuspenseQuery();
|
||||
const verifyMutation = trpc.viewer.organizations.adminVerify.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("org_has_been_processed"), "success");
|
||||
await utils.viewer.organizations.adminGetAll.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err.message);
|
||||
showToast(t("org_error_processing"), "error");
|
||||
},
|
||||
});
|
||||
const updateMutation = trpc.viewer.organizations.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("org_has_been_processed"), "success");
|
||||
await utils.viewer.organizations.adminGetAll.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err.message);
|
||||
showToast(t("org_error_processing"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table>
|
||||
<Header>
|
||||
<ColumnTitle widthClassNames="w-auto">{t("organization")}</ColumnTitle>
|
||||
<ColumnTitle widthClassNames="w-auto">{t("owner")}</ColumnTitle>
|
||||
<ColumnTitle widthClassNames="w-auto">
|
||||
<span className="sr-only">{t("edit")}</span>
|
||||
</ColumnTitle>
|
||||
</Header>
|
||||
|
||||
<Body>
|
||||
{data.map((org) => (
|
||||
<Row key={org.id}>
|
||||
<Cell widthClassNames="w-auto">
|
||||
<div className="text-subtle font-medium">
|
||||
<span className="text-default">{org.name}</span>
|
||||
<br />
|
||||
<span className="text-muted">
|
||||
{org.slug}.{extractDomainFromWebsiteUrl}
|
||||
</span>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell widthClassNames="w-auto">
|
||||
<span className="break-all">{org.members[0].user.email}</span>
|
||||
</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>}
|
||||
</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,
|
||||
},
|
||||
{
|
||||
id: "reject",
|
||||
label: t("reject"),
|
||||
onClick: () => {
|
||||
verifyMutation.mutate({
|
||||
orgId: org.id,
|
||||
status: "DENY",
|
||||
});
|
||||
},
|
||||
icon: X,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!org.metadata?.isOrganizationConfigured
|
||||
? [
|
||||
{
|
||||
id: "dns",
|
||||
label: t("mark_dns_configured"),
|
||||
onClick: () => {
|
||||
updateMutation.mutate({
|
||||
orgId: org.id,
|
||||
metadata: {
|
||||
isOrganizationConfigured: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
icon: CheckCheck,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AdminOrgList = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Meta title={t("organizations")} description={t("orgs_page_description")} />
|
||||
<NoSSR>
|
||||
<AdminOrgTable />
|
||||
</NoSSR>
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
|
||||
AdminOrgList.getLayout = getLayout;
|
||||
|
||||
export default AdminOrgList;
|
|
@ -1,108 +0,0 @@
|
|||
import NoSSR from "@calcom/core/components/NoSSR";
|
||||
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
|
||||
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Meta } from "@calcom/ui";
|
||||
import { DropdownActions, showToast, Table } from "@calcom/ui";
|
||||
import { Check, X } from "@calcom/ui/components/icon";
|
||||
|
||||
import { getLayout } from "../../../../../settings/layouts/SettingsLayout";
|
||||
|
||||
const { Body, Cell, ColumnTitle, Header, Row } = Table;
|
||||
|
||||
function UnverifiedOrgTable() {
|
||||
const utils = trpc.useContext();
|
||||
const [data] = trpc.viewer.organizations.adminGetUnverified.useSuspenseQuery();
|
||||
const mutation = trpc.viewer.organizations.adminVerify.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast("Org has been processed", "success");
|
||||
await utils.viewer.organizations.adminGetUnverified.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err.message);
|
||||
showToast("There has been an error processing this org.", "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table>
|
||||
<Header>
|
||||
<ColumnTitle widthClassNames="w-auto">Organization</ColumnTitle>
|
||||
<ColumnTitle widthClassNames="w-auto">Owner</ColumnTitle>
|
||||
<ColumnTitle widthClassNames="w-auto">
|
||||
<span className="sr-only">Edit</span>
|
||||
</ColumnTitle>
|
||||
</Header>
|
||||
|
||||
<Body>
|
||||
{data.map((org) => (
|
||||
<Row key={org.id}>
|
||||
<Cell widthClassNames="w-auto">
|
||||
<div className="text-subtle font-medium">
|
||||
<span className="text-default">{org.name}</span>
|
||||
<br />
|
||||
<span className="text-muted">
|
||||
{org.slug}.{extractDomainFromWebsiteUrl}
|
||||
</span>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell widthClassNames="w-auto">
|
||||
<span className="break-all">{org.members[0].user.email}</span>
|
||||
</Cell>
|
||||
|
||||
<Cell widthClassNames="w-auto">
|
||||
<div className="flex w-full justify-end">
|
||||
<DropdownActions
|
||||
actions={[
|
||||
{
|
||||
id: "accept",
|
||||
label: "Accept",
|
||||
onClick: () => {
|
||||
mutation.mutate({
|
||||
orgId: org.id,
|
||||
status: "ACCEPT",
|
||||
});
|
||||
},
|
||||
icon: Check,
|
||||
},
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
onClick: () => {
|
||||
mutation.mutate({
|
||||
orgId: org.id,
|
||||
status: "DENY",
|
||||
});
|
||||
},
|
||||
icon: X,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const UnverifiedOrgList = () => {
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Meta
|
||||
title="Organizations"
|
||||
description="A list of all organizations that need verification based on their email domain. Accepting an organization will allow all users with that email domain to sign up WITHOUT email verifciation."
|
||||
/>
|
||||
<NoSSR>
|
||||
<UnverifiedOrgTable />
|
||||
</NoSSR>
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
|
||||
UnverifiedOrgList.getLayout = getLayout;
|
||||
|
||||
export default UnverifiedOrgList;
|
|
@ -0,0 +1,32 @@
|
|||
import type { SomeZodObject, z } from "zod";
|
||||
|
||||
import objectKeys from "./objectKeys";
|
||||
|
||||
/**
|
||||
* The intention behind these helpers is to make it easier to work with metadata.
|
||||
* @param schema This is the zod schema that you want to use to parse the metadata
|
||||
* @param rawMetadata This is the metadata that you want to parse
|
||||
* @returns An object with the parsed metadata, a get function to get a specific key of the metadata, and a mergeMetadata function to merge new metadata with the old one
|
||||
* @example
|
||||
* const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema, team.metadata);
|
||||
* const newMetadata = mergeMetadata({ someKey: "someValue" });
|
||||
* prisma.team.update({ ..., data: { metadata: newMetadata } });
|
||||
*/
|
||||
export function getMetadataHelpers<T extends SomeZodObject>(schema: T, rawMetadata: unknown) {
|
||||
const metadata = schema.parse(rawMetadata) as z.infer<T>;
|
||||
return {
|
||||
metadata,
|
||||
get: (key: keyof z.infer<T>) => metadata[key],
|
||||
/** This method prevents overwriting the metadata fields that you don't want to change. */
|
||||
mergeMetadata: (newMetadata: z.infer<T>) => {
|
||||
const newMetadataToReturn = { ...metadata, ...newMetadata };
|
||||
// We check for each key of newMetadata and if it's explicitly undefined, we delete it from newMetadataToReturn
|
||||
objectKeys(newMetadata).forEach((key) => {
|
||||
if (newMetadata[key] === undefined) {
|
||||
delete newMetadataToReturn[key];
|
||||
}
|
||||
});
|
||||
return newMetadataToReturn as z.infer<T>;
|
||||
},
|
||||
};
|
||||
}
|
|
@ -328,6 +328,7 @@ export const teamMetadataSchema = z
|
|||
subscriptionItemId: z.string().nullable(),
|
||||
isOrganization: z.boolean().nullable(),
|
||||
isOrganizationVerified: z.boolean().nullable(),
|
||||
isOrganizationConfigured: z.boolean().nullable(),
|
||||
orgAutoAcceptEmail: z.string().nullable(),
|
||||
})
|
||||
.partial()
|
||||
|
|
|
@ -63,11 +63,8 @@ export const viewerOrganizationsRouter = router({
|
|||
const handler = await importHandler(namespaced("getMembers"), () => import("./getMembers.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
adminGetUnverified: authedAdminProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("adminGetUnverified"),
|
||||
() => import("./adminGetUnverified.handler")
|
||||
);
|
||||
adminGetAll: authedAdminProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("adminGetAll"), () => import("./adminGetAll.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
adminVerify: authedAdminProcedure.input(ZAdminVerifyInput).mutation(async (opts) => {
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import { prisma } from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
|
||||
type AdminGetUnverifiedOptions = {
|
||||
type AdminGetAllOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptions) => {
|
||||
const unVerifiedTeams = await prisma.team.findMany({
|
||||
export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetAllOptions) => {
|
||||
const allOrgs = await prisma.team.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
|
@ -18,18 +19,13 @@ export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptio
|
|||
equals: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
path: ["isOrganizationVerified"],
|
||||
equals: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
metadata: true,
|
||||
members: {
|
||||
where: {
|
||||
role: "OWNER",
|
||||
|
@ -47,7 +43,7 @@ export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptio
|
|||
},
|
||||
});
|
||||
|
||||
return unVerifiedTeams;
|
||||
return allOrgs.map((org) => ({ ...org, metadata: teamMetadataSchema.parse(org.metadata) }));
|
||||
};
|
||||
|
||||
export default adminGetUnverifiedHandler;
|
|
@ -1,19 +1,21 @@
|
|||
import { createHash } from "crypto";
|
||||
import { lookup } from "dns";
|
||||
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_CALCOM,
|
||||
IS_TEAM_BILLING_ENABLED,
|
||||
RESERVED_SUBDOMAINS,
|
||||
IS_PRODUCTION,
|
||||
WEBAPP_URL,
|
||||
} from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
|
@ -27,6 +29,15 @@ type CreateOptions = {
|
|||
input: TCreateInputSchema;
|
||||
};
|
||||
|
||||
const getIPAddress = async (url: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
lookup(url, (err, address) => {
|
||||
if (err) reject(err);
|
||||
resolve(address);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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}`,
|
||||
|
@ -43,12 +54,10 @@ const vercelCreateDomain = async (domain: string) => {
|
|||
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" });
|
||||
if (data.error?.code === "forbidden") return false;
|
||||
|
||||
// Domain is already being used by a different project
|
||||
if (data.error?.code === "domain_taken")
|
||||
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_project" });
|
||||
if (data.error?.code === "domain_taken") return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
@ -85,9 +94,35 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
|||
|
||||
const t = await getTranslation(ctx.user.locale ?? "en", "common");
|
||||
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
|
||||
let isOrganizationConfigured = false;
|
||||
|
||||
if (check === false) {
|
||||
if (IS_CALCOM) await vercelCreateDomain(slug);
|
||||
// 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 {
|
||||
// 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({
|
||||
where: { role: UserPermissionRole.ADMIN },
|
||||
select: { email: true },
|
||||
});
|
||||
if (instanceAdmins.length) {
|
||||
await sendAdminOrganizationNotification({
|
||||
instanceAdmins,
|
||||
orgSlug: slug,
|
||||
ownerEmail: adminEmail,
|
||||
webappIPAddress: await getIPAddress(
|
||||
WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "")
|
||||
),
|
||||
t,
|
||||
});
|
||||
} else {
|
||||
console.warn("Organization created: subdomain not configured and couldn't notify adminnistrators");
|
||||
}
|
||||
}
|
||||
|
||||
const createOwnerOrg = await prisma.user.create({
|
||||
data: {
|
||||
|
@ -118,6 +153,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
|||
...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }),
|
||||
isOrganization: true,
|
||||
isOrganizationVerified: false,
|
||||
isOrganizationConfigured,
|
||||
orgAutoAcceptEmail: emailDomain,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
|
||||
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
|
||||
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
@ -20,11 +22,13 @@ type UpdateOptions = {
|
|||
|
||||
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
// A user can only have one org so we pass in their currentOrgId here
|
||||
const currentOrgId = ctx.user?.organization?.id;
|
||||
const currentOrgId = ctx.user?.organization?.id || input.orgId;
|
||||
|
||||
if (!currentOrgId) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
if (!currentOrgId || ctx.user.role !== UserPermissionRole.ADMIN)
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
if (!(await isOrganisationAdmin(ctx.user?.id, currentOrgId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
if (!(await isOrganisationAdmin(ctx.user?.id, currentOrgId)) || ctx.user.role !== UserPermissionRole.ADMIN)
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
if (input.slug) {
|
||||
const userConflict = await prisma.team.findMany({
|
||||
|
@ -51,6 +55,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
});
|
||||
|
||||
if (!prevOrganisation) throw new TRPCError({ code: "NOT_FOUND", message: "Organisation not found." });
|
||||
const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema.unwrap(), prevOrganisation.metadata);
|
||||
|
||||
const data: Prisma.TeamUpdateArgs["data"] = {
|
||||
name: input.name,
|
||||
|
@ -64,6 +69,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
timeZone: input.timeZone,
|
||||
weekStart: input.weekStart,
|
||||
timeFormat: input.timeFormat,
|
||||
metadata: mergeMetadata({ ...input.metadata }),
|
||||
};
|
||||
|
||||
if (input.slug) {
|
||||
|
@ -73,20 +79,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
!prevOrganisation.slug
|
||||
) {
|
||||
// Save it on the metadata so we can use it later
|
||||
data.metadata = {
|
||||
requestedSlug: input.slug,
|
||||
};
|
||||
data.metadata = mergeMetadata({ requestedSlug: input.slug });
|
||||
} else {
|
||||
data.slug = input.slug;
|
||||
|
||||
data.metadata = mergeMetadata({
|
||||
// If we save slug, we don't need the requestedSlug anymore
|
||||
const metadataParse = teamMetadataSchema.safeParse(prevOrganisation.metadata);
|
||||
if (metadataParse.success) {
|
||||
const { requestedSlug: _, ...cleanMetadata } = metadataParse.data || {};
|
||||
data.metadata = {
|
||||
...cleanMetadata,
|
||||
};
|
||||
}
|
||||
requestedSlug: undefined,
|
||||
...input.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const ZUpdateInputSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
orgId: z
|
||||
.string()
|
||||
.regex(/^\d+$/)
|
||||
.transform((id) => parseInt(id))
|
||||
.or(z.number())
|
||||
.optional(),
|
||||
bio: z.string().optional(),
|
||||
logo: z
|
||||
|
@ -22,6 +25,7 @@ export const ZUpdateInputSchema = z.object({
|
|||
timeZone: z.string().optional(),
|
||||
weekStart: z.string().optional(),
|
||||
timeFormat: z.number().optional(),
|
||||
metadata: teamMetadataSchema.unwrap().pick({ isOrganizationConfigured: true }).optional(),
|
||||
});
|
||||
|
||||
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;
|
||||
|
|
Loading…
Reference in New Issue