Merge branch 'main' of github.com:calcom/cal.com into refactor-event-types-type-id-10419-cal-2264-cal-2296
This commit is contained in:
commit
40d3c605e2
|
@ -1,16 +0,0 @@
|
||||||
name: Add PRs to project Reviewing PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
add-PR-to-project:
|
|
||||||
name: Add PRs to project Reviewing PRs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/add-to-project@v0.1.0
|
|
||||||
with:
|
|
||||||
project-url: https://github.com/orgs/calcom/projects/11
|
|
||||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Label, TextField } from "@calcom/ui";
|
||||||
|
|
||||||
|
export default function TwoFactor({ center = true }) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const methods = useFormContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
|
||||||
|
<Label className="mt-4">{t("backup_code")}</Label>
|
||||||
|
|
||||||
|
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
id="backup-code"
|
||||||
|
label=""
|
||||||
|
defaultValue=""
|
||||||
|
placeholder="XXXXX-XXXXX"
|
||||||
|
minLength={10} // without dash
|
||||||
|
maxLength={11} // with dash
|
||||||
|
required
|
||||||
|
{...methods.register("backupCode")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Label, Input } from "@calcom/ui";
|
import { Label, Input } from "@calcom/ui";
|
||||||
|
|
||||||
export default function TwoFactor({ center = true }) {
|
export default function TwoFactor({ center = true, autoFocus = true }) {
|
||||||
const [value, onChange] = useState("");
|
const [value, onChange] = useState("");
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const methods = useFormContext();
|
const methods = useFormContext();
|
||||||
|
@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
|
||||||
name={`2fa${index + 1}`}
|
name={`2fa${index + 1}`}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
{...digit}
|
{...digit}
|
||||||
autoFocus={index === 0}
|
autoFocus={autoFocus && index === 0}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{selectedLocation && LocationOptions}
|
{selectedLocation && LocationOptions}
|
||||||
<DialogFooter>
|
<DialogFooter className="mt-4">
|
||||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
<Button
|
||||||
<Button
|
onClick={() => {
|
||||||
onClick={() => {
|
setShowLocationModal(false);
|
||||||
setShowLocationModal(false);
|
setSelectedLocation?.(undefined);
|
||||||
setSelectedLocation?.(undefined);
|
setEditingLocationType?.("");
|
||||||
setEditingLocationType?.("");
|
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
}}
|
||||||
}}
|
type="button"
|
||||||
type="button"
|
color="secondary">
|
||||||
color="secondary">
|
{t("cancel")}
|
||||||
{t("cancel")}
|
</Button>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button data-testid="update-location" type="submit">
|
<Button data-testid="update-location" type="submit">
|
||||||
{t("update")}
|
{t("update")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||||
<DialogContent>
|
<DialogContent enableOverflow>
|
||||||
<div className="flex flex-row space-x-3">
|
<div className="flex flex-row space-x-3">
|
||||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||||
<Clock className="m-auto h-6 w-6" />
|
<Clock className="m-auto h-6 w-6" />
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||||
|
|
||||||
|
import BackupCode from "@components/auth/BackupCode";
|
||||||
import TwoFactor from "@components/auth/TwoFactor";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
|
|
||||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||||
|
@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DisableTwoFactorValues {
|
interface DisableTwoFactorValues {
|
||||||
|
backupCode: string;
|
||||||
totpCode: string;
|
totpCode: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
|
||||||
}: DisableTwoFactorAuthModalProps) => {
|
}: DisableTwoFactorAuthModalProps) => {
|
||||||
const [isDisabling, setIsDisabling] = useState(false);
|
const [isDisabling, setIsDisabling] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const form = useForm<DisableTwoFactorValues>();
|
const form = useForm<DisableTwoFactorValues>();
|
||||||
|
|
||||||
async function handleDisable({ totpCode, password }: 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) {
|
if (isDisabling) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await TwoFactorAuthAPI.disable(password, totpCode);
|
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
|
setTwoFactorLostAccess(false);
|
||||||
|
resetForm();
|
||||||
onDisable();
|
onDisable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
if (body.error === ErrorCode.IncorrectPassword) {
|
if (body.error === ErrorCode.IncorrectPassword) {
|
||||||
setErrorMessage(t("incorrect_password"));
|
setErrorMessage(t("incorrect_password"));
|
||||||
}
|
} else if (body.error === ErrorCode.SecondFactorRequired) {
|
||||||
if (body.error === ErrorCode.SecondFactorRequired) {
|
|
||||||
setErrorMessage(t("2fa_required"));
|
setErrorMessage(t("2fa_required"));
|
||||||
}
|
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
|
||||||
setErrorMessage(t("incorrect_2fa"));
|
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 {
|
} else {
|
||||||
setErrorMessage(t("something_went_wrong"));
|
setErrorMessage(t("something_went_wrong"));
|
||||||
}
|
}
|
||||||
|
@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{!disablePassword && (
|
{!disablePassword && (
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
required
|
||||||
labelProps={{
|
labelProps={{
|
||||||
className: "block text-sm font-medium text-default",
|
className: "block text-sm font-medium text-default",
|
||||||
}}
|
}}
|
||||||
|
@ -85,12 +100,25 @@ const DisableTwoFactorAuthModal = ({
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TwoFactor center={false} />
|
{twoFactorLostAccess ? (
|
||||||
|
<BackupCode center={false} />
|
||||||
|
) : (
|
||||||
|
<TwoFactor center={false} autoFocus={false} />
|
||||||
|
)}
|
||||||
|
|
||||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter showDivider className="relative mt-5">
|
<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}>
|
<Button color="secondary" onClick={onCancel}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
|
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
|
||||||
|
|
||||||
import TwoFactor from "@components/auth/TwoFactor";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
|
|
||||||
|
@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
|
||||||
|
|
||||||
enum SetupStep {
|
enum SetupStep {
|
||||||
ConfirmPassword,
|
ConfirmPassword,
|
||||||
|
DisplayBackupCodes,
|
||||||
DisplayQrCode,
|
DisplayQrCode,
|
||||||
EnterTotpCode,
|
EnterTotpCode,
|
||||||
}
|
}
|
||||||
|
@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||||
|
|
||||||
const setupDescriptions = {
|
const setupDescriptions = {
|
||||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||||
|
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
|
||||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||||
};
|
};
|
||||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [backupCodes, setBackupCodes] = useState([]);
|
||||||
|
const [backupCodesUrl, setBackupCodesUrl] = useState("");
|
||||||
const [dataUri, setDataUri] = useState("");
|
const [dataUri, setDataUri] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
setPassword("");
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStep(SetupStep.ConfirmPassword);
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSetup(e: React.FormEvent) {
|
async function handleSetup(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
if (response.status === 200) {
|
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);
|
setDataUri(body.dataUri);
|
||||||
setSecret(body.secret);
|
setSecret(body.secret);
|
||||||
setStep(SetupStep.DisplayQrCode);
|
setStep(SetupStep.DisplayQrCode);
|
||||||
|
@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
onEnable();
|
setStep(SetupStep.DisplayBackupCodes);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||||
}
|
}
|
||||||
}, [form, handleEnableRef, totpCode]);
|
}, [form, handleEnableRef, totpCode]);
|
||||||
|
|
||||||
|
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
<DialogContent
|
||||||
|
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
|
||||||
|
description={setupDescriptions[step]}
|
||||||
|
type="creation">
|
||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<form onSubmit={handleSetup}>
|
<form onSubmit={handleSetup}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<TextField
|
<PasswordField
|
||||||
label={t("password")}
|
label={t("password")}
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
|
@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
</WithStep>
|
</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}>
|
<Form handleSubmit={handleEnable} form={form}>
|
||||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||||
<div className="-mt-4 pb-2">
|
<div className="-mt-4 pb-2">
|
||||||
|
@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||||
</div>
|
</div>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<DialogFooter className="mt-8" showDivider>
|
<DialogFooter className="mt-8" showDivider>
|
||||||
<Button color="secondary" onClick={onCancel}>
|
{step !== SetupStep.DisplayBackupCodes ? (
|
||||||
{t("cancel")}
|
<Button
|
||||||
</Button>
|
color="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
onCancel();
|
||||||
|
resetState();
|
||||||
|
}}>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||||
{t("enable")}
|
{t("enable")}
|
||||||
</Button>
|
</Button>
|
||||||
</WithStep>
|
</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>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async disable(password: string, code: string) {
|
async disable(password: string, code: string, backupCode: string) {
|
||||||
return fetch("/api/auth/two-factor/totp/disable", {
|
return fetch("/api/auth/two-factor/totp/disable", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ password, code }),
|
body: JSON.stringify({ password, code, backupCode }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
|
|
@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formMethods = useForm<{
|
type formSchemaType = z.infer<typeof formSchema>;
|
||||||
username: string;
|
|
||||||
email_address: string;
|
const formMethods = useForm<formSchemaType>({
|
||||||
full_name: string;
|
mode: "onChange",
|
||||||
password: string;
|
|
||||||
}>({
|
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||||
props.onError();
|
props.onError();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
const onSubmit = formMethods.handleSubmit(async (data) => {
|
||||||
props.onSubmit();
|
props.onSubmit();
|
||||||
const response = await fetch("/api/auth/setup", {
|
const response = await fetch("/api/auth/setup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||||
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
name="username"
|
name="username"
|
||||||
onChange={async (e) => {
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange(e.target.value);
|
|
||||||
formMethods.setValue("username", e.target.value);
|
|
||||||
await formMethods.trigger("username");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||||
<TextField
|
<TextField
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={async (e) => {
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange(e.target.value);
|
|
||||||
formMethods.setValue("full_name", e.target.value);
|
|
||||||
await formMethods.trigger("full_name");
|
|
||||||
}}
|
|
||||||
color={formMethods.formState.errors.full_name ? "warn" : ""}
|
color={formMethods.formState.errors.full_name ? "warn" : ""}
|
||||||
type="text"
|
type="text"
|
||||||
name="full_name"
|
name="full_name"
|
||||||
|
@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||||
<EmailField
|
<EmailField
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={async (e) => {
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange(e.target.value);
|
|
||||||
formMethods.setValue("email_address", e.target.value);
|
|
||||||
await formMethods.trigger("email_address");
|
|
||||||
}}
|
|
||||||
className="my-0"
|
className="my-0"
|
||||||
name="email_address"
|
name="email_address"
|
||||||
/>
|
/>
|
||||||
|
@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||||
<PasswordField
|
<PasswordField
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={async (e) => {
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange(e.target.value);
|
|
||||||
formMethods.setValue("password", e.target.value);
|
|
||||||
await formMethods.trigger("password");
|
|
||||||
}}
|
|
||||||
hintErrors={["caplow", "admin_min", "num"]}
|
hintErrors={["caplow", "admin_min", "num"]}
|
||||||
name="password"
|
name="password"
|
||||||
className="my-0"
|
className="my-0"
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
||||||
|
@ -11,18 +10,9 @@ import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||||
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
|
import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||||
|
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
const signupSchema = z.object({
|
|
||||||
username: z.string().refine((value) => !value.includes("+"), {
|
|
||||||
message: "String should not contain a plus symbol (+).",
|
|
||||||
}),
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string().min(7),
|
|
||||||
language: z.string().optional(),
|
|
||||||
token: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).end();
|
return res.status(405).end();
|
||||||
|
|
|
@ -43,8 +43,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
|
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if user has 2fa
|
|
||||||
if (user.twoFactorEnabled) {
|
// if user has 2fa and using backup code
|
||||||
|
if (user.twoFactorEnabled && req.body.backupCode) {
|
||||||
|
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||||
|
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||||
|
throw new Error(ErrorCode.InternalServerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.backupCodes) {
|
||||||
|
return res.status(400).json({ error: ErrorCode.MissingBackupCodes });
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
|
||||||
|
|
||||||
|
// check if user-supplied code matches one
|
||||||
|
const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
|
||||||
|
if (index === -1) {
|
||||||
|
return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
// we delete all stored backup codes at the end, no need to do this here
|
||||||
|
|
||||||
|
// if user has 2fa and NOT using backup code, try totp
|
||||||
|
} else if (user.twoFactorEnabled) {
|
||||||
if (!req.body.code) {
|
if (!req.body.code) {
|
||||||
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
|
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
|
||||||
// throw new Error(ErrorCode.SecondFactorRequired);
|
// throw new Error(ErrorCode.SecondFactorRequired);
|
||||||
|
@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
backupCodes: null,
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
twoFactorSecret: null,
|
twoFactorSecret: null,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import crypto from "crypto";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { authenticator } from "otplib";
|
import { authenticator } from "otplib";
|
||||||
import qrcode from "qrcode";
|
import qrcode from "qrcode";
|
||||||
|
@ -56,11 +57,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
// bytes without updating the sanity checks in the enable and login endpoints.
|
// bytes without updating the sanity checks in the enable and login endpoints.
|
||||||
const secret = authenticator.generateSecret(20);
|
const secret = authenticator.generateSecret(20);
|
||||||
|
|
||||||
|
// generate backup codes with 10 character length
|
||||||
|
const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex"));
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
|
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
|
||||||
},
|
},
|
||||||
|
@ -70,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
const keyUri = authenticator.keyuri(name, "Cal", secret);
|
const keyUri = authenticator.keyuri(name, "Cal", secret);
|
||||||
const dataUri = await qrcode.toDataURL(keyUri);
|
const dataUri = await qrcode.toDataURL(keyUri);
|
||||||
|
|
||||||
return res.json({ secret, keyUri, dataUri });
|
return res.json({ secret, keyUri, dataUri, backupCodes });
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
||||||
import { ArrowLeft } from "@calcom/ui/components/icon";
|
import { ArrowLeft, Lock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
import type { WithNonceProps } from "@lib/withNonce";
|
import type { WithNonceProps } from "@lib/withNonce";
|
||||||
|
@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce";
|
||||||
|
|
||||||
import AddToHomescreen from "@components/AddToHomescreen";
|
import AddToHomescreen from "@components/AddToHomescreen";
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
import BackupCode from "@components/auth/BackupCode";
|
||||||
import TwoFactor from "@components/auth/TwoFactor";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
import AuthContainer from "@components/ui/AuthContainer";
|
import AuthContainer from "@components/ui/AuthContainer";
|
||||||
|
|
||||||
|
@ -39,6 +40,7 @@ interface LoginValues {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
totpCode: string;
|
totpCode: string;
|
||||||
|
backupCode: string;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
}
|
}
|
||||||
export default function Login({
|
export default function Login({
|
||||||
|
@ -65,6 +67,7 @@ export default function Login({
|
||||||
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
|
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
|
||||||
const { register, formState } = methods;
|
const { register, formState } = methods;
|
||||||
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
|
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
|
||||||
|
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const errorMessages: { [key: string]: string } = {
|
const errorMessages: { [key: string]: string } = {
|
||||||
|
@ -98,15 +101,35 @@ export default function Login({
|
||||||
);
|
);
|
||||||
|
|
||||||
const TwoFactorFooter = (
|
const TwoFactorFooter = (
|
||||||
<Button
|
<>
|
||||||
onClick={() => {
|
<Button
|
||||||
setTwoFactorRequired(false);
|
onClick={() => {
|
||||||
methods.setValue("totpCode", "");
|
if (twoFactorLostAccess) {
|
||||||
}}
|
setTwoFactorLostAccess(false);
|
||||||
StartIcon={ArrowLeft}
|
methods.setValue("backupCode", "");
|
||||||
color="minimal">
|
} else {
|
||||||
{t("go_back")}
|
setTwoFactorRequired(false);
|
||||||
</Button>
|
methods.setValue("totpCode", "");
|
||||||
|
}
|
||||||
|
setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
StartIcon={ArrowLeft}
|
||||||
|
color="minimal">
|
||||||
|
{t("go_back")}
|
||||||
|
</Button>
|
||||||
|
{!twoFactorLostAccess ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setTwoFactorLostAccess(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
methods.setValue("totpCode", "");
|
||||||
|
}}
|
||||||
|
StartIcon={Lock}
|
||||||
|
color="minimal">
|
||||||
|
{t("lost_access")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ExternalTotpFooter = (
|
const ExternalTotpFooter = (
|
||||||
|
@ -130,8 +153,9 @@ export default function Login({
|
||||||
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
|
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
|
||||||
// we're logged in! let's do a hard refresh to the desired url
|
// we're logged in! let's do a hard refresh to the desired url
|
||||||
else if (!res.error) router.push(callbackUrl);
|
else if (!res.error) router.push(callbackUrl);
|
||||||
// reveal two factor input if required
|
|
||||||
else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
|
else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
|
||||||
|
else if (res.error === ErrorCode.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code"));
|
||||||
|
else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes"));
|
||||||
// fallback if error not found
|
// fallback if error not found
|
||||||
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
||||||
};
|
};
|
||||||
|
@ -194,7 +218,7 @@ export default function Login({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{twoFactorRequired && <TwoFactor center />}
|
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
|
||||||
|
|
||||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||||
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
|
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
|
||||||
|
|
||||||
|
@ -25,14 +26,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||||
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
|
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
|
||||||
import { ssrInit } from "../server/lib/ssr";
|
import { ssrInit } from "../server/lib/ssr";
|
||||||
|
|
||||||
const signupSchema = z.object({
|
const signupSchema = apiSignupSchema.extend({
|
||||||
username: z.string().refine((value) => !value.includes("+"), {
|
|
||||||
message: "String should not contain a plus symbol (+).",
|
|
||||||
}),
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string().min(7),
|
|
||||||
language: z.string().optional(),
|
|
||||||
token: z.string().optional(),
|
|
||||||
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
|
apiError: z.string().optional(), // Needed to display API errors doesnt get passed to the API
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -46,6 +40,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const flags = useFlagMap();
|
const flags = useFlagMap();
|
||||||
const methods = useForm<FormValues>({
|
const methods = useForm<FormValues>({
|
||||||
|
mode: "onChange",
|
||||||
resolver: zodResolver(signupSchema),
|
resolver: zodResolver(signupSchema),
|
||||||
defaultValues: prepopulateFormValues,
|
defaultValues: prepopulateFormValues,
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { test } from "./lib/fixtures";
|
||||||
|
|
||||||
test.describe.configure({ mode: "parallel" });
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
|
// TODO: add more backup code tests, e.g. login + disabling 2fa with backup
|
||||||
|
|
||||||
// a test to logout requires both a succesfull login as logout, to prevent
|
// a test to logout requires both a succesfull login as logout, to prevent
|
||||||
// a doubling of tests failing on logout & logout, we can group them.
|
// a doubling of tests failing on logout & logout, we can group them.
|
||||||
test.describe("2FA Tests", async () => {
|
test.describe("2FA Tests", async () => {
|
||||||
|
@ -45,6 +47,8 @@ test.describe("2FA Tests", async () => {
|
||||||
secret: secret!,
|
secret: secret!,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: this passes even when switch is not checked, compare to test
|
||||||
|
// below which checks for data-state="checked" and works as expected
|
||||||
await page.waitForSelector(`[data-testid=two-factor-switch]`);
|
await page.waitForSelector(`[data-testid=two-factor-switch]`);
|
||||||
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
|
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
|
||||||
|
|
||||||
|
@ -103,6 +107,23 @@ test.describe("2FA Tests", async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
await fillOtp({ page, secret: secret! });
|
await fillOtp({ page, secret: secret! });
|
||||||
|
|
||||||
|
// backup codes are now showing, so run a few tests
|
||||||
|
|
||||||
|
// click download button
|
||||||
|
const promise = page.waitForEvent("download");
|
||||||
|
await page.getByTestId("backup-codes-download").click();
|
||||||
|
const download = await promise;
|
||||||
|
expect(download.suggestedFilename()).toBe("cal-backup-codes.txt");
|
||||||
|
// TODO: check file content
|
||||||
|
|
||||||
|
// click copy button
|
||||||
|
await page.getByTestId("backup-codes-copy").click();
|
||||||
|
await page.getByTestId("toast-success").waitFor();
|
||||||
|
// TODO: check clipboard content
|
||||||
|
|
||||||
|
// close backup code dialog
|
||||||
|
await page.getByTestId("backup-codes-close").click();
|
||||||
|
|
||||||
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();
|
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|
|
@ -225,6 +225,7 @@
|
||||||
"create_account": "Konto erstellen",
|
"create_account": "Konto erstellen",
|
||||||
"confirm_password": "Passwort bestätigen",
|
"confirm_password": "Passwort bestätigen",
|
||||||
"confirm_auth_change": "Dies ändert die Art und Weise, wie Sie sich anmelden",
|
"confirm_auth_change": "Dies ändert die Art und Weise, wie Sie sich anmelden",
|
||||||
|
"confirm_auth_email_change": "Wenn Sie Ihre E-Mail-Adresse ändern, wird Ihre derzeitige Authentifizierungsmethode für die Anmeldung bei Cal.com unterbrochen. Wir werden Sie bitten, Ihre neue E-Mail-Adresse zu verifizieren. In der Folge werden Sie abgemeldet und verwenden Ihre neue E-Mail-Adresse, um sich anstelle Ihrer aktuellen Authentifizierungsmethode anzumelden, nachdem Sie Ihr Passwort anhand der Anweisungen, die Ihnen per E-Mail zugesandt werden, festgelegt haben.",
|
||||||
"reset_your_password": "Legen Sie Ihr neues Passwort mit den Anweisungen fest, die an Ihre E-Mail-Adresse gesendet wurden.",
|
"reset_your_password": "Legen Sie Ihr neues Passwort mit den Anweisungen fest, die an Ihre E-Mail-Adresse gesendet wurden.",
|
||||||
"email_change": "Melden Sie sich mit Ihrer neuen E-Mail-Adresse und Ihrem Passwort wieder an.",
|
"email_change": "Melden Sie sich mit Ihrer neuen E-Mail-Adresse und Ihrem Passwort wieder an.",
|
||||||
"create_your_account": "Erstellen Sie Ihr Konto",
|
"create_your_account": "Erstellen Sie Ihr Konto",
|
||||||
|
@ -255,6 +256,7 @@
|
||||||
"available_apps": "Verfügbare Apps",
|
"available_apps": "Verfügbare Apps",
|
||||||
"available_apps_lower_case": "Verfügbare Apps",
|
"available_apps_lower_case": "Verfügbare Apps",
|
||||||
"available_apps_desc": "Sie haben keine Apps installiert. Sehen Sie sich beliebte Apps unten an und entdecken Sie noch mehr in unserem <1>App Store</1>",
|
"available_apps_desc": "Sie haben keine Apps installiert. Sehen Sie sich beliebte Apps unten an und entdecken Sie noch mehr in unserem <1>App Store</1>",
|
||||||
|
"fixed_host_helper": "Füge jeden hinzu, der an der Veranstaltung teilnehmen muss. <1>Mehr erfahren</1>",
|
||||||
"check_email_reset_password": "Überprüfen Sie Ihre E-Mail. Wir haben Ihnen einen Link zum Zurücksetzen Ihres Passworts gesendet.",
|
"check_email_reset_password": "Überprüfen Sie Ihre E-Mail. Wir haben Ihnen einen Link zum Zurücksetzen Ihres Passworts gesendet.",
|
||||||
"finish": "Fertig",
|
"finish": "Fertig",
|
||||||
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
|
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
|
||||||
|
@ -559,6 +561,7 @@
|
||||||
"leave": "Verlassen",
|
"leave": "Verlassen",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"my_team_url": "Meine Team-URL",
|
"my_team_url": "Meine Team-URL",
|
||||||
|
"my_teams": "Meine Teams",
|
||||||
"team_name": "Teamname",
|
"team_name": "Teamname",
|
||||||
"your_team_name": "Ihr Teamname",
|
"your_team_name": "Ihr Teamname",
|
||||||
"team_updated_successfully": "Team erfolgreich aktualisiert",
|
"team_updated_successfully": "Team erfolgreich aktualisiert",
|
||||||
|
@ -1974,6 +1977,8 @@
|
||||||
"org_team_names_example_5": "z.B. Data Analytics Team",
|
"org_team_names_example_5": "z.B. Data Analytics Team",
|
||||||
"org_max_team_warnings": "Weitere Teams können Sie zu einem späteren Zeitpunkt hinzufügen.",
|
"org_max_team_warnings": "Weitere Teams können Sie zu einem späteren Zeitpunkt hinzufügen.",
|
||||||
"what_is_this_meeting_about": "Worum geht es in diesem Termin?",
|
"what_is_this_meeting_about": "Worum geht es in diesem Termin?",
|
||||||
|
"add_to_team": "Zum Team hinzufügen",
|
||||||
|
"user_isnt_in_any_teams": "Dieser Benutzer ist in keinem Team",
|
||||||
"kyc_verification_information": "Aus Sicherheitsgründen müssen Sie Ihren {{teamOrAccount}} verifizieren, bevor Sie Textnachrichten an die Teilnehmer senden können. Bitte kontaktieren Sie uns unter <a>{{supportEmail}}</a> und geben Sie folgende Informationen an:",
|
"kyc_verification_information": "Aus Sicherheitsgründen müssen Sie Ihren {{teamOrAccount}} verifizieren, bevor Sie Textnachrichten an die Teilnehmer senden können. Bitte kontaktieren Sie uns unter <a>{{supportEmail}}</a> und geben Sie folgende Informationen an:",
|
||||||
"org_admin_other_teams": "Weitere Teams",
|
"org_admin_other_teams": "Weitere Teams",
|
||||||
"no_other_teams_found": "Keine weiteren Teams gefunden",
|
"no_other_teams_found": "Keine weiteren Teams gefunden",
|
||||||
|
|
|
@ -2010,6 +2010,13 @@
|
||||||
"member_removed": "Member removed",
|
"member_removed": "Member removed",
|
||||||
"my_availability": "My Availability",
|
"my_availability": "My Availability",
|
||||||
"team_availability": "Team Availability",
|
"team_availability": "Team Availability",
|
||||||
|
"backup_code": "Backup Code",
|
||||||
|
"backup_codes": "Backup Codes",
|
||||||
|
"backup_code_instructions": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||||
|
"backup_codes_copied": "Backup codes copied!",
|
||||||
|
"incorrect_backup_code": "Backup code is incorrect.",
|
||||||
|
"lost_access": "Lost access",
|
||||||
|
"missing_backup_codes": "No backup codes found. Please generate them in your settings.",
|
||||||
"admin_org_notification_email_subject": "New organization created: pending action",
|
"admin_org_notification_email_subject": "New organization created: pending action",
|
||||||
"hi_admin": "Hi Administrator",
|
"hi_admin": "Hi Administrator",
|
||||||
"admin_org_notification_email_title": "An organization requires DNS setup",
|
"admin_org_notification_email_title": "An organization requires DNS setup",
|
||||||
|
@ -2026,5 +2033,6 @@
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
"your_organization_updated_sucessfully": "Your organization updated successfully",
|
"your_organization_updated_sucessfully": "Your organization updated successfully",
|
||||||
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
||||||
|
"include_calendar_event": "Include calendar event",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1988,6 +1988,8 @@
|
||||||
"remove_users_from_org_confirm": "Voulez-vous vraiment supprimer {{userCount}} utilisateurs de cette organisation ?",
|
"remove_users_from_org_confirm": "Voulez-vous vraiment supprimer {{userCount}} utilisateurs de cette organisation ?",
|
||||||
"user_has_no_schedules": "Cet utilisateur n'a pas encore configuré de planning",
|
"user_has_no_schedules": "Cet utilisateur n'a pas encore configuré de planning",
|
||||||
"user_isnt_in_any_teams": "Cet utilisateur n'est dans aucune équipe",
|
"user_isnt_in_any_teams": "Cet utilisateur n'est dans aucune équipe",
|
||||||
|
"requires_booker_email_verification": "Nécessite la vérification par e-mail du participant",
|
||||||
|
"description_requires_booker_email_verification": "Pour assurer la vérification par e-mail du participant avant la planification des événements.",
|
||||||
"requires_confirmation_mandatory": "Les messages ne peuvent être envoyés aux participants que lorsque le type d'événement nécessite une confirmation.",
|
"requires_confirmation_mandatory": "Les messages ne peuvent être envoyés aux participants que lorsque le type d'événement nécessite une confirmation.",
|
||||||
"kyc_verification_information": "Pour garantir la sécurité, vous devez vérifier votre {{teamOrAccount}} avant d'envoyer des messages aux participants. Veuillez nous contacter à <a>{{supportEmail}}</a> et fournir les informations suivantes :",
|
"kyc_verification_information": "Pour garantir la sécurité, vous devez vérifier votre {{teamOrAccount}} avant d'envoyer des messages aux participants. Veuillez nous contacter à <a>{{supportEmail}}</a> et fournir les informations suivantes :",
|
||||||
"kyc_verification_documents": "<ul><li>Votre {{teamOrUser}}</li><li>Pour les entreprises : Joignez votre document de vérification d'entreprise</li><li>Pour les particuliers : Joignez une pièce d'identité</li></ul>",
|
"kyc_verification_documents": "<ul><li>Votre {{teamOrUser}}</li><li>Pour les entreprises : Joignez votre document de vérification d'entreprise</li><li>Pour les particuliers : Joignez une pièce d'identité</li></ul>",
|
||||||
|
@ -2001,16 +2003,28 @@
|
||||||
"no_other_teams_found_description": "Il n'y a pas d'autres équipes dans cette organisation.",
|
"no_other_teams_found_description": "Il n'y a pas d'autres équipes dans cette organisation.",
|
||||||
"attendee_first_name_variable": "Prénom du participant",
|
"attendee_first_name_variable": "Prénom du participant",
|
||||||
"attendee_last_name_variable": "Nom du participant",
|
"attendee_last_name_variable": "Nom du participant",
|
||||||
|
"attendee_first_name_info": "Le prénom du participant",
|
||||||
|
"attendee_last_name_info": "Le nom de famille du participant",
|
||||||
"me": "Moi",
|
"me": "Moi",
|
||||||
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
|
||||||
"member_removed": "Membre supprimé",
|
"member_removed": "Membre supprimé",
|
||||||
"my_availability": "Mes disponibilités",
|
"my_availability": "Mes disponibilités",
|
||||||
"team_availability": "Disponibilités de l'équipe",
|
"team_availability": "Disponibilités de l'équipe",
|
||||||
|
"backup_code": "Code de récupération",
|
||||||
|
"backup_codes": "Codes de récupération",
|
||||||
|
"backup_code_instructions": "Chaque code de récupération peut être utilisé une seule fois pour accorder l'accès sans votre authentificateur.",
|
||||||
|
"backup_codes_copied": "Codes de récupération copiés !",
|
||||||
|
"incorrect_backup_code": "Le code de récupération est incorrect.",
|
||||||
|
"lost_access": "Accès perdu",
|
||||||
|
"missing_backup_codes": "Aucun code de récupération trouvé. Merci de les générer dans vos paramètres.",
|
||||||
"admin_org_notification_email_subject": "Nouvelle organisation créée : action en attente",
|
"admin_org_notification_email_subject": "Nouvelle organisation créée : action en attente",
|
||||||
|
"hi_admin": "Bonjour administrateur",
|
||||||
"admin_org_notification_email_title": "Une organisation nécessite une configuration DNS",
|
"admin_org_notification_email_title": "Une organisation nécessite une configuration DNS",
|
||||||
|
"admin_org_notification_email_body_part1": "Une organisation avec le slug « {{orgSlug}} » a été créée.<br /><br />Assurez-vous de configurer votre registre DNS pour pointer le sous-domaine correspondant à la nouvelle organisation où l'application principale est en cours d'exécution. Autrement, l'organisation ne fonctionnera pas.<br /><br />Voici les options de base pour configurer un sous-domaine pour qu'il pointe vers son application afin qu'il charge la page de profil de l'organisation.<br /><br />Vous pouvez le faire soit avec l'enregistrement A :",
|
||||||
"admin_org_notification_email_body_part2": "Ou l'enregistrement CNAME :",
|
"admin_org_notification_email_body_part2": "Ou l'enregistrement CNAME :",
|
||||||
"admin_org_notification_email_body_part3": "Une fois le sous-domaine configuré, veuillez marquer la configuration DNS comme terminée dans les paramètres d'administration des organisations.",
|
"admin_org_notification_email_body_part3": "Une fois le sous-domaine configuré, veuillez marquer la configuration DNS comme terminée dans les paramètres d'administration des organisations.",
|
||||||
"admin_org_notification_email_cta": "Accéder aux paramètres d'administration des organisations",
|
"admin_org_notification_email_cta": "Accéder aux paramètres d'administration des organisations",
|
||||||
|
"org_has_been_processed": "L'organisation a été traitée",
|
||||||
"org_error_processing": "Une erreur s'est produite lors du traitement de cette organisation",
|
"org_error_processing": "Une erreur s'est produite lors du traitement de cette organisation",
|
||||||
"orgs_page_description": "Une liste de toutes les organisations. Accepter une organisation permettra à tous les utilisateurs avec ce domaine de messagerie de s'inscrire SANS vérification d'e-mail.",
|
"orgs_page_description": "Une liste de toutes les organisations. Accepter une organisation permettra à tous les utilisateurs avec ce domaine de messagerie de s'inscrire SANS vérification d'e-mail.",
|
||||||
"unverified": "Non vérifié",
|
"unverified": "Non vérifié",
|
||||||
|
@ -2018,5 +2032,7 @@
|
||||||
"mark_dns_configured": "Marquer comme DNS configuré",
|
"mark_dns_configured": "Marquer comme DNS configuré",
|
||||||
"value": "Valeur",
|
"value": "Valeur",
|
||||||
"your_organization_updated_sucessfully": "Votre organisation a été mise à jour avec succès",
|
"your_organization_updated_sucessfully": "Votre organisation a été mise à jour avec succès",
|
||||||
|
"seat_options_doesnt_multiple_durations": "L'option par place ne prend pas en charge les durées multiples",
|
||||||
|
"include_calendar_event": "Inclure l'événement du calendrier",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1944,5 +1944,6 @@
|
||||||
"insights_user_filter": "用户:{{userName}}",
|
"insights_user_filter": "用户:{{userName}}",
|
||||||
"insights_subtitle": "查看您活动的预约 insights",
|
"insights_subtitle": "查看您活动的预约 insights",
|
||||||
"custom_plan": "自定义计划",
|
"custom_plan": "自定义计划",
|
||||||
|
"include_calendar_event": "包括日历活动",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1854,7 +1854,7 @@
|
||||||
"payment_app_commission": "需付款 ({{paymentFeePercentage}}% + {{fee, currency}} 每筆交易佣金)",
|
"payment_app_commission": "需付款 ({{paymentFeePercentage}}% + {{fee, currency}} 每筆交易佣金)",
|
||||||
"email_invite_team": "已邀請 {{email}}",
|
"email_invite_team": "已邀請 {{email}}",
|
||||||
"email_invite_team_bulk": "已邀請 {{userCount}} 位使用者",
|
"email_invite_team_bulk": "已邀請 {{userCount}} 位使用者",
|
||||||
"error_collecting_card": "卡片發生錯誤",
|
"error_collecting_card": "收集卡片時發生錯誤",
|
||||||
"image_size_limit_exceed": "上傳的圖片大小不得超過 5MB 限制",
|
"image_size_limit_exceed": "上傳的圖片大小不得超過 5MB 限制",
|
||||||
"unauthorized_workflow_error_message": "{{errorCode}}:您未獲得授權,無法啟用或停用此工作流程",
|
"unauthorized_workflow_error_message": "{{errorCode}}:您未獲得授權,無法啟用或停用此工作流程",
|
||||||
"inline_embed": "內嵌式嵌入",
|
"inline_embed": "內嵌式嵌入",
|
||||||
|
|
|
@ -5,15 +5,21 @@ export type GetAppData = (key: string) => unknown;
|
||||||
export type SetAppData = (key: string, value: unknown) => void;
|
export type SetAppData = (key: string, value: unknown) => void;
|
||||||
type LockedIcon = JSX.Element | false | undefined;
|
type LockedIcon = JSX.Element | false | undefined;
|
||||||
type Disabled = boolean | undefined;
|
type Disabled = boolean | undefined;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
const EventTypeAppContext = React.createContext<[GetAppData, SetAppData, LockedIcon, Disabled]>([
|
|
||||||
() => ({}),
|
|
||||||
() => ({}),
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type SetAppDataGeneric<TAppData extends ZodType> = <
|
type AppContext = {
|
||||||
|
getAppData: GetAppData;
|
||||||
|
setAppData: SetAppData;
|
||||||
|
LockedIcon?: LockedIcon;
|
||||||
|
disabled?: Disabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
const EventTypeAppContext = React.createContext<AppContext>({
|
||||||
|
getAppData: () => ({}),
|
||||||
|
setAppData: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SetAppDataGeneric<TAppData extends ZodType> = <
|
||||||
TKey extends keyof z.infer<TAppData>,
|
TKey extends keyof z.infer<TAppData>,
|
||||||
TValue extends z.infer<TAppData>[TKey]
|
TValue extends z.infer<TAppData>[TKey]
|
||||||
>(
|
>(
|
||||||
|
@ -21,7 +27,7 @@ export type SetAppDataGeneric<TAppData extends ZodType> = <
|
||||||
value: TValue
|
value: TValue
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
export type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
|
type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
|
||||||
key: TKey
|
key: TKey
|
||||||
) => z.infer<TAppData>[TKey];
|
) => z.infer<TAppData>[TKey];
|
||||||
|
|
||||||
|
@ -29,7 +35,12 @@ export const useAppContextWithSchema = <TAppData extends ZodType>() => {
|
||||||
type GetAppData = GetAppDataGeneric<TAppData>;
|
type GetAppData = GetAppDataGeneric<TAppData>;
|
||||||
type SetAppData = SetAppDataGeneric<TAppData>;
|
type SetAppData = SetAppDataGeneric<TAppData>;
|
||||||
// TODO: Not able to do it without type assertion here
|
// TODO: Not able to do it without type assertion here
|
||||||
const context = React.useContext(EventTypeAppContext) as [GetAppData, SetAppData, LockedIcon, Disabled];
|
const context = React.useContext(EventTypeAppContext) as {
|
||||||
|
getAppData: GetAppData;
|
||||||
|
setAppData: SetAppData;
|
||||||
|
LockedIcon: LockedIcon;
|
||||||
|
disabled: Disabled;
|
||||||
|
};
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
export default EventTypeAppContext;
|
export default EventTypeAppContext;
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { Switch, Badge, Avatar } from "@calcom/ui";
|
import { Switch, Badge, Avatar } from "@calcom/ui";
|
||||||
|
|
||||||
import type { SetAppDataGeneric } from "../EventTypeAppContext";
|
|
||||||
import type { eventTypeAppCardZod } from "../eventTypeAppCardZod";
|
|
||||||
import type { CredentialOwner } from "../types";
|
import type { CredentialOwner } from "../types";
|
||||||
import OmniInstallAppButton from "./OmniInstallAppButton";
|
import OmniInstallAppButton from "./OmniInstallAppButton";
|
||||||
|
|
||||||
|
@ -16,24 +15,20 @@ export default function AppCard({
|
||||||
switchOnClick,
|
switchOnClick,
|
||||||
switchChecked,
|
switchChecked,
|
||||||
children,
|
children,
|
||||||
setAppData,
|
|
||||||
returnTo,
|
returnTo,
|
||||||
teamId,
|
teamId,
|
||||||
disableSwitch,
|
|
||||||
LockedIcon,
|
|
||||||
}: {
|
}: {
|
||||||
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
|
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
|
||||||
description?: React.ReactNode;
|
description?: React.ReactNode;
|
||||||
switchChecked?: boolean;
|
switchChecked?: boolean;
|
||||||
switchOnClick?: (e: boolean) => void;
|
switchOnClick?: (e: boolean) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
setAppData: SetAppDataGeneric<typeof eventTypeAppCardZod>;
|
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
disableSwitch?: boolean;
|
|
||||||
LockedIcon?: React.ReactNode;
|
LockedIcon?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
|
const { setAppData, LockedIcon, disabled } = useAppContextWithSchema();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -92,7 +87,7 @@ export default function AppCard({
|
||||||
{app?.isInstalled || app.credentialOwner ? (
|
{app?.isInstalled || app.credentialOwner ? (
|
||||||
<div className="ml-auto flex items-center">
|
<div className="ml-auto flex items-center">
|
||||||
<Switch
|
<Switch
|
||||||
disabled={!app.enabled || disableSwitch}
|
disabled={!app.enabled || disabled}
|
||||||
onCheckedChange={(enabled) => {
|
onCheckedChange={(enabled) => {
|
||||||
if (switchOnClick) {
|
if (switchOnClick) {
|
||||||
switchOnClick(enabled);
|
switchOnClick(enabled);
|
||||||
|
|
|
@ -19,7 +19,7 @@ export const EventTypeAppCard = (props: {
|
||||||
const { app, getAppData, setAppData, LockedIcon, disabled } = props;
|
const { app, getAppData, setAppData, LockedIcon, disabled } = props;
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
|
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
|
||||||
<EventTypeAppContext.Provider value={[getAppData, setAppData, LockedIcon, disabled]}>
|
<EventTypeAppContext.Provider value={{ getAppData, setAppData, LockedIcon, disabled }}>
|
||||||
<DynamicComponent
|
<DynamicComponent
|
||||||
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
|
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
|
||||||
componentMap={EventTypeAddonMap}
|
componentMap={EventTypeAddonMap}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||||
import type { EventTypeAppCardApp } from "../types";
|
import type { EventTypeAppCardApp } from "../types";
|
||||||
|
|
||||||
function useIsAppEnabled(app: EventTypeAppCardApp) {
|
function useIsAppEnabled(app: EventTypeAppCardApp) {
|
||||||
const [getAppData, setAppData] = useAppContextWithSchema();
|
const { getAppData, setAppData } = useAppContextWithSchema();
|
||||||
const [enabled, setEnabled] = useState(() => {
|
const [enabled, setEnabled] = useState(() => {
|
||||||
if (!app.credentialOwner) {
|
if (!app.credentialOwner) {
|
||||||
return getAppData("enabled");
|
return getAppData("enabled");
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { Select } from "@calcom/ui";
|
||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
|
||||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const [enabled, setEnabled] = useState(getAppData("enabled"));
|
const [enabled, setEnabled] = useState(getAppData("enabled"));
|
||||||
const [projects, setProjects] = useState();
|
const [projects, setProjects] = useState();
|
||||||
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
|
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
|
||||||
|
@ -32,7 +32,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
|
|
|
@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const trackingId = getAppData("trackingId");
|
const trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(e);
|
updateEnabled(e);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const trackingId = getAppData("trackingId");
|
const trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(e);
|
updateEnabled(e);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const thankYouPage = getAppData("thankYouPage");
|
const thankYouPage = getAppData("thankYouPage");
|
||||||
const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app);
|
const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app);
|
||||||
|
|
||||||
|
@ -16,11 +16,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
description={t("confirmation_page_gif")}
|
description={t("confirmation_page_gif")}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
setShowGifSelection(e);
|
setShowGifSelection(e);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const trackingId = getAppData("trackingId");
|
const trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(e);
|
updateEnabled(e);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
|
||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const trackingId = getAppData("trackingId");
|
const trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={updateEnabled}
|
switchOnClick={updateEnabled}
|
||||||
switchChecked={enabled}
|
switchChecked={enabled}
|
||||||
teamId={eventType.team?.id || undefined}>
|
teamId={eventType.team?.id || undefined}>
|
||||||
|
|
|
@ -16,7 +16,7 @@ type Option = { value: string; label: string };
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const { asPath } = useRouter();
|
const { asPath } = useRouter();
|
||||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const price = getAppData("price");
|
const price = getAppData("price");
|
||||||
const currency = getAppData("currency");
|
const currency = getAppData("currency");
|
||||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||||
|
@ -38,7 +38,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
returnTo={WEBAPP_URL + asPath}
|
returnTo={WEBAPP_URL + asPath}
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
switchChecked={requirePayment}
|
switchChecked={requirePayment}
|
||||||
switchOnClick={(enabled) => {
|
switchOnClick={(enabled) => {
|
||||||
|
|
|
@ -7,17 +7,14 @@ import { TextField } from "@calcom/ui";
|
||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const plausibleUrl = getAppData("PLAUSIBLE_URL");
|
const plausibleUrl = getAppData("PLAUSIBLE_URL");
|
||||||
const trackingId = getAppData("trackingId");
|
const trackingId = getAppData("trackingId");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(e);
|
updateEnabled(e);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [_getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const [additionalParameters, setAdditionalParameters] = useState("");
|
const [additionalParameters, setAdditionalParameters] = useState("");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
|
@ -37,10 +37,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
updateEnabled(e);
|
updateEnabled(e);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -15,7 +15,7 @@ type Option = { value: string; label: string };
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const price = getAppData("price");
|
const price = getAppData("price");
|
||||||
const currency = getAppData("currency");
|
const currency = getAppData("currency");
|
||||||
const paymentOption = getAppData("paymentOption");
|
const paymentOption = getAppData("paymentOption");
|
||||||
|
@ -38,10 +38,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
returnTo={WEBAPP_URL + pathname + "?tabName=apps"}
|
returnTo={WEBAPP_URL + pathname + "?tabName=apps"}
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchChecked={requirePayment}
|
switchChecked={requirePayment}
|
||||||
switchOnClick={(enabled) => {
|
switchOnClick={(enabled) => {
|
||||||
setRequirePayment(enabled);
|
setRequirePayment(enabled);
|
||||||
|
|
|
@ -8,13 +8,12 @@ import { TextField } from "@calcom/ui";
|
||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||||
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const trackingId = getAppData("trackingId");
|
const trackingId = getAppData("trackingId");
|
||||||
const [enabled, setEnabled] = useState(getAppData("enabled"));
|
const [enabled, setEnabled] = useState(getAppData("enabled"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
|
|
|
@ -7,16 +7,13 @@ import { Sunrise, Sunset } from "@calcom/ui/components/icon";
|
||||||
import type { appDataSchema } from "../zod";
|
import type { appDataSchema } from "../zod";
|
||||||
|
|
||||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
|
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
|
||||||
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
|
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||||
const isSunrise = getAppData("isSunrise");
|
const isSunrise = getAppData("isSunrise");
|
||||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
setAppData={setAppData}
|
|
||||||
app={app}
|
app={app}
|
||||||
disableSwitch={disabled}
|
|
||||||
LockedIcon={LockedIcon}
|
|
||||||
switchOnClick={(e) => {
|
switchOnClick={(e) => {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
updateEnabled(false);
|
updateEnabled(false);
|
||||||
|
|
|
@ -8,6 +8,8 @@ export enum ErrorCode {
|
||||||
TwoFactorSetupRequired = "two-factor-setup-required",
|
TwoFactorSetupRequired = "two-factor-setup-required",
|
||||||
SecondFactorRequired = "second-factor-required",
|
SecondFactorRequired = "second-factor-required",
|
||||||
IncorrectTwoFactorCode = "incorrect-two-factor-code",
|
IncorrectTwoFactorCode = "incorrect-two-factor-code",
|
||||||
|
IncorrectBackupCode = "incorrect-backup-code",
|
||||||
|
MissingBackupCodes = "missing-backup-codes",
|
||||||
IncorrectEmailVerificationCode = "incorrect_email_verification_code",
|
IncorrectEmailVerificationCode = "incorrect_email_verification_code",
|
||||||
InternalServerError = "internal-server-error",
|
InternalServerError = "internal-server-error",
|
||||||
NewPasswordMatchesOld = "new-password-matches-old",
|
NewPasswordMatchesOld = "new-password-matches-old",
|
||||||
|
|
|
@ -10,15 +10,11 @@ export function isPasswordValid(password: string, breakdown?: boolean, strict?:
|
||||||
num = false, // At least one number
|
num = false, // At least one number
|
||||||
min = false, // Eight characters, or fifteen in strict mode.
|
min = false, // Eight characters, or fifteen in strict mode.
|
||||||
admin_min = false;
|
admin_min = false;
|
||||||
if (password.length > 7 && (!strict || password.length > 14)) min = true;
|
if (password.length >= 7 && (!strict || password.length > 14)) min = true;
|
||||||
if (strict && password.length > 14) admin_min = true;
|
if (strict && password.length > 14) admin_min = true;
|
||||||
for (let i = 0; i < password.length; i++) {
|
if (password.match(/\d/)) num = true;
|
||||||
if (!isNaN(parseInt(password[i]))) num = true;
|
if (password.match(/[a-z]/)) low = true;
|
||||||
else {
|
if (password.match(/[A-Z]/)) cap = true;
|
||||||
if (password[i] === password[i].toUpperCase()) cap = true;
|
|
||||||
if (password[i] === password[i].toLowerCase()) low = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!breakdown) return cap && low && num && min && (strict ? admin_min : true);
|
if (!breakdown) return cap && low && num && min && (strict ? admin_min : true);
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/Imperso
|
||||||
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
|
||||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||||
import { isENVDev } from "@calcom/lib/env";
|
import { isENVDev } from "@calcom/lib/env";
|
||||||
import { randomString } from "@calcom/lib/random";
|
import { randomString } from "@calcom/lib/random";
|
||||||
|
@ -62,6 +62,7 @@ const providers: Provider[] = [
|
||||||
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
|
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
|
||||||
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
|
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
|
||||||
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
|
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
|
||||||
|
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
|
@ -85,6 +86,7 @@ const providers: Provider[] = [
|
||||||
organizationId: true,
|
organizationId: true,
|
||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
twoFactorSecret: true,
|
twoFactorSecret: true,
|
||||||
|
backupCodes: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
organization: {
|
organization: {
|
||||||
select: {
|
select: {
|
||||||
|
@ -126,7 +128,33 @@ const providers: Provider[] = [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.twoFactorEnabled) {
|
if (user.twoFactorEnabled && credentials.backupCode) {
|
||||||
|
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
|
||||||
|
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||||
|
throw new Error(ErrorCode.InternalServerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.backupCodes) throw new Error(ErrorCode.MissingBackupCodes);
|
||||||
|
|
||||||
|
const backupCodes = JSON.parse(
|
||||||
|
symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
|
||||||
|
);
|
||||||
|
|
||||||
|
// check if user-supplied code matches one
|
||||||
|
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
|
||||||
|
if (index === -1) throw new Error(ErrorCode.IncorrectBackupCode);
|
||||||
|
|
||||||
|
// delete verified backup code and re-encrypt remaining
|
||||||
|
backupCodes[index] = null;
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (user.twoFactorEnabled) {
|
||||||
if (!credentials.totpCode) {
|
if (!credentials.totpCode) {
|
||||||
throw new Error(ErrorCode.SecondFactorRequired);
|
throw new Error(ErrorCode.SecondFactorRequired);
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,13 +24,8 @@ export const DatePicker = () => {
|
||||||
return (
|
return (
|
||||||
<DatePickerComponent
|
<DatePickerComponent
|
||||||
isLoading={schedule.isLoading}
|
isLoading={schedule.isLoading}
|
||||||
onChange={(date: Dayjs) => {
|
onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)}
|
||||||
setSelectedDate(date.format("YYYY-MM-DD"));
|
onMonthChange={(date) => setMonth(date.format("YYYY-MM"))}
|
||||||
}}
|
|
||||||
onMonthChange={(date: Dayjs) => {
|
|
||||||
setMonth(date.format("YYYY-MM"));
|
|
||||||
setSelectedDate(date.format("YYYY-MM-DD"));
|
|
||||||
}}
|
|
||||||
includedDates={nonEmptyScheduleDays}
|
includedDates={nonEmptyScheduleDays}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
browsingDate={month ? dayjs(month) : undefined}
|
browsingDate={month ? dayjs(month) : undefined}
|
||||||
|
|
|
@ -160,7 +160,8 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
|
||||||
updateQueryParam("date", selectedDate ?? "");
|
updateQueryParam("date", selectedDate ?? "");
|
||||||
|
|
||||||
// Setting month make sure small calendar in fullscreen layouts also updates.
|
// Setting month make sure small calendar in fullscreen layouts also updates.
|
||||||
if (newSelection.month() !== currentSelection.month()) {
|
// If selectedDate is null, prevents setting month to Invalid-Date
|
||||||
|
if (selectedDate && newSelection.month() !== currentSelection.month() ) {
|
||||||
set({ month: newSelection.format("YYYY-MM") });
|
set({ month: newSelection.format("YYYY-MM") });
|
||||||
updateQueryParam("month", newSelection.format("YYYY-MM"));
|
updateQueryParam("month", newSelection.format("YYYY-MM"));
|
||||||
}
|
}
|
||||||
|
@ -193,7 +194,6 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
|
||||||
setMonth: (month: string | null) => {
|
setMonth: (month: string | null) => {
|
||||||
set({ month, selectedTimeslot: null });
|
set({ month, selectedTimeslot: null });
|
||||||
updateQueryParam("month", month ?? "");
|
updateQueryParam("month", month ?? "");
|
||||||
get().setSelectedDate(null);
|
|
||||||
},
|
},
|
||||||
isTeamEvent: false,
|
isTeamEvent: false,
|
||||||
seatedEventData: {
|
seatedEventData: {
|
||||||
|
|
|
@ -9,9 +9,10 @@ import type { PublicEvent } from "../../types";
|
||||||
|
|
||||||
export const EventDuration = ({ event }: { event: PublicEvent }) => {
|
export const EventDuration = ({ event }: { event: PublicEvent }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [selectedDuration, setSelectedDuration] = useBookerStore((state) => [
|
const [selectedDuration, setSelectedDuration, state] = useBookerStore((state) => [
|
||||||
state.selectedDuration,
|
state.selectedDuration,
|
||||||
state.setSelectedDuration,
|
state.setSelectedDuration,
|
||||||
|
state.state,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isDynamicEvent = "isDynamic" in event && event.isDynamic;
|
const isDynamicEvent = "isDynamic" in event && event.isDynamic;
|
||||||
|
@ -30,14 +31,16 @@ export const EventDuration = ({ event }: { event: PublicEvent }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{durations.map((duration) => (
|
{durations
|
||||||
<Badge
|
.filter((dur) => state !== "booking" || dur === selectedDuration)
|
||||||
variant="gray"
|
.map((duration) => (
|
||||||
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
|
<Badge
|
||||||
size="md"
|
variant="gray"
|
||||||
key={duration}
|
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
|
||||||
onClick={() => setSelectedDuration(duration)}>{`${duration} ${t("minute_timeUnit")}`}</Badge>
|
size="md"
|
||||||
))}
|
key={duration}
|
||||||
|
onClick={() => setSelectedDuration(duration)}>{`${duration} ${t("minute_timeUnit")}`}</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -16,7 +16,7 @@ export type DatePickerProps = {
|
||||||
/** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */
|
/** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */
|
||||||
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
/** Fires whenever a selected date is changed. */
|
/** Fires whenever a selected date is changed. */
|
||||||
onChange: (date: Dayjs) => void;
|
onChange: (date: Dayjs | null) => void;
|
||||||
/** Fires when the month is changed. */
|
/** Fires when the month is changed. */
|
||||||
onMonthChange?: (date: Dayjs) => void;
|
onMonthChange?: (date: Dayjs) => void;
|
||||||
/** which date or dates are currently selected (not tracked from here) */
|
/** which date or dates are currently selected (not tracked from here) */
|
||||||
|
@ -30,7 +30,7 @@ export type DatePickerProps = {
|
||||||
/** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */
|
/** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */
|
||||||
excludedDates?: string[];
|
excludedDates?: string[];
|
||||||
/** defaults to all, which dates are bookable (inverse of excludedDates) */
|
/** defaults to all, which dates are bookable (inverse of excludedDates) */
|
||||||
includedDates?: string[];
|
includedDates?: string[] | null;
|
||||||
/** allows adding classes to the container */
|
/** allows adding classes to the container */
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Shows a small loading spinner next to the month name */
|
/** Shows a small loading spinner next to the month name */
|
||||||
|
@ -121,7 +121,7 @@ const Days = ({
|
||||||
// Create placeholder elements for empty days in first week
|
// Create placeholder elements for empty days in first week
|
||||||
const weekdayOfFirst = browsingDate.date(1).day();
|
const weekdayOfFirst = browsingDate.date(1).day();
|
||||||
const currentDate = minDate.utcOffset(browsingDate.utcOffset());
|
const currentDate = minDate.utcOffset(browsingDate.utcOffset());
|
||||||
const availableDates = (includedDates: string[] | undefined) => {
|
const availableDates = (includedDates: string[] | undefined | null) => {
|
||||||
const dates = [];
|
const dates = [];
|
||||||
const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate));
|
const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate));
|
||||||
for (
|
for (
|
||||||
|
@ -148,6 +148,21 @@ const Days = ({
|
||||||
days.push(date);
|
days.push(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const daysToRenderForTheMonth = days.map((day) => {
|
||||||
|
if (!day) return { day: null, disabled: true };
|
||||||
|
return {
|
||||||
|
day: day,
|
||||||
|
disabled:
|
||||||
|
(includedDates && !includedDates.includes(yyyymmdd(day))) || excludedDates.includes(yyyymmdd(day)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useHandleInitialDateSelection({
|
||||||
|
daysToRenderForTheMonth,
|
||||||
|
selected,
|
||||||
|
onChange: props.onChange,
|
||||||
|
});
|
||||||
|
|
||||||
const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow);
|
const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow);
|
||||||
|
|
||||||
const isActive = (day: dayjs.Dayjs) => {
|
const isActive = (day: dayjs.Dayjs) => {
|
||||||
|
@ -177,7 +192,7 @@ const Days = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{days.map((day, idx) => (
|
{daysToRenderForTheMonth.map(({ day, disabled }, idx) => (
|
||||||
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]">
|
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]">
|
||||||
{day === null ? (
|
{day === null ? (
|
||||||
<div key={`e-${idx}`} />
|
<div key={`e-${idx}`} />
|
||||||
|
@ -194,10 +209,7 @@ const Days = ({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onChange(day);
|
props.onChange(day);
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={disabled}
|
||||||
(includedDates && !includedDates.includes(yyyymmdd(day))) ||
|
|
||||||
excludedDates.includes(yyyymmdd(day))
|
|
||||||
}
|
|
||||||
active={isActive(day)}
|
active={isActive(day)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -293,4 +305,41 @@ const DatePicker = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes care of selecting a valid date in the month if the selected date is not available in the month
|
||||||
|
*/
|
||||||
|
const useHandleInitialDateSelection = ({
|
||||||
|
daysToRenderForTheMonth,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
daysToRenderForTheMonth: { day: Dayjs | null; disabled: boolean }[];
|
||||||
|
selected: Dayjs | Dayjs[] | null | undefined;
|
||||||
|
onChange: (date: Dayjs | null) => void;
|
||||||
|
}) => {
|
||||||
|
// Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment
|
||||||
|
if (selected instanceof Array) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day;
|
||||||
|
|
||||||
|
const isSelectedDateAvailable = selected
|
||||||
|
? daysToRenderForTheMonth.some(({ day, disabled }) => {
|
||||||
|
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true;
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (firstAvailableDateOfTheMonth) {
|
||||||
|
// If selected date not available in the month, select the first available date of the month
|
||||||
|
if (!isSelectedDateAvailable) {
|
||||||
|
onChange(firstAvailableDateOfTheMonth);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No date is available and if we were asked to select something inform that it couldn't be selected. This would actually help in not showing the timeslots section(with No Time Available) when no date in the month is available
|
||||||
|
if (selected) {
|
||||||
|
onChange(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default DatePicker;
|
export default DatePicker;
|
||||||
|
|
|
@ -1,10 +1,16 @@
|
||||||
/* Schedule any workflow reminder that falls within 72 hours for email */
|
/* Schedule any workflow reminder that falls within 72 hours for email */
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import client from "@sendgrid/client";
|
import client from "@sendgrid/client";
|
||||||
import sgMail from "@sendgrid/mail";
|
import sgMail from "@sendgrid/mail";
|
||||||
|
import { createEvent } from "ics";
|
||||||
|
import type { DateArray } from "ics";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { RRule } from "rrule";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||||
|
import { parseRecurringEvent } from "@calcom/lib";
|
||||||
import { defaultHandler } from "@calcom/lib/server";
|
import { defaultHandler } from "@calcom/lib/server";
|
||||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
@ -20,6 +26,65 @@ const senderEmail = process.env.SENDGRID_EMAIL as string;
|
||||||
|
|
||||||
sgMail.setApiKey(sendgridAPIKey);
|
sgMail.setApiKey(sendgridAPIKey);
|
||||||
|
|
||||||
|
type Booking = Prisma.BookingGetPayload<{
|
||||||
|
include: {
|
||||||
|
eventType: true;
|
||||||
|
user: true;
|
||||||
|
attendees: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function getiCalEventAsString(booking: Booking) {
|
||||||
|
let recurrenceRule: string | undefined = undefined;
|
||||||
|
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
|
||||||
|
if (recurringEvent?.count) {
|
||||||
|
recurrenceRule = new RRule(recurringEvent).toString().replace("RRULE:", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = uuidv4();
|
||||||
|
|
||||||
|
const icsEvent = createEvent({
|
||||||
|
uid,
|
||||||
|
startInputType: "utc",
|
||||||
|
start: dayjs(booking.startTime.toISOString() || "")
|
||||||
|
.utc()
|
||||||
|
.toArray()
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
|
||||||
|
duration: {
|
||||||
|
minutes: dayjs(booking.endTime.toISOString() || "").diff(
|
||||||
|
dayjs(booking.startTime.toISOString() || ""),
|
||||||
|
"minute"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
title: booking.eventType?.title || "",
|
||||||
|
description: booking.description || "",
|
||||||
|
location: booking.location || "",
|
||||||
|
organizer: {
|
||||||
|
email: booking.user?.email || "",
|
||||||
|
name: booking.user?.name || "",
|
||||||
|
},
|
||||||
|
attendees: [
|
||||||
|
{
|
||||||
|
name: booking.attendees[0].name,
|
||||||
|
email: booking.attendees[0].email,
|
||||||
|
partstat: "ACCEPTED",
|
||||||
|
role: "REQ-PARTICIPANT",
|
||||||
|
rsvp: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
method: "REQUEST",
|
||||||
|
...{ recurrenceRule },
|
||||||
|
status: "CONFIRMED",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (icsEvent.error) {
|
||||||
|
throw icsEvent.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return icsEvent.value;
|
||||||
|
}
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||||
if (process.env.CRON_API_KEY !== apiKey) {
|
if (process.env.CRON_API_KEY !== apiKey) {
|
||||||
|
@ -258,6 +323,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
enable: sandboxMode,
|
enable: sandboxMode,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
attachments: reminder.workflowStep.includeCalendarEvent
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"),
|
||||||
|
filename: "event.ics",
|
||||||
|
type: "text/calendar; method=REQUEST",
|
||||||
|
disposition: "attachment",
|
||||||
|
contentId: uuidv4(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -113,6 +113,7 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||||
sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID,
|
sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID,
|
||||||
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
|
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
|
||||||
numberVerificationPending: false,
|
numberVerificationPending: false,
|
||||||
|
includeCalendarEvent: false,
|
||||||
};
|
};
|
||||||
steps?.push(step);
|
steps?.push(step);
|
||||||
form.setValue("steps", steps);
|
form.setValue("steps", steps);
|
||||||
|
|
|
@ -861,6 +861,29 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||||
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
|
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{isEmailSubjectNeeded && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Controller
|
||||||
|
name={`steps.${step.stepNumber - 1}.includeCalendarEvent`}
|
||||||
|
control={form.control}
|
||||||
|
render={() => (
|
||||||
|
<CheckboxField
|
||||||
|
disabled={props.readOnly}
|
||||||
|
defaultChecked={
|
||||||
|
form.getValues(`steps.${step.stepNumber - 1}.includeCalendarEvent`) || false
|
||||||
|
}
|
||||||
|
description={t("include_calendar_event")}
|
||||||
|
onChange={(e) =>
|
||||||
|
form.setValue(
|
||||||
|
`steps.${step.stepNumber - 1}.includeCalendarEvent`,
|
||||||
|
e.target.checked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!props.readOnly && (
|
{!props.readOnly && (
|
||||||
<div className="mt-3 ">
|
<div className="mt-3 ">
|
||||||
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
|
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
import client from "@sendgrid/client";
|
import client from "@sendgrid/client";
|
||||||
import type { MailData } from "@sendgrid/helpers/classes/mail";
|
import type { MailData } from "@sendgrid/helpers/classes/mail";
|
||||||
import sgMail from "@sendgrid/mail";
|
import sgMail from "@sendgrid/mail";
|
||||||
|
import { createEvent } from "ics";
|
||||||
|
import type { ParticipationStatus } from "ics";
|
||||||
|
import type { DateArray } from "ics";
|
||||||
|
import { RRule } from "rrule";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { TimeUnit } from "@calcom/prisma/enums";
|
import type { TimeUnit } from "@calcom/prisma/enums";
|
||||||
|
@ -42,6 +48,47 @@ async function getBatchId() {
|
||||||
return batchIdResponse[1].batch_id as string;
|
return batchIdResponse[1].batch_id as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getiCalEventAsString(evt: BookingInfo, status?: ParticipationStatus) {
|
||||||
|
const uid = uuidv4();
|
||||||
|
let recurrenceRule: string | undefined = undefined;
|
||||||
|
if (evt.eventType.recurringEvent?.count) {
|
||||||
|
recurrenceRule = new RRule(evt.eventType.recurringEvent).toString().replace("RRULE:", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const icsEvent = createEvent({
|
||||||
|
uid,
|
||||||
|
startInputType: "utc",
|
||||||
|
start: dayjs(evt.startTime)
|
||||||
|
.utc()
|
||||||
|
.toArray()
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
|
||||||
|
duration: { minutes: dayjs(evt.endTime).diff(dayjs(evt.startTime), "minute") },
|
||||||
|
title: evt.title,
|
||||||
|
description: evt.additionalNotes || "",
|
||||||
|
location: evt.location || "",
|
||||||
|
organizer: { email: evt.organizer.email || "", name: evt.organizer.name },
|
||||||
|
attendees: [
|
||||||
|
{
|
||||||
|
name: preprocessNameFieldDataWithVariant("fullName", evt.attendees[0].name) as string,
|
||||||
|
email: evt.attendees[0].email,
|
||||||
|
partstat: status,
|
||||||
|
role: "REQ-PARTICIPANT",
|
||||||
|
rsvp: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
method: "REQUEST",
|
||||||
|
...{ recurrenceRule },
|
||||||
|
status: "CONFIRMED",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (icsEvent.error) {
|
||||||
|
throw icsEvent.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return icsEvent.value;
|
||||||
|
}
|
||||||
|
|
||||||
type ScheduleEmailReminderAction = Extract<
|
type ScheduleEmailReminderAction = Extract<
|
||||||
WorkflowActions,
|
WorkflowActions,
|
||||||
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
|
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
|
||||||
|
@ -62,7 +109,8 @@ export const scheduleEmailReminder = async (
|
||||||
template: WorkflowTemplates,
|
template: WorkflowTemplates,
|
||||||
sender: string,
|
sender: string,
|
||||||
hideBranding?: boolean,
|
hideBranding?: boolean,
|
||||||
seatReferenceUid?: string
|
seatReferenceUid?: string,
|
||||||
|
includeCalendarEvent?: boolean
|
||||||
) => {
|
) => {
|
||||||
if (action === WorkflowActions.EMAIL_ADDRESS) return;
|
if (action === WorkflowActions.EMAIL_ADDRESS) return;
|
||||||
const { startTime, endTime } = evt;
|
const { startTime, endTime } = evt;
|
||||||
|
@ -186,11 +234,19 @@ export const scheduleEmailReminder = async (
|
||||||
|
|
||||||
const batchId = await getBatchId();
|
const batchId = await getBatchId();
|
||||||
|
|
||||||
function sendEmail(data: Partial<MailData>) {
|
function sendEmail(data: Partial<MailData>, triggerEvent?: WorkflowTriggerEvents) {
|
||||||
if (!process.env.SENDGRID_API_KEY) {
|
if (!process.env.SENDGRID_API_KEY) {
|
||||||
console.info("No sendgrid API key provided, skipping email");
|
console.info("No sendgrid API key provided, skipping email");
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status: ParticipationStatus =
|
||||||
|
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT
|
||||||
|
? "COMPLETED"
|
||||||
|
: triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED
|
||||||
|
? "DECLINED"
|
||||||
|
: "ACCEPTED";
|
||||||
|
|
||||||
return sgMail.send({
|
return sgMail.send({
|
||||||
to: data.to,
|
to: data.to,
|
||||||
from: {
|
from: {
|
||||||
|
@ -206,6 +262,17 @@ export const scheduleEmailReminder = async (
|
||||||
enable: sandboxMode,
|
enable: sandboxMode,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
attachments: includeCalendarEvent
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
content: Buffer.from(getiCalEventAsString(evt, status) || "").toString("base64"),
|
||||||
|
filename: "event.ics",
|
||||||
|
type: "text/calendar; method=REQUEST",
|
||||||
|
disposition: "attachment",
|
||||||
|
contentId: uuidv4(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
sendAt: data.sendAt,
|
sendAt: data.sendAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -218,7 +285,7 @@ export const scheduleEmailReminder = async (
|
||||||
try {
|
try {
|
||||||
if (!sendTo) throw new Error("No email addresses provided");
|
if (!sendTo) throw new Error("No email addresses provided");
|
||||||
const addressees = Array.isArray(sendTo) ? sendTo : [sendTo];
|
const addressees = Array.isArray(sendTo) ? sendTo : [sendTo];
|
||||||
const promises = addressees.map((email) => sendEmail({ to: email }));
|
const promises = addressees.map((email) => sendEmail({ to: email }, triggerEvent));
|
||||||
// TODO: Maybe don't await for this?
|
// TODO: Maybe don't await for this?
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -237,10 +304,13 @@ export const scheduleEmailReminder = async (
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
|
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
|
||||||
await sendEmail({
|
await sendEmail(
|
||||||
to: sendTo,
|
{
|
||||||
sendAt: scheduledDate.unix(),
|
to: sendTo,
|
||||||
});
|
sendAt: scheduledDate.unix(),
|
||||||
|
},
|
||||||
|
triggerEvent
|
||||||
|
);
|
||||||
await prisma.workflowReminder.create({
|
await prisma.workflowReminder.create({
|
||||||
data: {
|
data: {
|
||||||
bookingUid: uid,
|
bookingUid: uid,
|
||||||
|
|
|
@ -106,7 +106,8 @@ const processWorkflowStep = async (
|
||||||
step.template,
|
step.template,
|
||||||
step.sender || SENDER_NAME,
|
step.sender || SENDER_NAME,
|
||||||
hideBranding,
|
hideBranding,
|
||||||
seatReferenceUid
|
seatReferenceUid,
|
||||||
|
step.includeCalendarEvent
|
||||||
);
|
);
|
||||||
} else if (isWhatsappAction(step.action)) {
|
} else if (isWhatsappAction(step.action)) {
|
||||||
const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo;
|
const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo;
|
||||||
|
|
|
@ -7,7 +7,7 @@ import type { TimeUnit } from "@calcom/prisma/enums";
|
||||||
import { WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
|
import { WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
|
||||||
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { CalEventResponses } from "@calcom/types/Calendar";
|
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { getSenderId } from "../alphanumericSenderIdSupport";
|
import { getSenderId } from "../alphanumericSenderIdSupport";
|
||||||
import * as twilio from "./smsProviders/twilioProvider";
|
import * as twilio from "./smsProviders/twilioProvider";
|
||||||
|
@ -44,6 +44,7 @@ export type BookingInfo = {
|
||||||
};
|
};
|
||||||
eventType: {
|
eventType: {
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
recurringEvent?: RecurringEvent | null;
|
||||||
};
|
};
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
|
|
@ -64,6 +64,7 @@ const formSchema = z.object({
|
||||||
emailSubject: z.string().nullable(),
|
emailSubject: z.string().nullable(),
|
||||||
template: z.nativeEnum(WorkflowTemplates),
|
template: z.nativeEnum(WorkflowTemplates),
|
||||||
numberRequired: z.boolean().nullable(),
|
numberRequired: z.boolean().nullable(),
|
||||||
|
includeCalendarEvent: z.boolean().nullable(),
|
||||||
sendTo: z
|
sendTo: z
|
||||||
.string()
|
.string()
|
||||||
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
|
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
|
||||||
|
|
|
@ -229,10 +229,8 @@ const EmailEmbed = ({ eventType, username }: { eventType?: EventType; username:
|
||||||
<div className="text-default text-sm">{t("select_date")}</div>
|
<div className="text-default text-sm">{t("select_date")}</div>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
isLoading={schedule.isLoading}
|
isLoading={schedule.isLoading}
|
||||||
onChange={(date: Dayjs) => {
|
onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)}
|
||||||
setSelectedDate(date.format("YYYY-MM-DD"));
|
onMonthChange={(date) => {
|
||||||
}}
|
|
||||||
onMonthChange={(date: Dayjs) => {
|
|
||||||
setMonth(date.format("YYYY-MM"));
|
setMonth(date.format("YYYY-MM"));
|
||||||
setSelectedDate(date.format("YYYY-MM-DD"));
|
setSelectedDate(date.format("YYYY-MM-DD"));
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -24,7 +24,10 @@ export const getFullName = (name: string | { firstName: string; lastName?: strin
|
||||||
if (typeof name === "string") {
|
if (typeof name === "string") {
|
||||||
nameString = name;
|
nameString = name;
|
||||||
} else {
|
} else {
|
||||||
nameString = name.firstName + " " + name.lastName;
|
nameString = name.firstName;
|
||||||
|
if (name.lastName) {
|
||||||
|
nameString = nameString + " " + name.lastName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nameString;
|
return nameString;
|
||||||
};
|
};
|
||||||
|
|
|
@ -51,7 +51,11 @@ const DateOverrideForm = ({
|
||||||
|
|
||||||
const [selectedDates, setSelectedDates] = useState<Dayjs[]>(value ? [dayjs.utc(value[0].start)] : []);
|
const [selectedDates, setSelectedDates] = useState<Dayjs[]>(value ? [dayjs.utc(value[0].start)] : []);
|
||||||
|
|
||||||
const onDateChange = (newDate: Dayjs) => {
|
const onDateChange = (newDate: Dayjs | null) => {
|
||||||
|
// If no date is selected, do nothing
|
||||||
|
if (!newDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If clicking on a selected date unselect it
|
// If clicking on a selected date unselect it
|
||||||
if (selectedDates.some((date) => yyyymmdd(date) === yyyymmdd(newDate))) {
|
if (selectedDates.some((date) => yyyymmdd(date) === yyyymmdd(newDate))) {
|
||||||
setSelectedDates(selectedDates.filter((date) => yyyymmdd(date) !== yyyymmdd(newDate)));
|
setSelectedDates(selectedDates.filter((date) => yyyymmdd(date) !== yyyymmdd(newDate)));
|
||||||
|
|
|
@ -189,6 +189,7 @@ export const buildUser = <T extends Partial<UserPayload>>(user?: T): UserPayload
|
||||||
availability: [],
|
availability: [],
|
||||||
avatar: "",
|
avatar: "",
|
||||||
away: false,
|
away: false,
|
||||||
|
backupCodes: null,
|
||||||
bio: null,
|
bio: null,
|
||||||
brandColor: "#292929",
|
brandColor: "#292929",
|
||||||
bufferTime: 0,
|
bufferTime: 0,
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "backupCodes" TEXT;
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "WorkflowStep" ADD COLUMN "includeCalendarEvent" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -202,6 +202,7 @@ model User {
|
||||||
timeFormat Int? @default(12)
|
timeFormat Int? @default(12)
|
||||||
twoFactorSecret String?
|
twoFactorSecret String?
|
||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
|
backupCodes String?
|
||||||
identityProvider IdentityProvider @default(CAL)
|
identityProvider IdentityProvider @default(CAL)
|
||||||
identityProviderId String?
|
identityProviderId String?
|
||||||
availability Availability[]
|
availability Availability[]
|
||||||
|
@ -738,6 +739,7 @@ model WorkflowStep {
|
||||||
numberRequired Boolean?
|
numberRequired Boolean?
|
||||||
sender String?
|
sender String?
|
||||||
numberVerificationPending Boolean @default(true)
|
numberVerificationPending Boolean @default(true)
|
||||||
|
includeCalendarEvent Boolean @default(false)
|
||||||
|
|
||||||
@@index([workflowId])
|
@@index([workflowId])
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
|
|
||||||
import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
|
import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
|
||||||
import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-builder/schema";
|
import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-builder/schema";
|
||||||
import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/schema";
|
import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/schema";
|
||||||
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
||||||
|
@ -602,6 +603,28 @@ export const emailSchemaRefinement = (value: string) => {
|
||||||
return emailRegex.test(value);
|
return emailRegex.test(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const signupSchema = z.object({
|
||||||
|
username: z.string().refine((value) => !value.includes("+"), {
|
||||||
|
message: "String should not contain a plus symbol (+).",
|
||||||
|
}),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().superRefine((data, ctx) => {
|
||||||
|
const isStrict = false;
|
||||||
|
const result = isPasswordValid(data, true, isStrict);
|
||||||
|
Object.keys(result).map((key: string) => {
|
||||||
|
if (!result[key as keyof typeof result]) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [key],
|
||||||
|
message: key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
language: z.string().optional(),
|
||||||
|
token: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const ZVerifyCodeInputSchema = z.object({
|
export const ZVerifyCodeInputSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
|
@ -610,3 +633,4 @@ export const ZVerifyCodeInputSchema = z.object({
|
||||||
export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;
|
export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;
|
||||||
|
|
||||||
export const coerceToDate = z.coerce.date();
|
export const coerceToDate = z.coerce.date();
|
||||||
|
|
||||||
|
|
|
@ -272,7 +272,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) =>
|
||||||
...group.metadata,
|
...group.metadata,
|
||||||
teamId: group.teamId,
|
teamId: group.teamId,
|
||||||
membershipRole: group.membershipRole,
|
membershipRole: group.membershipRole,
|
||||||
image: `${bookerUrl}${group.teamId ? "/team" : ""}/${group.profile.slug}/avatar.png`,
|
image: `${bookerUrl}/${group.profile.slug}/avatar.png`,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -454,6 +454,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||||
senderName: newStep.senderName,
|
senderName: newStep.senderName,
|
||||||
}),
|
}),
|
||||||
numberVerificationPending: false,
|
numberVerificationPending: false,
|
||||||
|
includeCalendarEvent: newStep.includeCalendarEvent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
//cancel all reminders of step and create new ones (not for newEventTypes)
|
//cancel all reminders of step and create new ones (not for newEventTypes)
|
||||||
|
|
|
@ -24,6 +24,7 @@ export const ZUpdateInputSchema = z.object({
|
||||||
numberRequired: z.boolean().nullable(),
|
numberRequired: z.boolean().nullable(),
|
||||||
sender: z.string().optional().nullable(),
|
sender: z.string().optional().nullable(),
|
||||||
senderName: z.string().optional().nullable(),
|
senderName: z.string().optional().nullable(),
|
||||||
|
includeCalendarEvent: z.boolean(),
|
||||||
})
|
})
|
||||||
.array(),
|
.array(),
|
||||||
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
|
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
|
||||||
|
|
|
@ -3,17 +3,21 @@ import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { Check, Circle, Info, X } from "../../icon";
|
import { Check, Circle, Info, X } from "../../icon";
|
||||||
|
|
||||||
export function HintsOrErrors<T extends FieldValues = FieldValues>(props: {
|
type hintsOrErrorsProps = {
|
||||||
hintErrors?: string[];
|
hintErrors?: string[];
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
export function HintsOrErrors<T extends FieldValues = FieldValues>({
|
||||||
|
hintErrors,
|
||||||
|
fieldName,
|
||||||
|
t,
|
||||||
|
}: hintsOrErrorsProps) {
|
||||||
const methods = useFormContext() as ReturnType<typeof useFormContext> | null;
|
const methods = useFormContext() as ReturnType<typeof useFormContext> | null;
|
||||||
/* If there's no methods it means we're using these components outside a React Hook Form context */
|
/* If there's no methods it means we're using these components outside a React Hook Form context */
|
||||||
if (!methods) return null;
|
if (!methods) return null;
|
||||||
const { formState } = methods;
|
const { formState } = methods;
|
||||||
const { hintErrors, fieldName, t } = props;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const fieldErrors: FieldErrors<T> | undefined = formState.errors[fieldName];
|
const fieldErrors: FieldErrors<T> | undefined = formState.errors[fieldName];
|
||||||
|
|
|
@ -44,7 +44,11 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
|
||||||
addOnFilled={false}
|
addOnFilled={false}
|
||||||
addOnSuffix={
|
addOnSuffix={
|
||||||
<Tooltip content={textLabel}>
|
<Tooltip content={textLabel}>
|
||||||
<button className="text-emphasis h-9" type="button" onClick={() => toggleIsPasswordVisible()}>
|
<button
|
||||||
|
className="text-emphasis h-9"
|
||||||
|
tabIndex={-1}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleIsPasswordVisible()}>
|
||||||
{isPasswordVisible ? (
|
{isPasswordVisible ? (
|
||||||
<EyeOff className="h-4 stroke-[2.5px]" />
|
<EyeOff className="h-4 stroke-[2.5px]" />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -43,13 +43,23 @@ function WizardForm<T extends DefaultStep>(props: {
|
||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-4 print:w-full">
|
<div className="mx-auto mt-4 print:w-full" data-testid="wizard-form">
|
||||||
<div className={classNames("overflow-hidden md:mb-2 md:w-[700px]", props.containerClassname)}>
|
<div className={classNames("overflow-hidden md:mb-2 md:w-[700px]", props.containerClassname)}>
|
||||||
<div className="px-6 py-5 sm:px-14">
|
<div className="px-6 py-5 sm:px-14">
|
||||||
<h1 className="font-cal text-emphasis text-2xl">{currentStep.title}</h1>
|
<h1 className="font-cal text-emphasis text-2xl" data-testid="step-title">
|
||||||
<p className="text-subtle text-sm">{currentStep.description}</p>
|
{currentStep.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-subtle text-sm" data-testid="step-description">
|
||||||
|
{currentStep.description}
|
||||||
|
</p>
|
||||||
{!props.disableNavigation && (
|
{!props.disableNavigation && (
|
||||||
<Steps maxSteps={steps.length} currentStep={step} navigateToStep={noop} stepLabel={stepLabel} />
|
<Steps
|
||||||
|
maxSteps={steps.length}
|
||||||
|
currentStep={step}
|
||||||
|
navigateToStep={noop}
|
||||||
|
stepLabel={stepLabel}
|
||||||
|
data-testid="wizard-step-component"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,138 @@
|
||||||
|
/* eslint-disable playwright/missing-playwright-await */
|
||||||
|
import { render, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
import WizardForm from "./WizardForm";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter() {
|
||||||
|
return { replace: vi.fn() };
|
||||||
|
},
|
||||||
|
useSearchParams() {
|
||||||
|
return { get: vi.fn().mockReturnValue(currentStepNavigation) };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: "Step 1",
|
||||||
|
description: "Description 1",
|
||||||
|
content: <p data-testid="content-1">Step 1</p>,
|
||||||
|
isEnabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Step 2",
|
||||||
|
description: "Description 2",
|
||||||
|
content: (setIsLoading: (value: boolean) => void) => (
|
||||||
|
<button data-testid="content-2" onClick={() => setIsLoading(true)}>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
{ title: "Step 3", description: "Description 3", content: <p data-testid="content-3">Step 3</p> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
href: "/test/mock",
|
||||||
|
steps: steps,
|
||||||
|
nextLabel: "Next step",
|
||||||
|
prevLabel: "Previous step",
|
||||||
|
finishLabel: "Finish",
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentStepNavigation: number;
|
||||||
|
|
||||||
|
const renderComponent = (extraProps?: { disableNavigation: boolean }) =>
|
||||||
|
render(<WizardForm {...props} {...extraProps} />);
|
||||||
|
|
||||||
|
describe("Tests for WizardForm component", () => {
|
||||||
|
test("Should handle all the steps correctly", async () => {
|
||||||
|
currentStepNavigation = 1;
|
||||||
|
const { queryByTestId, queryByText, rerender } = renderComponent();
|
||||||
|
const { prevLabel, nextLabel, finishLabel } = props;
|
||||||
|
const stepInfo = {
|
||||||
|
title: queryByTestId("step-title"),
|
||||||
|
description: queryByTestId("step-description"),
|
||||||
|
};
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
steps.forEach((step, index) => {
|
||||||
|
rerender(<WizardForm {...props} />);
|
||||||
|
|
||||||
|
const { title, description } = step;
|
||||||
|
const buttons = {
|
||||||
|
prev: queryByText(prevLabel),
|
||||||
|
next: queryByText(nextLabel),
|
||||||
|
finish: queryByText(finishLabel),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(stepInfo.title).toHaveTextContent(title);
|
||||||
|
expect(stepInfo.description).toHaveTextContent(description);
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
// case of first step
|
||||||
|
expect(buttons.prev && buttons.finish).not.toBeInTheDocument();
|
||||||
|
expect(buttons.next).toBeInTheDocument();
|
||||||
|
} else if (index === steps.length - 1) {
|
||||||
|
// case of last step
|
||||||
|
expect(buttons.prev && buttons.finish).toBeInTheDocument();
|
||||||
|
expect(buttons.next).not.toBeInTheDocument();
|
||||||
|
} else {
|
||||||
|
// case of in-between steps
|
||||||
|
expect(buttons.prev && buttons.next).toBeInTheDocument();
|
||||||
|
expect(buttons.finish).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStepNavigation++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Should handle the visibility of the content", async () => {
|
||||||
|
test("Should render JSX content correctly", async () => {
|
||||||
|
currentStepNavigation = 1;
|
||||||
|
const { getByTestId, getByText } = renderComponent();
|
||||||
|
const currentStep = steps[0];
|
||||||
|
|
||||||
|
expect(getByTestId("content-1")).toBeInTheDocument();
|
||||||
|
expect(getByText(currentStep.title && currentStep.description)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should render function content correctly", async () => {
|
||||||
|
currentStepNavigation = 2;
|
||||||
|
const { getByTestId, getByText } = renderComponent();
|
||||||
|
const currentStep = steps[1];
|
||||||
|
|
||||||
|
expect(getByTestId("content-2")).toBeInTheDocument();
|
||||||
|
expect(getByText(currentStep.title && currentStep.description)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should disable 'Next step' button if current step navigation is not enabled", async () => {
|
||||||
|
currentStepNavigation = 1;
|
||||||
|
const { nextLabel } = props;
|
||||||
|
const { getByText } = renderComponent();
|
||||||
|
|
||||||
|
expect(getByText(nextLabel)).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should handle when navigation is disabled", async () => {
|
||||||
|
const { queryByText, queryByTestId } = renderComponent({ disableNavigation: true });
|
||||||
|
const { prevLabel, nextLabel, finishLabel } = props;
|
||||||
|
const stepComponent = queryByTestId("wizard-step-component");
|
||||||
|
const stepInfo = {
|
||||||
|
title: queryByTestId("step-title"),
|
||||||
|
description: queryByTestId("step-description"),
|
||||||
|
};
|
||||||
|
const buttons = {
|
||||||
|
prev: queryByText(prevLabel),
|
||||||
|
next: queryByText(nextLabel),
|
||||||
|
finish: queryByText(finishLabel),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(stepInfo.title && stepInfo.description).toBeInTheDocument();
|
||||||
|
expect(stepComponent).not.toBeInTheDocument();
|
||||||
|
expect(buttons.prev && buttons.next && buttons.finish).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user