Support OIDC (#6661)
* Support OIDC * tweak to the Configure button * fix the type import
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
"yarn": ">=1.19.0 < 2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "1.3.6",
|
||||
"@boxyhq/saml-jackson": "1.7.1",
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/app-store-cli": "*",
|
||||
"@calcom/core": "*",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
// This is the callback endpoint for the OIDC provider
|
||||
// A team must set this endpoint in the OIDC provider's configuration
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(400).send("Method not allowed");
|
||||
}
|
||||
|
||||
const { code, state } = req.query as {
|
||||
code: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
const { oauthController } = await jackson();
|
||||
|
||||
try {
|
||||
const { redirect_url } = await oauthController.oidcAuthzResponse({ code, state });
|
||||
|
||||
if (!redirect_url) {
|
||||
throw new HttpError({
|
||||
message: "No redirect URL found",
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
return res.redirect(302, redirect_url);
|
||||
} catch (err) {
|
||||
const { message, statusCode = 500 } = err as HttpError;
|
||||
|
||||
return res.status(statusCode).send(message);
|
||||
}
|
||||
}
|
||||
@@ -1375,15 +1375,6 @@
|
||||
"error_editing_availability": "Error editing availability",
|
||||
"dont_have_permission": "You don't have permission to access this resource.",
|
||||
"saml_config": "Single Sign-On",
|
||||
"saml_description": "Allow team members to login using an Identity Provider",
|
||||
"saml_config_deleted_successfully": "SAML configuration deleted successfully",
|
||||
"saml_config_updated_successfully": "SAML configuration updated successfully",
|
||||
"saml_configuration": "SAML configuration",
|
||||
"delete_saml_configuration": "Delete SAML configuration",
|
||||
"delete_saml_configuration_confirmation_message": "Are you sure you want to delete the SAML configuration? Your team members who use SAML login will no longer be able to access Cal.com.",
|
||||
"confirm_delete_saml_configuration": "Yes, delete SAML configuration",
|
||||
"saml_not_configured_yet": "SAML not configured yet",
|
||||
"saml_configuration_description": "Please paste the SAML metadata from your Identity Provider in the textbox below to update your SAML configuration.",
|
||||
"saml_configuration_placeholder": "Please paste the SAML metadata from your Identity Provider here",
|
||||
"saml_email_required": "Please enter an email so we can find your SAML Identity Provider",
|
||||
"saml_sp_title": "Service Provider Details",
|
||||
@@ -1523,5 +1514,24 @@
|
||||
"reporting": "Reporting",
|
||||
"reporting_feature": "See all incoming from data and download it as a CSV",
|
||||
"teams_plan_required": "Teams plan required",
|
||||
"routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature."
|
||||
"routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature.",
|
||||
"configure": "Configure",
|
||||
"sso_configuration": "Single Sign-On",
|
||||
"sso_configuration_description": "Configure SAML/OIDC SSO and allow team members to login using an Identity Provider",
|
||||
"sso_oidc_heading": "SSO with OIDC",
|
||||
"sso_oidc_description": "Configure OIDC SSO with Identity Provider of your choice.",
|
||||
"sso_oidc_configuration_title": "OIDC Configuration",
|
||||
"sso_oidc_configuration_description": "Configure OIDC connection to your identity provider. You can find the required information in your identity provider.",
|
||||
"sso_oidc_callback_copied": "Callback URL copied",
|
||||
"sso_saml_heading": "SSO with SAML",
|
||||
"sso_saml_description": "Configure SAML SSO with Identity Provider of your choice.",
|
||||
"sso_saml_configuration_title": "SAML Configuration",
|
||||
"sso_saml_configuration_description": "Configure SAML connection to your identity provider. You can find the required information in your identity provider.",
|
||||
"sso_saml_acsurl_copied": "ACS URL copied",
|
||||
"sso_saml_entityid_copied": "Entity ID copied",
|
||||
"sso_connection_created_successfully": "{{connectionType}} configuration created successfully",
|
||||
"sso_connection_deleted_successfully": "{{connectionType}} configuration deleted successfully",
|
||||
"delete_sso_configuration": "Delete {{connectionType}} configuration",
|
||||
"delete_sso_configuration_confirmation": "Yes, delete {{connectionType}} configuration",
|
||||
"delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com."
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import LicenseRequired from "@calcom/ee/common/components/v2/LicenseRequired";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, DialogFooter, Form, showToast, TextArea } from "@calcom/ui";
|
||||
|
||||
interface TeamSSOValues {
|
||||
metadata: string;
|
||||
}
|
||||
|
||||
export default function ConfigDialogForm({
|
||||
teamId,
|
||||
handleClose,
|
||||
}: {
|
||||
teamId: number | null;
|
||||
handleClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const form = useForm<TeamSSOValues>();
|
||||
|
||||
const mutation = trpc.viewer.saml.update.useMutation({
|
||||
async onSuccess() {
|
||||
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
|
||||
await utils.viewer.saml.get.invalidate();
|
||||
showToast(t("saml_config_updated_successfully"), "success");
|
||||
handleClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
teamId,
|
||||
encodedRawMetadata: Buffer.from(values.metadata).toString("base64"),
|
||||
});
|
||||
}}>
|
||||
<div className="mb-10 mt-1">
|
||||
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">
|
||||
{t("saml_configuration")}
|
||||
</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("saml_configuration_description")}</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="metadata"
|
||||
render={({ field: { value } }) => (
|
||||
<div>
|
||||
<TextArea
|
||||
data-testid="saml_config"
|
||||
name="metadata"
|
||||
value={value}
|
||||
className="h-40"
|
||||
required={true}
|
||||
placeholder={t("saml_configuration_placeholder")}
|
||||
onChange={(e) => {
|
||||
form.setValue("metadata", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={handleClose} tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { SSOConnection } from "@calcom/ee/sso/lib/saml";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
showToast,
|
||||
Tooltip,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Label,
|
||||
} from "@calcom/ui";
|
||||
import { ClipboardCopyIcon } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function ConnectionInfo({
|
||||
teamId,
|
||||
connection,
|
||||
}: {
|
||||
teamId: number | null;
|
||||
connection: SSOConnection;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const connectionType = connection.type.toUpperCase();
|
||||
|
||||
// Delete SSO connection
|
||||
const mutation = trpc.viewer.saml.delete.useMutation({
|
||||
async onSuccess() {
|
||||
showToast(
|
||||
t("sso_connection_deleted_successfully", {
|
||||
connectionType,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
await utils.viewer.saml.get.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteConnection = async () => {
|
||||
mutation.mutate({
|
||||
teamId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{connection.type === "saml" ? (
|
||||
<SAMLInfo acsUrl={connection.acsUrl} entityId={connection.entityId} />
|
||||
) : (
|
||||
<OIDCInfo callbackUrl={connection.callbackUrl} />
|
||||
)}
|
||||
<hr className="my-6 border-neutral-200" />
|
||||
<div className="flex flex-col space-y-3">
|
||||
<Label>{t("danger_zone")}</Label>
|
||||
<Dialog>
|
||||
<div>
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive">{t("delete_sso_configuration", { connectionType })}</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_sso_configuration", { connectionType })}
|
||||
confirmBtnText={t("delete_sso_configuration_confirmation", { connectionType })}
|
||||
onConfirm={deleteConnection}>
|
||||
{t("delete_sso_configuration_confirmation_description", { appName: APP_NAME, connectionType })}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connection info for SAML
|
||||
const SAMLInfo = ({ acsUrl, entityId }: { acsUrl: string | null; entityId: string | null }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!acsUrl || !entityId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex">
|
||||
<Label>ACS URL</Label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<code className="flex w-full items-center truncate rounded rounded-r-none bg-gray-100 pl-2 font-mono text-gray-800">
|
||||
{acsUrl}
|
||||
</code>
|
||||
<Tooltip side="top" content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(acsUrl);
|
||||
showToast(t("sso_saml_acsurl_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-l-none py-[19px] text-base ">
|
||||
<ClipboardCopyIcon className="h-5 w-5 text-gray-100 ltr:mr-2 rtl:ml-2" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex">
|
||||
<Label>Entity ID</Label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<code className="flex w-full items-center truncate rounded rounded-r-none bg-gray-100 pl-2 font-mono text-gray-800">
|
||||
{entityId}
|
||||
</code>
|
||||
<Tooltip side="top" content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(entityId);
|
||||
showToast(t("sso_saml_entityid_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-l-none py-[19px] text-base ">
|
||||
<ClipboardCopyIcon className="h-5 w-5 text-gray-100 ltr:mr-2 rtl:ml-2" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Connection info for OIDC
|
||||
const OIDCInfo = ({ callbackUrl }: { callbackUrl: string | null }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!callbackUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex">
|
||||
<Label>Callback URL</Label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<code className="flex w-full items-center truncate rounded rounded-r-none bg-gray-100 pl-2 font-mono text-gray-800">
|
||||
{callbackUrl}
|
||||
</code>
|
||||
<Tooltip side="top" content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(callbackUrl);
|
||||
showToast(t("sso_oidc_callback_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-l-none py-[19px] text-base ">
|
||||
<ClipboardCopyIcon className="h-5 w-5 text-gray-100 ltr:mr-2 rtl:ml-2" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import type { SSOConnection } from "@calcom/ee/sso/lib/saml";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, DialogFooter, Form, showToast, TextField, Dialog, DialogContent } from "@calcom/ui";
|
||||
|
||||
type FormValues = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
wellKnownUrl: string;
|
||||
};
|
||||
|
||||
export default function OIDCConnection({
|
||||
teamId,
|
||||
connection,
|
||||
}: {
|
||||
teamId: number | null;
|
||||
connection: SSOConnection | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div>
|
||||
<h2 className="font-medium">{t("sso_oidc_heading")}</h2>
|
||||
<p className="text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
|
||||
{t("sso_oidc_description")}
|
||||
</p>
|
||||
</div>
|
||||
{!connection && (
|
||||
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pt-0 sm:pl-3">
|
||||
<Button color="secondary" onClick={() => setOpenModal(true)}>
|
||||
{t("configure")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CreateConnectionDialog teamId={teamId} openModal={openModal} setOpenModal={setOpenModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CreateConnectionDialog = ({
|
||||
teamId,
|
||||
openModal,
|
||||
setOpenModal,
|
||||
}: {
|
||||
teamId: number | null;
|
||||
openModal: boolean;
|
||||
setOpenModal: (open: boolean) => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const telemetry = useTelemetry();
|
||||
const form = useForm<FormValues>();
|
||||
|
||||
const mutation = trpc.viewer.saml.updateOIDC.useMutation({
|
||||
async onSuccess() {
|
||||
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
|
||||
showToast(
|
||||
t("sso_connection_created_successfully", {
|
||||
connectionType: "OIDC",
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
setOpenModal(false);
|
||||
await utils.viewer.saml.get.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||
<DialogContent type="creation">
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
const { clientId, clientSecret, wellKnownUrl } = values;
|
||||
|
||||
mutation.mutate({
|
||||
teamId,
|
||||
clientId,
|
||||
clientSecret,
|
||||
wellKnownUrl,
|
||||
});
|
||||
}}>
|
||||
<div className="mb-10 mt-1">
|
||||
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">
|
||||
{t("sso_oidc_configuration_title")}
|
||||
</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("sso_oidc_configuration_description")}</p>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
name="clientId"
|
||||
label="Client id"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
form.setValue("clientId", e?.target.value);
|
||||
}}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
name="clientSecret"
|
||||
label="Client secret"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
form.setValue("clientSecret", e?.target.value);
|
||||
}}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="wellKnownUrl"
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
name="wellKnownUrl"
|
||||
label="Well-Known URL"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
form.setValue("wellKnownUrl", e?.target.value);
|
||||
}}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setOpenModal(false);
|
||||
}}
|
||||
tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||
import ConfigDialogForm from "@calcom/features/ee/sso/components/ConfigDialogForm";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
Label,
|
||||
Meta,
|
||||
showToast,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
} from "@calcom/ui";
|
||||
import { ClipboardCopyIcon, FiDatabase, FiTrash2 } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function SAMLConfiguration({ teamId }: { teamId: number | null }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [configModal, setConfigModal] = useState(false);
|
||||
|
||||
const { data: connection, isLoading } = trpc.viewer.saml.get.useQuery(
|
||||
{ teamId },
|
||||
{
|
||||
onError: (err) => {
|
||||
setHasError(true);
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setHasError(false);
|
||||
setErrorMessage("");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const mutation = trpc.viewer.saml.delete.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.viewer.saml.get.invalidate();
|
||||
showToast(t("saml_config_deleted_successfully"), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteConnection = () => {
|
||||
mutation.mutate({
|
||||
teamId,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLoader title={t("saml_config")} description={t("saml_description")} />;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("saml_config")} description={t("saml_description")} />
|
||||
<Alert severity="warning" message={t(errorMessage)} className="mb-4 " />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("saml_config")} description={t("saml_description")} />
|
||||
<LicenseRequired>
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="mb-3">
|
||||
{connection && connection.provider ? (
|
||||
<Badge variant="green" bold>
|
||||
SAML SSO enabled via {connection.provider}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="gray" bold>
|
||||
{t("saml_not_configured_yet")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
color="secondary"
|
||||
StartIcon={FiDatabase}
|
||||
onClick={() => {
|
||||
setConfigModal(true);
|
||||
}}>
|
||||
{t("saml_btn_configure")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Provider Details */}
|
||||
{connection && connection.provider && (
|
||||
<>
|
||||
<hr className="my-8 border border-gray-200" />
|
||||
<div className="mb-3 text-base font-semibold">{t("saml_sp_title")}</div>
|
||||
<p className="mt-3 text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
|
||||
{t("saml_sp_description")}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col">
|
||||
<div className="flex">
|
||||
<Label>{t("saml_sp_acs_url")}</Label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<code className="mr-1 w-full truncate rounded-sm bg-gray-100 py-2 px-3 font-mono text-gray-800">
|
||||
{connection.acsUrl}
|
||||
</code>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(connection.acsUrl);
|
||||
showToast(t("saml_sp_acs_url_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="px-4 text-base">
|
||||
<ClipboardCopyIcon className="h-5 w-5 text-gray-100" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col">
|
||||
<div className="flex">
|
||||
<Label>{t("saml_sp_entity_id")}</Label>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<code className="mr-1 w-full truncate rounded-sm bg-gray-100 py-2 px-3 font-mono text-gray-800">
|
||||
{connection.entityId}
|
||||
</code>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(connection.entityId);
|
||||
showToast(t("saml_sp_entity_id_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="px-4 text-base">
|
||||
<ClipboardCopyIcon className="h-5 w-5 text-gray-100" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Danger Zone and Delete Confirmation */}
|
||||
{connection && connection.provider && (
|
||||
<>
|
||||
<hr className="my-8 border border-gray-200" />
|
||||
<div className="mb-3 text-base font-semibold">{t("danger_zone")}</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={FiTrash2}>
|
||||
{t("delete_saml_configuration")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_saml_configuration")}
|
||||
confirmBtnText={t("confirm_delete_saml_configuration")}
|
||||
onConfirm={deleteConnection}>
|
||||
{t("delete_saml_configuration_confirmation_message", { appName: APP_NAME })}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add/Update SAML Connection */}
|
||||
<Dialog open={configModal} onOpenChange={setConfigModal}>
|
||||
<DialogContent type="creation">
|
||||
<ConfigDialogForm handleClose={() => setConfigModal(false)} teamId={teamId} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</LicenseRequired>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import type { SSOConnection } from "@calcom/ee/sso/lib/saml";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, DialogFooter, Form, showToast, TextArea, Dialog, DialogContent } from "@calcom/ui";
|
||||
|
||||
interface FormValues {
|
||||
metadata: string;
|
||||
}
|
||||
|
||||
export default function SAMLConnection({
|
||||
teamId,
|
||||
connection,
|
||||
}: {
|
||||
teamId: number | null;
|
||||
connection: SSOConnection | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div>
|
||||
<h2 className="font-medium">{t("sso_saml_heading")}</h2>
|
||||
<p className="text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
|
||||
{t("sso_saml_description")}
|
||||
</p>
|
||||
</div>
|
||||
{!connection && (
|
||||
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pt-0 sm:pl-3">
|
||||
<Button color="secondary" onClick={() => setOpenModal(true)}>
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CreateConnectionDialog teamId={teamId} openModal={openModal} setOpenModal={setOpenModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CreateConnectionDialog = ({
|
||||
teamId,
|
||||
openModal,
|
||||
setOpenModal,
|
||||
}: {
|
||||
teamId: number | null;
|
||||
openModal: boolean;
|
||||
setOpenModal: (open: boolean) => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const telemetry = useTelemetry();
|
||||
const form = useForm<FormValues>();
|
||||
|
||||
const mutation = trpc.viewer.saml.update.useMutation({
|
||||
async onSuccess() {
|
||||
telemetry.event(telemetryEventTypes.samlConfig, collectPageParameters());
|
||||
showToast(
|
||||
t("sso_connection_created_successfully", {
|
||||
connectionType: "SAML",
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
setOpenModal(false);
|
||||
await utils.viewer.saml.get.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||
<DialogContent type="creation">
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
teamId,
|
||||
encodedRawMetadata: Buffer.from(values.metadata).toString("base64"),
|
||||
});
|
||||
}}>
|
||||
<div className="mb-10 mt-1">
|
||||
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">
|
||||
{t("sso_saml_configuration_title")}
|
||||
</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("sso_saml_configuration_description")}</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="metadata"
|
||||
render={({ field: { value } }) => (
|
||||
<div>
|
||||
<TextArea
|
||||
data-testid="saml_config"
|
||||
name="metadata"
|
||||
value={value}
|
||||
className="h-40"
|
||||
required={true}
|
||||
placeholder={t("saml_configuration_placeholder")}
|
||||
onChange={(e) => {
|
||||
form.setValue("metadata", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setOpenModal(false);
|
||||
}}
|
||||
tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import ConnectionInfo from "@calcom/ee/sso/components/ConnectionInfo";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||
import OIDCConnection from "@calcom/features/ee/sso/components/OIDCConnection";
|
||||
import SAMLConnection from "@calcom/features/ee/sso/components/SAMLConnection";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AppSkeletonLoader as SkeletonLoader, Meta, Alert } from "@calcom/ui";
|
||||
|
||||
export default function SSOConfiguration({ teamId }: { teamId: number | null }) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: connection, isLoading } = trpc.viewer.saml.get.useQuery(
|
||||
{ teamId },
|
||||
{
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("saml_config")} description={t("saml_description")} />
|
||||
<Alert severity="warning" message={t(errorMessage)} className="mb-4 " />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// No connection found
|
||||
if (!connection) {
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div className="flex flex-col space-y-10">
|
||||
<SAMLConnection teamId={teamId} connection={null} />
|
||||
<OIDCConnection teamId={teamId} connection={null} />
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div className="flex flex-col space-y-6">
|
||||
{connection.type === "saml" ? (
|
||||
<SAMLConnection teamId={teamId} connection={connection} />
|
||||
) : (
|
||||
<OIDCConnection teamId={teamId} connection={connection} />
|
||||
)}
|
||||
<ConnectionInfo teamId={teamId} connection={connection} />
|
||||
</div>
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import jackson, {
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { samlDatabaseUrl, samlAudience, samlPath } from "./saml";
|
||||
import { samlDatabaseUrl, samlAudience, samlPath, oidcPath } from "./saml";
|
||||
|
||||
// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
|
||||
const opts: JacksonOption = {
|
||||
externalUrl: WEBAPP_URL,
|
||||
samlPath,
|
||||
samlAudience,
|
||||
oidcPath,
|
||||
db: {
|
||||
engine: "sql",
|
||||
type: "postgres",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SAMLSSORecord, OIDCSSORecord } from "@boxyhq/saml-jackson";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
@@ -11,6 +12,7 @@ export const samlTenantID = "Cal.com";
|
||||
export const samlProductID = "Cal.com";
|
||||
export const samlAudience = "https://saml.cal.com";
|
||||
export const samlPath = "/api/auth/saml/callback";
|
||||
export const oidcPath = "/api/auth/oidc";
|
||||
|
||||
export const hostedCal = Boolean(HOSTED_CAL_FEATURES);
|
||||
export const tenantPrefix = "team-";
|
||||
@@ -94,3 +96,10 @@ export const canAccess = async (user: { id: number; email: string }, teamId: num
|
||||
access: true,
|
||||
};
|
||||
};
|
||||
|
||||
export type SSOConnection = (SAMLSSORecord | OIDCSSORecord) & {
|
||||
type: string;
|
||||
acsUrl: string | null;
|
||||
entityId: string | null;
|
||||
callbackUrl: string | null;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AppSkeletonLoader as SkeletonLoader } from "@calcom/ui";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import { getLayout } from "../../../settings/layouts/SettingsLayout";
|
||||
import SAMLConfiguration from "../components/SAMLConfiguration";
|
||||
import SSOConfiguration from "../components/SSOConfiguration";
|
||||
|
||||
const SAMLSSO = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
if (!HOSTED_CAL_FEATURES) {
|
||||
@@ -34,7 +37,12 @@ const SAMLSSO = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
return <SAMLConfiguration teamId={teamId} />;
|
||||
return (
|
||||
<div className="w-full bg-white sm:mx-0 xl:mt-0">
|
||||
<Meta title={t("sso_configuration")} description={t("sso_configuration_description")} />
|
||||
<SSOConfiguration teamId={teamId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SAMLSSO.getLayout = getLayout;
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import { getLayout } from "../../../settings/layouts/SettingsLayout";
|
||||
import SAMLConfiguration from "../components/SAMLConfiguration";
|
||||
import SSOConfiguration from "../components/SSOConfiguration";
|
||||
|
||||
const SAMLSSO = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
if (HOSTED_CAL_FEATURES) {
|
||||
router.push("/404");
|
||||
}
|
||||
|
||||
const teamId = null;
|
||||
|
||||
return <SAMLConfiguration teamId={teamId} />;
|
||||
return (
|
||||
<div className="w-full bg-white sm:mx-0 xl:mt-0">
|
||||
<Meta title={t("sso_configuration")} description={t("sso_configuration_description")} />
|
||||
<SSOConfiguration teamId={null} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SAMLSSO.getLayout = getLayout;
|
||||
|
||||
@@ -43,8 +43,8 @@ import { authRouter } from "./viewer/auth";
|
||||
import { availabilityRouter } from "./viewer/availability";
|
||||
import { bookingsRouter } from "./viewer/bookings";
|
||||
import { eventTypesRouter } from "./viewer/eventTypes";
|
||||
import { samlRouter } from "./viewer/saml";
|
||||
import { slotsRouter } from "./viewer/slots";
|
||||
import { ssoRouter } from "./viewer/sso";
|
||||
import { viewerTeamsRouter } from "./viewer/teams";
|
||||
import { webhookRouter } from "./viewer/webhook";
|
||||
import { workflowsRouter } from "./viewer/workflows";
|
||||
@@ -1156,7 +1156,7 @@ export const viewerRouter = mergeRouters(
|
||||
apiKeys: apiKeysRouter,
|
||||
slots: slotsRouter,
|
||||
workflows: workflowsRouter,
|
||||
saml: samlRouter,
|
||||
saml: ssoRouter,
|
||||
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
|
||||
// After that there would just one merge call here for all the apps.
|
||||
appRoutingForms: app_RoutingForms,
|
||||
|
||||
+66
-18
@@ -1,14 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { samlProductID, samlTenantID, tenantPrefix, canAccess } from "@calcom/features/ee/sso/lib/saml";
|
||||
import {
|
||||
samlProductID,
|
||||
samlTenantID,
|
||||
tenantPrefix,
|
||||
canAccess,
|
||||
oidcPath,
|
||||
} from "@calcom/features/ee/sso/lib/saml";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { router, authedProcedure } from "../../trpc";
|
||||
|
||||
export const samlRouter = router({
|
||||
// Retrieve SAML Connection
|
||||
export const ssoRouter = router({
|
||||
// Retrieve SSO Connection
|
||||
get: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -16,8 +22,6 @@ export const samlRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { connectionController, samlSPConfig } = await jackson();
|
||||
|
||||
const { teamId } = input;
|
||||
|
||||
const { message, access } = await canAccess(ctx.user, teamId);
|
||||
@@ -29,30 +33,34 @@ export const samlRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const { connectionController, samlSPConfig } = await jackson();
|
||||
|
||||
// Retrieve the SP SAML Config
|
||||
const SPConfig = await samlSPConfig.get();
|
||||
|
||||
const response = {
|
||||
provider: "",
|
||||
acsUrl: SPConfig.acsUrl,
|
||||
entityId: SPConfig.entityId,
|
||||
};
|
||||
|
||||
try {
|
||||
const connections = await connectionController.getConnections({
|
||||
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
|
||||
product: samlProductID,
|
||||
});
|
||||
|
||||
if (connections.length > 0 && "idpMetadata" in connections[0]) {
|
||||
response["provider"] = connections[0].idpMetadata.provider;
|
||||
if (connections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error getting SAML config", err);
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SAML Connection failed." });
|
||||
}
|
||||
|
||||
return response;
|
||||
const type = "idpMetadata" in connections[0] ? "saml" : "oidc";
|
||||
|
||||
return {
|
||||
...connections[0],
|
||||
type,
|
||||
acsUrl: type === "saml" ? SPConfig.acsUrl : null,
|
||||
entityId: type === "saml" ? SPConfig.entityId : null,
|
||||
callbackUrl: type === "oidc" ? `${process.env.NEXT_PUBLIC_WEBAPP_URL}${oidcPath}` : null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Error getting SSO connection", err);
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SSO connection failed." });
|
||||
}
|
||||
}),
|
||||
// Update the SAML Connection
|
||||
update: authedProcedure
|
||||
@@ -120,4 +128,44 @@ export const samlRouter = router({
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Deleting SAML Connection failed." });
|
||||
}
|
||||
}),
|
||||
|
||||
// Update the OIDC Connection
|
||||
updateOIDC: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.union([z.number(), z.null()]),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
wellKnownUrl: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { teamId, clientId, clientSecret, wellKnownUrl } = input;
|
||||
|
||||
const { message, access } = await canAccess(ctx.user, teamId);
|
||||
|
||||
if (!access) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
const { connectionController } = await jackson();
|
||||
|
||||
try {
|
||||
return await connectionController.createOIDCConnection({
|
||||
defaultRedirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/auth/saml/idp`,
|
||||
redirectUrl: JSON.stringify([`${process.env.NEXT_PUBLIC_WEBAPP_URL}/*`]),
|
||||
tenant: teamId ? tenantPrefix + teamId : samlTenantID,
|
||||
product: samlProductID,
|
||||
oidcClientId: clientId,
|
||||
oidcClientSecret: clientSecret,
|
||||
oidcDiscoveryUrl: wellKnownUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error updating OIDC connection", err);
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Updating OIDC Connection failed." });
|
||||
}
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user