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:
Alan 2023-08-30 13:32:21 -07:00
commit 40d3c605e2
65 changed files with 831 additions and 237 deletions

View File

@ -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 }}

View File

@ -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>
);
}

View File

@ -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"
/> />
))} ))}

View File

@ -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>

View File

@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -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",
}, },

View File

@ -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"

View File

@ -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();

View File

@ -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,
}, },

View File

@ -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 });
} }

View File

@ -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

View File

@ -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,
}); });

View File

@ -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;

View File

@ -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",

View File

@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
} }

View File

@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
} }

View File

@ -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": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
} }

View File

@ -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": "內嵌式嵌入",

View File

@ -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;

View File

@ -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);

View File

@ -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}

View File

@ -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");

View File

@ -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) {

View File

@ -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);
}} }}

View File

@ -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);
}} }}

View File

@ -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);
}} }}

View File

@ -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);
}} }}

View File

@ -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}>

View File

@ -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) => {

View File

@ -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);
}} }}

View File

@ -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);
}} }}

View File

@ -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);

View File

@ -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) {

View File

@ -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);

View File

@ -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",

View File

@ -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);

View File

@ -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);
} }

View File

@ -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}

View File

@ -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: {

View File

@ -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>
); );
}; };

View File

@ -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;

View File

@ -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,
}); });
} }

View File

@ -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);

View File

@ -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)}>

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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("@"))

View File

@ -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"));
}} }}

View File

@ -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;
}; };

View File

@ -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)));

View File

@ -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,

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "backupCodes" TEXT;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "WorkflowStep" ADD COLUMN "includeCalendarEvent" BOOLEAN NOT NULL DEFAULT false;

View File

@ -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])
} }

View File

@ -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();

View File

@ -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`,
})), })),
}; };
}; };

View File

@ -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)

View File

@ -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),

View File

@ -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];

View File

@ -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]" />
) : ( ) : (

View File

@ -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>

View File

@ -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();
});
});