first commit
This commit is contained in:
126
calcom/apps/web/components/settings/CustomEmailTextField.tsx
Normal file
126
calcom/apps/web/components/settings/CustomEmailTextField.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { FormValues } from "@pages/settings/my-account/profile";
|
||||
import { useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Badge,
|
||||
TextField,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Button,
|
||||
InputError,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type CustomEmailTextFieldProps = {
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
formMethodFieldName: keyof FormValues;
|
||||
errorMessage: string;
|
||||
emailVerified: boolean;
|
||||
emailPrimary: boolean;
|
||||
dataTestId: string;
|
||||
handleChangePrimary: () => void;
|
||||
handleVerifyEmail: () => void;
|
||||
handleItemDelete: () => void;
|
||||
};
|
||||
|
||||
const CustomEmailTextField = ({
|
||||
formMethods,
|
||||
formMethodFieldName,
|
||||
errorMessage,
|
||||
emailVerified,
|
||||
emailPrimary,
|
||||
dataTestId,
|
||||
handleChangePrimary,
|
||||
handleVerifyEmail,
|
||||
handleItemDelete,
|
||||
}: CustomEmailTextFieldProps) => {
|
||||
const { t } = useLocale();
|
||||
const [inputFocus, setInputFocus] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`border-default mt-2 flex items-center rounded-md border ${
|
||||
inputFocus ? "ring-brand-default border-neutral-300 ring-2" : ""
|
||||
}`}>
|
||||
<TextField
|
||||
{...formMethods.register(formMethodFieldName)}
|
||||
label=""
|
||||
containerClassName="flex flex-1 items-center"
|
||||
className="mb-0 border-none outline-none focus:ring-0"
|
||||
data-testid={dataTestId}
|
||||
onFocus={() => setInputFocus(true)}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
/>
|
||||
<div className="flex items-center pr-2">
|
||||
{emailPrimary && (
|
||||
<Badge variant="blue" size="sm" data-testid={`${dataTestId}-primary-badge`}>
|
||||
{t("primary")}
|
||||
</Badge>
|
||||
)}
|
||||
{!emailVerified && (
|
||||
<Badge variant="orange" size="sm" className="ml-2" data-testid={`${dataTestId}-unverified-badge`}>
|
||||
{t("unverified")}
|
||||
</Badge>
|
||||
)}
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
StartIcon="ellipsis"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="ml-2 rounded-md"
|
||||
data-testid="secondary-email-action-group-button"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
StartIcon="flag"
|
||||
color="secondary"
|
||||
className="disabled:opacity-40"
|
||||
onClick={handleChangePrimary}
|
||||
disabled={!emailVerified || emailPrimary}
|
||||
data-testid="secondary-email-make-primary-button">
|
||||
{t("make_primary")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{!emailVerified && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
StartIcon="send"
|
||||
color="secondary"
|
||||
className="disabled:opacity-40"
|
||||
onClick={handleVerifyEmail}
|
||||
disabled={emailVerified}
|
||||
data-testid="resend-verify-email-button">
|
||||
{t("resend_email")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
StartIcon="trash"
|
||||
color="destructive"
|
||||
className="disabled:opacity-40"
|
||||
onClick={handleItemDelete}
|
||||
disabled={emailPrimary}
|
||||
data-testid="secondary-email-delete-button">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && <InputError message={errorMessage} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEmailTextField;
|
||||
140
calcom/apps/web/components/settings/DisableTwoFactorModal.tsx
Normal file
140
calcom/apps/web/components/settings/DisableTwoFactorModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
|
||||
interface DisableTwoFactorAuthModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
disablePassword?: boolean;
|
||||
/** Called when the user closes the modal without disabling two-factor auth */
|
||||
onCancel: () => void;
|
||||
/** Called when the user disables two-factor auth */
|
||||
onDisable: () => void;
|
||||
}
|
||||
|
||||
interface DisableTwoFactorValues {
|
||||
backupCode: string;
|
||||
totpCode: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const DisableTwoFactorAuthModal = ({
|
||||
onDisable,
|
||||
onCancel,
|
||||
disablePassword,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DisableTwoFactorAuthModalProps) => {
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useForm<DisableTwoFactorValues>();
|
||||
|
||||
const resetForm = (clearPassword = true) => {
|
||||
if (clearPassword) form.setValue("password", "");
|
||||
form.setValue("backupCode", "");
|
||||
form.setValue("totpCode", "");
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
|
||||
if (isDisabling) {
|
||||
return;
|
||||
}
|
||||
setIsDisabling(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
|
||||
if (response.status === 200) {
|
||||
setTwoFactorLostAccess(false);
|
||||
resetForm();
|
||||
onDisable();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
} else if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
setErrorMessage(t("2fa_required"));
|
||||
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(t("incorrect_2fa"));
|
||||
} else if (body.error === ErrorCode.IncorrectBackupCode) {
|
||||
setErrorMessage(t("incorrect_backup_code"));
|
||||
} else if (body.error === ErrorCode.MissingBackupCodes) {
|
||||
setErrorMessage(t("missing_backup_codes"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_disabling_2fa"), e);
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent title={t("disable_2fa")} description={t("disable_2fa_recommendation")} type="creation">
|
||||
<Form form={form} handleSubmit={handleDisable}>
|
||||
<div className="mb-8">
|
||||
{!disablePassword && (
|
||||
<PasswordField
|
||||
required
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-default",
|
||||
}}
|
||||
{...form.register("password")}
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
)}
|
||||
{twoFactorLostAccess ? (
|
||||
<BackupCode center={false} />
|
||||
) : (
|
||||
<TwoFactor center={false} autoFocus={false} />
|
||||
)}
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter showDivider className="relative mt-5">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="mr-auto"
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(!twoFactorLostAccess);
|
||||
resetForm(false);
|
||||
}}>
|
||||
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
data-testid="disable-2fa"
|
||||
loading={isDisabling}
|
||||
disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableTwoFactorAuthModal;
|
||||
296
calcom/apps/web/components/settings/EnableTwoFactorModal.tsx
Normal file
296
calcom/apps/web/components/settings/EnableTwoFactorModal.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import type { BaseSyntheticEvent } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
|
||||
interface EnableTwoFactorModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
|
||||
/**
|
||||
* Called when the user closes the modal without disabling two-factor auth
|
||||
*/
|
||||
onCancel: () => void;
|
||||
|
||||
/**
|
||||
* Called when the user enables two-factor auth
|
||||
*/
|
||||
onEnable: () => void;
|
||||
}
|
||||
|
||||
enum SetupStep {
|
||||
ConfirmPassword,
|
||||
DisplayBackupCodes,
|
||||
DisplayQrCode,
|
||||
EnterTotpCode,
|
||||
}
|
||||
|
||||
const WithStep = ({
|
||||
step,
|
||||
current,
|
||||
children,
|
||||
}: {
|
||||
step: SetupStep;
|
||||
current: SetupStep;
|
||||
children: JSX.Element;
|
||||
}) => {
|
||||
return step === current ? children : null;
|
||||
};
|
||||
|
||||
interface EnableTwoFactorValues {
|
||||
totpCode: string;
|
||||
}
|
||||
|
||||
const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: EnableTwoFactorModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const form = useForm<EnableTwoFactorValues>();
|
||||
|
||||
const setupDescriptions = {
|
||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
|
||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||
};
|
||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||
const [password, setPassword] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState([]);
|
||||
const [backupCodesUrl, setBackupCodesUrl] = useState("");
|
||||
const [dataUri, setDataUri] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const resetState = () => {
|
||||
setPassword("");
|
||||
setErrorMessage(null);
|
||||
setStep(SetupStep.ConfirmPassword);
|
||||
};
|
||||
|
||||
async function handleSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.setup(password);
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
setBackupCodes(body.backupCodes);
|
||||
|
||||
// create backup codes download url
|
||||
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
|
||||
type: "text/plain",
|
||||
});
|
||||
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
|
||||
setBackupCodesUrl(URL.createObjectURL(textBlob));
|
||||
|
||||
setDataUri(body.dataUri);
|
||||
setSecret(body.secret);
|
||||
setStep(SetupStep.DisplayQrCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_enabling_2fa"), e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable({ totpCode }: EnableTwoFactorValues, e: BaseSyntheticEvent | undefined) {
|
||||
e?.preventDefault();
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.enable(totpCode);
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
setStep(SetupStep.DisplayBackupCodes);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(`${t("code_is_incorrect")} ${t("please_try_again")}`);
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_enabling_2fa"), e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnableRef = useCallbackRef(handleEnable);
|
||||
|
||||
const totpCode = form.watch("totpCode");
|
||||
|
||||
// auto submit 2FA if all inputs have a value
|
||||
useEffect(() => {
|
||||
if (totpCode?.trim().length === 6) {
|
||||
form.handleSubmit(handleEnableRef.current)();
|
||||
}
|
||||
}, [form, handleEnableRef, totpCode]);
|
||||
|
||||
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
|
||||
description={setupDescriptions[step]}
|
||||
type="creation">
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
<PasswordField
|
||||
label={t("password")}
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<>
|
||||
<div className="-mt-3 flex justify-center">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={dataUri} alt="" />
|
||||
}
|
||||
</div>
|
||||
<p data-testid="two-factor-secret" className="mb-4 text-center font-mono text-xs">
|
||||
{secret}
|
||||
</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
|
||||
{backupCodes.map((code) => (
|
||||
<div key={code}>{formatBackupCode(code)}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</WithStep>
|
||||
<Form handleSubmit={handleEnable} form={form}>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<div className="-mt-4 pb-2">
|
||||
<TwoFactor center />
|
||||
|
||||
{errorMessage && (
|
||||
<p data-testid="error-submitting-code" className="mt-1 text-sm text-red-700">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</WithStep>
|
||||
<DialogFooter className="mt-8" showDivider>
|
||||
{step !== SetupStep.DisplayBackupCodes ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
resetState();
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
onClick={handleSetup}
|
||||
loading={isSubmitting}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="goto-otp-screen"
|
||||
className="me-2 ms-2"
|
||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
data-testid="enable-2fa"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetState();
|
||||
onEnable();
|
||||
}}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-copy"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
|
||||
showToast(t("backup_codes_copied"), "success");
|
||||
}}>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
|
||||
<Button color="primary" data-testid="backup-codes-download">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
</WithStep>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnableTwoFactorModal;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui";
|
||||
|
||||
interface SecondaryEmailConfirmModalProps {
|
||||
email: string;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SecondaryEmailConfirmModal = ({ email, onCancel }: SecondaryEmailConfirmModalProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
title={t("confirm_email")}
|
||||
description={<Trans i18nKey="confirm_email_description" values={{ email }} />}
|
||||
type="creation"
|
||||
data-testid="secondary-email-confirm-dialog">
|
||||
<DialogFooter>
|
||||
<DialogClose color="primary" onClick={onCancel} data-testid="secondary-email-confirm-done-button">
|
||||
{t("done")}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecondaryEmailConfirmModal;
|
||||
77
calcom/apps/web/components/settings/SecondaryEmailModal.tsx
Normal file
77
calcom/apps/web/components/settings/SecondaryEmailModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Button,
|
||||
TextField,
|
||||
Form,
|
||||
InputError,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface SecondaryEmailModalProps {
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
handleAddEmail: (value: { email: string }) => void;
|
||||
onCancel: () => void;
|
||||
clearErrorMessage: () => void;
|
||||
}
|
||||
|
||||
const SecondaryEmailModal = ({
|
||||
isLoading,
|
||||
errorMessage,
|
||||
handleAddEmail,
|
||||
onCancel,
|
||||
clearErrorMessage,
|
||||
}: SecondaryEmailModalProps) => {
|
||||
const { t } = useLocale();
|
||||
type FormValues = {
|
||||
email: string;
|
||||
};
|
||||
const formMethods = useForm<FormValues>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// We will reset the errorMessage once the user starts modifying the email
|
||||
const subscription = formMethods.watch(() => clearErrorMessage());
|
||||
return () => subscription.unsubscribe();
|
||||
}, [formMethods.watch]);
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
title={t("add_email")}
|
||||
description={t("add_email_description")}
|
||||
type="creation"
|
||||
data-testid="secondary-email-add-dialog">
|
||||
<Form form={formMethods} handleSubmit={handleAddEmail}>
|
||||
<TextField
|
||||
label={t("email_address")}
|
||||
data-testid="secondary-email-input"
|
||||
{...formMethods.register("email")}
|
||||
/>
|
||||
{errorMessage && <InputError message={errorMessage} />}
|
||||
<DialogFooter showDivider className="mt-10">
|
||||
<DialogClose onClick={onCancel}>{t("cancel")}</DialogClose>
|
||||
<Button type="submit" data-testid="add-secondary-email-button" disabled={isLoading}>
|
||||
{t("add_email")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecondaryEmailModal;
|
||||
163
calcom/apps/web/components/settings/TravelScheduleModal.tsx
Normal file
163
calcom/apps/web/components/settings/TravelScheduleModal.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { FormValues } from "@pages/settings/my-account/general";
|
||||
import { useState } from "react";
|
||||
import type { UseFormSetValue } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib/timePreferences";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Button,
|
||||
Label,
|
||||
DateRangePicker,
|
||||
TimezoneSelect,
|
||||
SettingsToggle,
|
||||
DatePicker,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface TravelScheduleModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
existingSchedules: FormValues["travelSchedules"];
|
||||
}
|
||||
|
||||
const TravelScheduleModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
setValue,
|
||||
existingSchedules,
|
||||
}: TravelScheduleModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const { timezone: preferredTimezone } = useTimePreferences();
|
||||
|
||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState(preferredTimezone);
|
||||
const [isNoEndDate, setIsNoEndDate] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const isOverlapping = (newSchedule: { startDate: Date; endDate?: Date }) => {
|
||||
const newStart = dayjs(newSchedule.startDate);
|
||||
const newEnd = newSchedule.endDate ? dayjs(newSchedule.endDate) : null;
|
||||
|
||||
for (const schedule of existingSchedules) {
|
||||
const start = dayjs(schedule.startDate);
|
||||
const end = schedule.endDate ? dayjs(schedule.endDate) : null;
|
||||
|
||||
if (!newEnd) {
|
||||
// if the start date is after or on the existing schedule's start date and before the existing schedule's end date (if it has one)
|
||||
if (newStart.isSame(start) || newStart.isAfter(start)) {
|
||||
if (!end || newStart.isSame(end) || newStart.isBefore(end)) return true;
|
||||
}
|
||||
} else {
|
||||
// For schedules with an end date, check for any overlap
|
||||
if (newStart.isSame(end) || newStart.isBefore(end) || end === null) {
|
||||
if (newEnd.isSame(start) || newEnd.isAfter(start)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetValues = () => {
|
||||
setStartDate(new Date());
|
||||
setEndDate(new Date());
|
||||
setSelectedTimeZone(preferredTimezone);
|
||||
setIsNoEndDate(false);
|
||||
};
|
||||
|
||||
const createNewSchedule = () => {
|
||||
const newSchedule = {
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone: selectedTimeZone,
|
||||
};
|
||||
|
||||
if (!isOverlapping(newSchedule)) {
|
||||
setValue("travelSchedules", existingSchedules.concat(newSchedule), { shouldDirty: true });
|
||||
onOpenChange();
|
||||
resetValues();
|
||||
} else {
|
||||
setErrorMessage(t("overlaps_with_existing_schedule"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title={t("travel_schedule")}
|
||||
description={t("travel_schedule_description")}
|
||||
type="creation">
|
||||
<div>
|
||||
{!isNoEndDate ? (
|
||||
<>
|
||||
<Label className="mt-2">{t("time_range")}</Label>
|
||||
<DateRangePicker
|
||||
dates={{
|
||||
startDate,
|
||||
endDate: endDate ?? startDate,
|
||||
}}
|
||||
onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
|
||||
// If newStartDate does become undefined - we resort back to to-todays date
|
||||
setStartDate(newStartDate ?? new Date());
|
||||
setEndDate(newEndDate);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Label className="mt-2">{t("date")}</Label>
|
||||
<DatePicker
|
||||
minDate={new Date()}
|
||||
date={startDate}
|
||||
className="w-56"
|
||||
onDatesChange={(newDate) => {
|
||||
setStartDate(newDate);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="text-error mt-1 text-sm">{errorMessage}</div>
|
||||
<div className="mt-3">
|
||||
<SettingsToggle
|
||||
labelClassName="mt-1 font-normal"
|
||||
title={t("schedule_tz_without_end_date")}
|
||||
checked={isNoEndDate}
|
||||
onCheckedChange={(e) => {
|
||||
setEndDate(!e ? startDate : undefined);
|
||||
setIsNoEndDate(e);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Label className="mt-6">{t("timezone")}</Label>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
className="mb-11 mt-2 w-full rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter showDivider className="relative">
|
||||
<DialogClose />
|
||||
<Button
|
||||
onClick={() => {
|
||||
createNewSchedule();
|
||||
}}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TravelScheduleModal;
|
||||
33
calcom/apps/web/components/settings/TwoFactorAuthAPI.ts
Normal file
33
calcom/apps/web/components/settings/TwoFactorAuthAPI.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
const TwoFactorAuthAPI = {
|
||||
async setup(password: string) {
|
||||
return fetch("/api/auth/two-factor/totp/setup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async enable(code: string) {
|
||||
return fetch("/api/auth/two-factor/totp/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async disable(password: string, code: string, backupCode: string) {
|
||||
return fetch("/api/auth/two-factor/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, code, backupCode }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default TwoFactorAuthAPI;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Card, Icon } from "@calcom/ui";
|
||||
|
||||
import { helpCards } from "@lib/settings/platform/utils";
|
||||
|
||||
export const HelpCards = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="grid-col-1 mb-4 grid gap-2 md:grid-cols-3">
|
||||
{helpCards.map((card) => {
|
||||
return (
|
||||
<div key={card.title}>
|
||||
<Card
|
||||
icon={<Icon name={card.icon} className="h-5 w-5 text-green-700" />}
|
||||
variant={card.variant}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
actionButton={{
|
||||
href: `${card.actionButton.href}`,
|
||||
child: `${card.actionButton.child}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EmptyScreen, Button } from "@calcom/ui";
|
||||
|
||||
export default function NoPlatformPlan() {
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon="credit-card"
|
||||
headline="Subscription needed"
|
||||
description="You are not subscribed to a Platform plan."
|
||||
buttonRaw={
|
||||
<div className="flex gap-2">
|
||||
<Button href="https://cal.com/platform/pricing">Go to Pricing</Button>
|
||||
<Button color="secondary" href="https://cal.com/pricing">
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
import { OAuthClientsDropdown } from "@components/settings/platform/dashboard/oauth-client-dropdown";
|
||||
|
||||
type ManagedUserHeaderProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
initialClientName: string;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const ManagedUserHeader = ({
|
||||
oauthClients,
|
||||
initialClientName,
|
||||
handleChange,
|
||||
}: ManagedUserHeaderProps) => {
|
||||
return (
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
Managed Users
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
See all the managed users created by your OAuth client.
|
||||
</p>
|
||||
</div>
|
||||
<OAuthClientsDropdown
|
||||
oauthClients={oauthClients}
|
||||
initialClientName={initialClientName}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
|
||||
import { ManagedUserHeader } from "@components/settings/platform/dashboard/managed-user-header";
|
||||
import { ManagedUserTable } from "@components/settings/platform/dashboard/managed-user-table";
|
||||
|
||||
type ManagedUserListProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
managedUsers?: ManagedUser[];
|
||||
initialClientName: string;
|
||||
initialClientId: string;
|
||||
isManagedUserLoading: boolean;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const ManagedUserList = ({
|
||||
initialClientName,
|
||||
initialClientId,
|
||||
oauthClients,
|
||||
managedUsers,
|
||||
isManagedUserLoading,
|
||||
handleChange,
|
||||
}: ManagedUserListProps) => {
|
||||
return (
|
||||
<div>
|
||||
<ManagedUserHeader
|
||||
oauthClients={oauthClients}
|
||||
initialClientName={initialClientName}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<ManagedUserTable
|
||||
managedUsers={managedUsers}
|
||||
isManagedUserLoading={isManagedUserLoading}
|
||||
initialClientId={initialClientId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
|
||||
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
|
||||
type ManagedUserTableProps = {
|
||||
managedUsers?: ManagedUser[];
|
||||
isManagedUserLoading: boolean;
|
||||
initialClientId: string;
|
||||
};
|
||||
|
||||
export const ManagedUserTable = ({
|
||||
managedUsers,
|
||||
isManagedUserLoading,
|
||||
initialClientId,
|
||||
}: ManagedUserTableProps) => {
|
||||
const showUsers = !isManagedUserLoading && managedUsers?.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showUsers ? (
|
||||
<>
|
||||
<table className="w-[100%] rounded-lg">
|
||||
<colgroup className="border-subtle overflow-hidden rounded-b-lg border border-b-0" span={3} />
|
||||
<tr>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Id</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Username</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Email</td>
|
||||
</tr>
|
||||
{managedUsers.map((user) => {
|
||||
return (
|
||||
<tr key={user.id} className="">
|
||||
<td className="border-subtle overflow-hidden border px-4 py-3 md:text-center">{user.id}</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">{user.username}</td>
|
||||
<td className="border-subtle overflow-hidden border px-4 py-3 md:overflow-auto md:text-center">
|
||||
{user.email}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
limitWidth={false}
|
||||
headline={
|
||||
initialClientId == undefined
|
||||
? "OAuth client is missing. You need to create an OAuth client first in order to create a managed user."
|
||||
: `OAuth client ${initialClientId} does not have a managed user present.`
|
||||
}
|
||||
description={
|
||||
initialClientId == undefined
|
||||
? "Refer to the Platform Docs from the sidebar in order to create an OAuth client."
|
||||
: "Refer to the Platform Docs from the sidebar in order to create a managed user."
|
||||
}
|
||||
className="items-center border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type OAuthClientsDropdownProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
initialClientName: string;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const OAuthClientsDropdown = ({
|
||||
oauthClients,
|
||||
initialClientName,
|
||||
handleChange,
|
||||
}: OAuthClientsDropdownProps) => {
|
||||
return (
|
||||
<div>
|
||||
{Array.isArray(oauthClients) && oauthClients.length > 0 ? (
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button color="secondary">{initialClientName}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{oauthClients.map((client) => {
|
||||
return (
|
||||
<div key={client.id}>
|
||||
{initialClientName !== client.name ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem type="button" onClick={() => handleChange(client.id, client.name)}>
|
||||
{client.name}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
import { EmptyScreen, Button } from "@calcom/ui";
|
||||
|
||||
import { OAuthClientCard } from "@components/settings/platform/oauth-clients/OAuthClientCard";
|
||||
|
||||
type OAuthClientsListProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
isDeleting: boolean;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAuthClientsListProps) => {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
OAuth Clients
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
Connect your platform to cal.com with OAuth
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />
|
||||
</div>
|
||||
</div>
|
||||
{Array.isArray(oauthClients) && oauthClients.length ? (
|
||||
<>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0">
|
||||
{oauthClients.map((client, index) => {
|
||||
return (
|
||||
<OAuthClientCard
|
||||
name={client.name}
|
||||
redirectUris={client.redirectUris}
|
||||
bookingRedirectUri={client.bookingRedirectUri}
|
||||
bookingRescheduleRedirectUri={client.bookingRescheduleRedirectUri}
|
||||
bookingCancelRedirectUri={client.bookingCancelRedirectUri}
|
||||
permissions={client.permissions}
|
||||
key={index}
|
||||
lastItem={oauthClients.length === index + 1}
|
||||
id={client.id}
|
||||
secret={client.secret}
|
||||
isLoading={isDeleting}
|
||||
onDelete={handleDelete}
|
||||
areEmailsEnabled={client.areEmailsEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
headline="Create your first OAuth client"
|
||||
description="OAuth clients facilitate access to Cal.com on behalf of users"
|
||||
Icon="plus"
|
||||
className=""
|
||||
buttonRaw={<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NewOAuthClientButton = ({ redirectLink, label }: { redirectLink: string; label?: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.push(redirectLink);
|
||||
}}
|
||||
color="secondary"
|
||||
StartIcon="plus">
|
||||
{!!label ? label : "Add"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { useCheckTeamBilling } from "@calcom/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
export const useGetUserAttributes = () => {
|
||||
const { data: user, isLoading: isUserLoading } = useMeQuery();
|
||||
const { data: userBillingData, isFetching: isUserBillingDataLoading } = useCheckTeamBilling(
|
||||
user?.organizationId,
|
||||
user?.organization.isPlatform
|
||||
);
|
||||
const isPlatformUser = user?.organization.isPlatform;
|
||||
const isPaidUser = userBillingData?.valid;
|
||||
const userOrgId = user?.organizationId;
|
||||
|
||||
return { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId };
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
|
||||
import type { Avatar } from "@calcom/prisma/client";
|
||||
import { Button, Icon, showToast } from "@calcom/ui";
|
||||
|
||||
import { hasPermission } from "../../../../../../packages/platform/utils/permissions";
|
||||
|
||||
type OAuthClientCardProps = {
|
||||
name: string;
|
||||
logo?: Avatar;
|
||||
redirectUris: string[];
|
||||
bookingRedirectUri: string | null;
|
||||
bookingCancelRedirectUri: string | null;
|
||||
bookingRescheduleRedirectUri: string | null;
|
||||
areEmailsEnabled: boolean;
|
||||
permissions: number;
|
||||
lastItem: boolean;
|
||||
id: string;
|
||||
secret: string;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const OAuthClientCard = ({
|
||||
name,
|
||||
logo,
|
||||
redirectUris,
|
||||
bookingRedirectUri,
|
||||
bookingCancelRedirectUri,
|
||||
bookingRescheduleRedirectUri,
|
||||
permissions,
|
||||
id,
|
||||
secret,
|
||||
lastItem,
|
||||
onDelete,
|
||||
isLoading,
|
||||
areEmailsEnabled,
|
||||
}: OAuthClientCardProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const clientPermissions = Object.values(PERMISSIONS_GROUPED_MAP).map((value, index) => {
|
||||
let permissionsMessage = "";
|
||||
const hasReadPermission = hasPermission(permissions, value.read);
|
||||
const hasWritePermission = hasPermission(permissions, value.write);
|
||||
|
||||
if (hasReadPermission || hasWritePermission) {
|
||||
permissionsMessage = hasReadPermission ? "read" : "write";
|
||||
}
|
||||
|
||||
if (hasReadPermission && hasWritePermission) {
|
||||
permissionsMessage = "read/write";
|
||||
}
|
||||
|
||||
return (
|
||||
!!permissionsMessage && (
|
||||
<div key={value.read} className="relative text-sm">
|
||||
{permissionsMessage} {`${value.label}s`.toLocaleLowerCase()}
|
||||
{Object.values(PERMISSIONS_GROUPED_MAP).length === index + 1 ? " " : ", "}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex w-full justify-between px-4 py-4 sm:px-6",
|
||||
lastItem ? "" : "border-subtle border-b"
|
||||
)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-1">
|
||||
<p className="font-semibold">
|
||||
Client name: <span className="font-normal">{name}</span>
|
||||
</p>
|
||||
</div>
|
||||
{!!logo && (
|
||||
<div>
|
||||
<>{logo}</>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="font-semibold">Client Id:</div>
|
||||
<div>{id}</div>
|
||||
<Icon
|
||||
name="clipboard"
|
||||
type="button"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(id);
|
||||
showToast("Client id copied to clipboard.", "success");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-semibold">Client Secret:</div>
|
||||
<div className="flex items-center justify-center rounded-md">
|
||||
{[...new Array(20)].map((_, index) => (
|
||||
<Icon name="asterisk" key={`${index}asterisk`} className="h-2 w-2" />
|
||||
))}
|
||||
<Icon
|
||||
name="clipboard"
|
||||
type="button"
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(secret);
|
||||
showToast("Client secret copied to clipboard.", "success");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle flex text-sm">
|
||||
<span className="font-semibold">Permissions: </span>
|
||||
{permissions ? <div className="flex">{clientPermissions}</div> : <> Disabled</>}
|
||||
</div>
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Redirect uris: </span>
|
||||
{redirectUris.map((item, index) => (redirectUris.length === index + 1 ? `${item}` : `${item}, `))}
|
||||
</div>
|
||||
{bookingRedirectUri && (
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Booking redirect uri: </span> {bookingRedirectUri}
|
||||
</div>
|
||||
)}
|
||||
{bookingRescheduleRedirectUri && (
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Booking reschedule uri: </span> {bookingRescheduleRedirectUri}
|
||||
</div>
|
||||
)}
|
||||
{bookingCancelRedirectUri && (
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Booking cancel uri: </span> {bookingCancelRedirectUri}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="text-sm font-semibold">Emails enabled:</span> {areEmailsEnabled ? "Yes" : "No"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<Button
|
||||
className="bg-subtle hover:bg-emphasis text-white"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red-500 text-white hover:bg-red-600"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => onDelete(id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
|
||||
import { TextField, Tooltip, Button, Label } from "@calcom/ui";
|
||||
|
||||
type OAuthClientFormProps = {
|
||||
defaultValues?: Partial<FormValues>;
|
||||
isPending?: boolean;
|
||||
isFormDisabled?: boolean;
|
||||
onSubmit: (data: FormValues) => void;
|
||||
};
|
||||
|
||||
export type FormValues = {
|
||||
name: string;
|
||||
logo?: string;
|
||||
permissions: number;
|
||||
eventTypeRead: boolean;
|
||||
eventTypeWrite: boolean;
|
||||
bookingRead: boolean;
|
||||
bookingWrite: boolean;
|
||||
scheduleRead: boolean;
|
||||
scheduleWrite: boolean;
|
||||
appsRead: boolean;
|
||||
appsWrite: boolean;
|
||||
profileRead: boolean;
|
||||
profileWrite: boolean;
|
||||
redirectUris: {
|
||||
uri: string;
|
||||
}[];
|
||||
bookingRedirectUri?: string;
|
||||
bookingCancelRedirectUri?: string;
|
||||
bookingRescheduleRedirectUri?: string;
|
||||
areEmailsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const OAuthClientForm = ({
|
||||
defaultValues,
|
||||
isPending,
|
||||
isFormDisabled,
|
||||
onSubmit,
|
||||
}: OAuthClientFormProps) => {
|
||||
const { t } = useLocale();
|
||||
const { register, control, handleSubmit, setValue } = useForm<FormValues>({
|
||||
defaultValues: { redirectUris: [{ uri: "" }], ...defaultValues },
|
||||
});
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "redirectUris",
|
||||
});
|
||||
|
||||
const [isSelectAllPermissionsChecked, setIsSelectAllPermissionsChecked] = useState(false);
|
||||
|
||||
const selectAllPermissions = useCallback(() => {
|
||||
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
|
||||
setValue(`${permissionKey}Read`, !isSelectAllPermissionsChecked);
|
||||
setValue(`${permissionKey}Write`, !isSelectAllPermissionsChecked);
|
||||
});
|
||||
|
||||
setIsSelectAllPermissionsChecked((preValue) => !preValue);
|
||||
}, [isSelectAllPermissionsChecked, setValue]);
|
||||
|
||||
const permissionsCheckboxes = Object.keys(PERMISSIONS_GROUPED_MAP).map((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
const permissionLabel = PERMISSIONS_GROUPED_MAP[entity].label;
|
||||
|
||||
return (
|
||||
<div className="my-3" key={key}>
|
||||
<p className="text-sm font-semibold">{permissionLabel}</p>
|
||||
<div className="mt-1 flex gap-x-5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<input
|
||||
{...register(`${permissionKey}Read`)}
|
||||
id={`${permissionKey}Read`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
<label htmlFor={`${permissionKey}Read`} className="cursor-pointer text-sm">
|
||||
Read
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<input
|
||||
{...register(`${permissionKey}Write`)}
|
||||
id={`${permissionKey}Write`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
<label htmlFor={`${permissionKey}Write`} className="cursor-pointer text-sm">
|
||||
Write
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
className="border-subtle rounded-b-lg border border-t-0 px-4 pb-8 pt-2"
|
||||
onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mt-6">
|
||||
<TextField disabled={isFormDisabled} required={true} label="Client name" {...register("name")} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label>Redirect uris</Label>
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div className="flex items-end" key={field.id}>
|
||||
<div className="w-[80vw]">
|
||||
<TextField
|
||||
type="url"
|
||||
required={index === 0}
|
||||
className="w-[100%]"
|
||||
label=""
|
||||
disabled={isFormDisabled}
|
||||
{...register(`redirectUris.${index}.uri` as const)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button
|
||||
tooltip="Add url"
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="plus"
|
||||
className="text-default mx-2 mb-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
append({ uri: "" });
|
||||
}}
|
||||
/>
|
||||
{index > 0 && (
|
||||
<Button
|
||||
tooltip="Remove url"
|
||||
type="button"
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
StartIcon="trash"
|
||||
className="text-default mx-2 mb-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/** <div className="mt-6">
|
||||
<Controller
|
||||
control={control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label>Client logo</Label>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={value}
|
||||
fallback={<Icon name="plus" className="text-subtle h-4 w-4" />}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="vatar-upload"
|
||||
buttonMsg="Upload"
|
||||
imageSrc={value}
|
||||
handleAvatarChange={(newAvatar: string) => {
|
||||
setValue("logo", newAvatar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div> */}
|
||||
<div className="mt-6">
|
||||
<Tooltip content="URL of your booking page">
|
||||
<TextField
|
||||
type="url"
|
||||
label="Booking redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingRedirectUri")}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Tooltip content="URL of the page where your users can cancel their booking">
|
||||
<TextField
|
||||
type="url"
|
||||
label="Booking cancel redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingCancelRedirectUri")}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Tooltip content="URL of the page where your users can reschedule their booking">
|
||||
<TextField
|
||||
type="url"
|
||||
label="Booking reschedule redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingRescheduleRedirectUri")}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<input
|
||||
{...register("areEmailsEnabled")}
|
||||
id="areEmailsEnabled"
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
<label htmlFor="areEmailsEnabled" className="cursor-pointer px-2 text-base font-semibold">
|
||||
Enable emails
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-base font-semibold underline">Permissions</h1>
|
||||
<Button type="button" onClick={selectAllPermissions} disabled={!!defaultValues || isFormDisabled}>
|
||||
{!isSelectAllPermissionsChecked ? "Select all" : "Discard all"}
|
||||
</Button>
|
||||
</div>
|
||||
<div>{permissionsCheckboxes}</div>
|
||||
</div>
|
||||
<Button className="mt-6" type="submit" loading={isPending}>
|
||||
{defaultValues ? "Update" : "Submit"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
type IndividualPlatformPlan = {
|
||||
plan: string;
|
||||
description: string;
|
||||
pricing?: number;
|
||||
includes: string[];
|
||||
};
|
||||
|
||||
// if pricing or plans change in future modify this
|
||||
export const platformPlans: IndividualPlatformPlan[] = [
|
||||
{
|
||||
plan: "Starter",
|
||||
description:
|
||||
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 99,
|
||||
includes: [
|
||||
"Up to 100 bookings a month",
|
||||
"Community Support",
|
||||
"Cal Atoms (React Library)",
|
||||
"Platform APIs",
|
||||
"Admin APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Essentials",
|
||||
description:
|
||||
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 299,
|
||||
includes: [
|
||||
"Up to 500 bookings a month. $0,60 overage beyond",
|
||||
"Everything in Starter",
|
||||
"Cal Atoms (React Library)",
|
||||
"User Management and Analytics",
|
||||
"Technical Account Manager and Onboarding Support",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Scale",
|
||||
description:
|
||||
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
|
||||
pricing: 2499,
|
||||
includes: [
|
||||
"Up to 5000 bookings a month. $0.50 overage beyond",
|
||||
"Everything in Essentials",
|
||||
"Credential import from other platforms",
|
||||
"Compliance Check SOC2, HIPAA",
|
||||
"One-on-one developer calls",
|
||||
"Help with Credentials Verification (Zoom, Google App Store)",
|
||||
"Expedited features and integrations",
|
||||
"SLA (99.999% uptime)",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Enterprise",
|
||||
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
|
||||
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
type PlatformBillingCardProps = {
|
||||
plan: string;
|
||||
description: string;
|
||||
pricing?: number;
|
||||
includes: string[];
|
||||
isLoading?: boolean;
|
||||
handleSubscribe?: () => void;
|
||||
};
|
||||
|
||||
export const PlatformBillingCard = ({
|
||||
plan,
|
||||
description,
|
||||
pricing,
|
||||
includes,
|
||||
isLoading,
|
||||
handleSubscribe,
|
||||
}: PlatformBillingCardProps) => {
|
||||
return (
|
||||
<div className="border-subtle mx-4 w-auto rounded-md border p-5 ">
|
||||
<div className="pb-5">
|
||||
<h1 className="pb-3 pt-3 text-xl font-semibold">{plan}</h1>
|
||||
<p className="pb-5 text-base">{description}</p>
|
||||
<h1 className="text-3xl font-semibold">
|
||||
{pricing && (
|
||||
<>
|
||||
US${pricing} <span className="text-sm">per month</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
onClick={handleSubscribe}
|
||||
className="flex w-[100%] items-center justify-center">
|
||||
{pricing ? "Subscribe" : "Schedule a time"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<p>This includes:</p>
|
||||
{includes.map((feature) => {
|
||||
return (
|
||||
<div key={feature} className="my-2 flex">
|
||||
<div className="pr-2">•</div>
|
||||
<div>{feature}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import { useSubscribeTeamToStripe } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
import { platformPlans } from "@components/settings/platform/platformUtils";
|
||||
import { PlatformBillingCard } from "@components/settings/platform/pricing/billing-card";
|
||||
|
||||
type PlatformPricingProps = { teamId?: number | null };
|
||||
|
||||
export const PlatformPricing = ({ teamId }: PlatformPricingProps) => {
|
||||
const router = useRouter();
|
||||
const { mutateAsync, isPending } = useSubscribeTeamToStripe({
|
||||
onSuccess: (redirectUrl: string) => {
|
||||
router.push(redirectUrl);
|
||||
},
|
||||
onError: () => {
|
||||
showToast(ErrorCode.UnableToSubscribeToThePlatform, "error");
|
||||
},
|
||||
teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-auto flex-col items-center justify-center px-5 py-10 md:px-10 lg:h-[100%]">
|
||||
<div className="mb-5 text-center text-2xl font-semibold">
|
||||
<h1>Subscribe to Platform</h1>
|
||||
</div>
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-4">
|
||||
{platformPlans.map((plan) => {
|
||||
return (
|
||||
<div key={plan.plan}>
|
||||
<PlatformBillingCard
|
||||
plan={plan.plan}
|
||||
description={plan.description}
|
||||
pricing={plan.pricing}
|
||||
includes={plan.includes}
|
||||
isLoading={isPending}
|
||||
handleSubscribe={() => {
|
||||
!!teamId &&
|
||||
(plan.plan === "Enterprise"
|
||||
? router.push("https://i.cal.com/sales/exploration")
|
||||
: mutateAsync({ plan: plan.plan.toLocaleUpperCase() }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user