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 { Label, Input } from "@calcom/ui";
export default function TwoFactor({ center = true }) {
export default function TwoFactor({ center = true, autoFocus = true }) {
const [value, onChange] = useState("");
const { t } = useLocale();
const methods = useFormContext();
@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
name={`2fa${index + 1}`}
inputMode="decimal"
{...digit}
autoFocus={index === 0}
autoFocus={autoFocus && index === 0}
autoComplete="one-time-code"
/>
))}

View File

@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}}
/>
{selectedLocation && LocationOptions}
<DialogFooter>
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
onClick={() => {
setShowLocationModal(false);
setSelectedLocation?.(undefined);
setEditingLocationType?.("");
locationFormMethods.unregister(["locationType", "locationLink"]);
}}
type="button"
color="secondary">
{t("cancel")}
</Button>
<DialogFooter className="mt-4">
<Button
onClick={() => {
setShowLocationModal(false);
setSelectedLocation?.(undefined);
setEditingLocationType?.("");
locationFormMethods.unregister(["locationType", "locationLink"]);
}}
type="button"
color="secondary">
{t("cancel")}
</Button>
<Button data-testid="update-location" type="submit">
{t("update")}
</Button>
</div>
<Button data-testid="update-location" type="submit">
{t("update")}
</Button>
</DialogFooter>
</Form>
</div>

View File

@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<DialogContent enableOverflow>
<div className="flex flex-row space-x-3">
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<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 { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
import BackupCode from "@components/auth/BackupCode";
import TwoFactor from "@components/auth/TwoFactor";
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
}
interface DisableTwoFactorValues {
backupCode: string;
totpCode: string;
password: string;
}
@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
}: DisableTwoFactorAuthModalProps) => {
const [isDisabling, setIsDisabling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
const { t } = useLocale();
const form = useForm<DisableTwoFactorValues>();
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) {
return;
}
@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
setErrorMessage(null);
try {
const response = await TwoFactorAuthAPI.disable(password, totpCode);
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
if (response.status === 200) {
setTwoFactorLostAccess(false);
resetForm();
onDisable();
return;
}
@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
const body = await response.json();
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage(t("incorrect_password"));
}
if (body.error === ErrorCode.SecondFactorRequired) {
} else if (body.error === ErrorCode.SecondFactorRequired) {
setErrorMessage(t("2fa_required"));
}
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
setErrorMessage(t("incorrect_2fa"));
} else if (body.error === ErrorCode.IncorrectBackupCode) {
setErrorMessage(t("incorrect_backup_code"));
} else if (body.error === ErrorCode.MissingBackupCodes) {
setErrorMessage(t("missing_backup_codes"));
} else {
setErrorMessage(t("something_went_wrong"));
}
@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
<div className="mb-8">
{!disablePassword && (
<PasswordField
required
labelProps={{
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"
/>
)}
<TwoFactor center={false} />
{twoFactorLostAccess ? (
<BackupCode center={false} />
) : (
<TwoFactor center={false} autoFocus={false} />
)}
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div>
<DialogFooter showDivider className="relative mt-5">
<Button
color="minimal"
className="mr-auto"
onClick={() => {
setTwoFactorLostAccess(!twoFactorLostAccess);
resetForm(false);
}}>
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
</Button>
<Button color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>

View File

@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
import TwoFactor from "@components/auth/TwoFactor";
@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
enum SetupStep {
ConfirmPassword,
DisplayBackupCodes,
DisplayQrCode,
EnterTotpCode,
}
@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const setupDescriptions = {
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
};
const [step, setStep] = useState(SetupStep.ConfirmPassword);
const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState([]);
const [backupCodesUrl, setBackupCodesUrl] = useState("");
const [dataUri, setDataUri] = useState("");
const [secret, setSecret] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const resetState = () => {
setPassword("");
setErrorMessage(null);
setStep(SetupStep.ConfirmPassword);
};
async function handleSetup(e: React.FormEvent) {
e.preventDefault();
@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const body = await response.json();
if (response.status === 200) {
setBackupCodes(body.backupCodes);
// create backup codes download url
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
type: "text/plain",
});
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
setBackupCodesUrl(URL.createObjectURL(textBlob));
setDataUri(body.dataUri);
setSecret(body.secret);
setStep(SetupStep.DisplayQrCode);
@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const body = await response.json();
if (response.status === 200) {
onEnable();
setStep(SetupStep.DisplayBackupCodes);
return;
}
@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
}
}, [form, handleEnableRef, totpCode]);
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
return (
<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}>
<form onSubmit={handleSetup}>
<div className="mb-4">
<TextField
<PasswordField
label={t("password")}
type="password"
name="password"
@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
</p>
</>
</WithStep>
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
<>
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
{backupCodes.map((code) => (
<div key={code}>{formatBackupCode(code)}</div>
))}
</div>
</>
</WithStep>
<Form handleSubmit={handleEnable} form={form}>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<div className="-mt-4 pb-2">
@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
</div>
</WithStep>
<DialogFooter className="mt-8" showDivider>
<Button color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
{step !== SetupStep.DisplayBackupCodes ? (
<Button
color="secondary"
onClick={() => {
onCancel();
resetState();
}}>
{t("cancel")}
</Button>
) : null}
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<Button
type="submit"
@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
{t("enable")}
</Button>
</WithStep>
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
<>
<Button
color="secondary"
data-testid="backup-codes-close"
onClick={(e) => {
e.preventDefault();
resetState();
onEnable();
}}>
{t("close")}
</Button>
<Button
color="secondary"
data-testid="backup-codes-copy"
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
showToast(t("backup_codes_copied"), "success");
}}>
{t("copy")}
</Button>
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
<Button color="primary" data-testid="backup-codes-download">
{t("download")}
</Button>
</a>
</>
</WithStep>
</DialogFooter>
</Form>
</DialogContent>

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", {
method: "POST",
body: JSON.stringify({ password, code }),
body: JSON.stringify({ password, code, backupCode }),
headers: {
"Content-Type": "application/json",
},

View File

@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
}),
});
const formMethods = useForm<{
username: string;
email_address: string;
full_name: string;
password: string;
}>({
type formSchemaType = z.infer<typeof formSchema>;
const formMethods = useForm<formSchemaType>({
mode: "onChange",
resolver: zodResolver(formSchema),
});
@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
props.onError();
};
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
const onSubmit = formMethods.handleSubmit(async (data) => {
props.onSubmit();
const response = await fetch("/api/auth/setup", {
method: "POST",
@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
onBlur={onBlur}
name="username"
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("username", e.target.value);
await formMethods.trigger("username");
}}
onChange={(e) => onChange(e.target.value)}
/>
</>
)}
@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<TextField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("full_name", e.target.value);
await formMethods.trigger("full_name");
}}
onChange={(e) => onChange(e.target.value)}
color={formMethods.formState.errors.full_name ? "warn" : ""}
type="text"
name="full_name"
@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<EmailField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("email_address", e.target.value);
await formMethods.trigger("email_address");
}}
onChange={(e) => onChange(e.target.value)}
className="my-0"
name="email_address"
/>
@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<PasswordField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
formMethods.setValue("password", e.target.value);
await formMethods.trigger("password");
}}
onChange={(e) => onChange(e.target.value)}
hintErrors={["caplow", "admin_min", "num"]}
name="password"
className="my-0"

View File

@ -1,5 +1,4 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
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 prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } 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) {
if (req.method !== "POST") {
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 });
}
}
// 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) {
return res.status(400).json({ 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,
},
data: {
backupCodes: null,
twoFactorEnabled: false,
twoFactorSecret: null,
},

View File

@ -1,3 +1,4 @@
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { authenticator } from "otplib";
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.
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({
where: {
id: session.user.id,
},
data: {
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
twoFactorEnabled: false,
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 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 prisma from "@calcom/prisma";
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 { WithNonceProps } from "@lib/withNonce";
@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce";
import AddToHomescreen from "@components/AddToHomescreen";
import PageWrapper from "@components/PageWrapper";
import BackupCode from "@components/auth/BackupCode";
import TwoFactor from "@components/auth/TwoFactor";
import AuthContainer from "@components/ui/AuthContainer";
@ -39,6 +40,7 @@ interface LoginValues {
email: string;
password: string;
totpCode: string;
backupCode: string;
csrfToken: string;
}
export default function Login({
@ -65,6 +67,7 @@ export default function Login({
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
const { register, formState } = methods;
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorMessages: { [key: string]: string } = {
@ -98,15 +101,35 @@ export default function Login({
);
const TwoFactorFooter = (
<Button
onClick={() => {
setTwoFactorRequired(false);
methods.setValue("totpCode", "");
}}
StartIcon={ArrowLeft}
color="minimal">
{t("go_back")}
</Button>
<>
<Button
onClick={() => {
if (twoFactorLostAccess) {
setTwoFactorLostAccess(false);
methods.setValue("backupCode", "");
} else {
setTwoFactorRequired(false);
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 = (
@ -130,8 +153,9 @@ export default function Login({
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
// we're logged in! let's do a hard refresh to the desired url
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.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code"));
else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes"));
// fallback if error not found
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
};
@ -194,7 +218,7 @@ export default function Login({
</div>
</div>
{twoFactorRequired && <TwoFactor center />}
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
{errorMessage && <Alert severity="error" title={errorMessage} />}
<Button

View File

@ -17,6 +17,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
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 { ssrInit } from "../server/lib/ssr";
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(),
const signupSchema = apiSignupSchema.extend({
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 flags = useFlagMap();
const methods = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(signupSchema),
defaultValues: prepopulateFormValues,
});

View File

@ -9,6 +9,8 @@ import { test } from "./lib/fixtures";
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 doubling of tests failing on logout & logout, we can group them.
test.describe("2FA Tests", async () => {
@ -45,6 +47,8 @@ test.describe("2FA Tests", async () => {
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 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
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();
return user;

View File

@ -225,6 +225,7 @@
"create_account": "Konto erstellen",
"confirm_password": "Passwort bestätigen",
"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.",
"email_change": "Melden Sie sich mit Ihrer neuen E-Mail-Adresse und Ihrem Passwort wieder an.",
"create_your_account": "Erstellen Sie Ihr Konto",
@ -255,6 +256,7 @@
"available_apps": "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>",
"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.",
"finish": "Fertig",
"organization_general_description": "Einstellungen für die Sprache und Zeitzone Ihres Teams verwalten",
@ -559,6 +561,7 @@
"leave": "Verlassen",
"profile": "Profil",
"my_team_url": "Meine Team-URL",
"my_teams": "Meine Teams",
"team_name": "Teamname",
"your_team_name": "Ihr Teamname",
"team_updated_successfully": "Team erfolgreich aktualisiert",
@ -1974,6 +1977,8 @@
"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.",
"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:",
"org_admin_other_teams": "Weitere Teams",
"no_other_teams_found": "Keine weiteren Teams gefunden",

View File

@ -2010,6 +2010,13 @@
"member_removed": "Member removed",
"my_availability": "My 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",
"hi_admin": "Hi Administrator",
"admin_org_notification_email_title": "An organization requires DNS setup",
@ -2026,5 +2033,6 @@
"value": "Value",
"your_organization_updated_sucessfully": "Your organization updated successfully",
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -1988,6 +1988,8 @@
"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_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.",
"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>",
@ -2001,16 +2003,28 @@
"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_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",
"verify_team_tooltip": "Vérifiez votre équipe pour activer l'envoi de messages aux participants",
"member_removed": "Membre supprimé",
"my_availability": "Mes disponibilités",
"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",
"hi_admin": "Bonjour administrateur",
"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_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",
"org_has_been_processed": "L'organisation a été traitée",
"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.",
"unverified": "Non vérifié",
@ -2018,5 +2032,7 @@
"mark_dns_configured": "Marquer comme DNS configuré",
"value": "Valeur",
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -1944,5 +1944,6 @@
"insights_user_filter": "用户:{{userName}}",
"insights_subtitle": "查看您活动的预约 insights",
"custom_plan": "自定义计划",
"include_calendar_event": "包括日历活动",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -1854,7 +1854,7 @@
"payment_app_commission": "需付款 ({{paymentFeePercentage}}% + {{fee, currency}} 每筆交易佣金)",
"email_invite_team": "已邀請 {{email}}",
"email_invite_team_bulk": "已邀請 {{userCount}} 位使用者",
"error_collecting_card": "卡片發生錯誤",
"error_collecting_card": "收集卡片發生錯誤",
"image_size_limit_exceed": "上傳的圖片大小不得超過 5MB 限制",
"unauthorized_workflow_error_message": "{{errorCode}}:您未獲得授權,無法啟用或停用此工作流程",
"inline_embed": "內嵌式嵌入",

View File

@ -5,15 +5,21 @@ export type GetAppData = (key: string) => unknown;
export type SetAppData = (key: string, value: unknown) => void;
type LockedIcon = JSX.Element | false | 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>,
TValue extends z.infer<TAppData>[TKey]
>(
@ -21,7 +27,7 @@ export type SetAppDataGeneric<TAppData extends ZodType> = <
value: TValue
) => 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
) => z.infer<TAppData>[TKey];
@ -29,7 +35,12 @@ export const useAppContextWithSchema = <TAppData extends ZodType>() => {
type GetAppData = GetAppDataGeneric<TAppData>;
type SetAppData = SetAppDataGeneric<TAppData>;
// 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;
};
export default EventTypeAppContext;

View File

@ -1,12 +1,11 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import { classNames } from "@calcom/lib";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Switch, Badge, Avatar } from "@calcom/ui";
import type { SetAppDataGeneric } from "../EventTypeAppContext";
import type { eventTypeAppCardZod } from "../eventTypeAppCardZod";
import type { CredentialOwner } from "../types";
import OmniInstallAppButton from "./OmniInstallAppButton";
@ -16,24 +15,20 @@ export default function AppCard({
switchOnClick,
switchChecked,
children,
setAppData,
returnTo,
teamId,
disableSwitch,
LockedIcon,
}: {
app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner };
description?: React.ReactNode;
switchChecked?: boolean;
switchOnClick?: (e: boolean) => void;
children?: React.ReactNode;
setAppData: SetAppDataGeneric<typeof eventTypeAppCardZod>;
returnTo?: string;
teamId?: number;
disableSwitch?: boolean;
LockedIcon?: React.ReactNode;
}) {
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const { setAppData, LockedIcon, disabled } = useAppContextWithSchema();
return (
<div
@ -92,7 +87,7 @@ export default function AppCard({
{app?.isInstalled || app.credentialOwner ? (
<div className="ml-auto flex items-center">
<Switch
disabled={!app.enabled || disableSwitch}
disabled={!app.enabled || disabled}
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);

View File

@ -19,7 +19,7 @@ export const EventTypeAppCard = (props: {
const { app, getAppData, setAppData, LockedIcon, disabled } = props;
return (
<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
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
componentMap={EventTypeAddonMap}

View File

@ -5,7 +5,7 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import type { EventTypeAppCardApp } from "../types";
function useIsAppEnabled(app: EventTypeAppCardApp) {
const [getAppData, setAppData] = useAppContextWithSchema();
const { getAppData, setAppData } = useAppContextWithSchema();
const [enabled, setEnabled] = useState(() => {
if (!app.credentialOwner) {
return getAppData("enabled");

View File

@ -9,7 +9,7 @@ import { Select } from "@calcom/ui";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData } = useAppContextWithSchema<typeof appDataSchema>();
const [enabled, setEnabled] = useState(getAppData("enabled"));
const [projects, setProjects] = useState();
const [selectedProject, setSelectedProject] = useState<undefined | { label: string; value: string }>();
@ -32,7 +32,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
return (
<AppCard
setAppData={setAppData}
app={app}
switchOnClick={(e) => {
if (!e) {

View File

@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
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 { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
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 { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -8,7 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { appDataSchema } from "../zod";
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 { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app);
@ -16,11 +16,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
return (
<AppCard
setAppData={setAppData}
app={app}
description={t("confirmation_page_gif")}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
setShowGifSelection(e);
}}

View File

@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
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 { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
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 { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={updateEnabled}
switchChecked={enabled}
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 { asPath } = useRouter();
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
const [selectedCurrency, setSelectedCurrency] = useState(
@ -38,7 +38,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
return (
<AppCard
returnTo={WEBAPP_URL + asPath}
setAppData={setAppData}
app={app}
switchChecked={requirePayment}
switchOnClick={(enabled) => {

View File

@ -7,17 +7,14 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
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 trackingId = getAppData("trackingId");
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -11,7 +11,7 @@ import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
const { t } = useLocale();
const [_getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { disabled } = useAppContextWithSchema<typeof appDataSchema>();
const [additionalParameters, setAdditionalParameters] = useState("");
const { enabled, updateEnabled } = useIsAppEnabled(app);
@ -37,10 +37,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
updateEnabled(e);
}}

View File

@ -15,7 +15,7 @@ type Option = { value: string; label: string };
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const pathname = usePathname();
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
const paymentOption = getAppData("paymentOption");
@ -38,10 +38,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
return (
<AppCard
returnTo={WEBAPP_URL + pathname + "?tabName=apps"}
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchChecked={requirePayment}
switchOnClick={(enabled) => {
setRequirePayment(enabled);

View File

@ -8,13 +8,12 @@ import { TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const [enabled, setEnabled] = useState(getAppData("enabled"));
return (
<AppCard
setAppData={setAppData}
app={app}
switchOnClick={(e) => {
if (!e) {

View File

@ -7,16 +7,13 @@ import { Sunrise, Sunset } from "@calcom/ui/components/icon";
import type { appDataSchema } from "../zod";
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 { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
if (!e) {
updateEnabled(false);

View File

@ -8,6 +8,8 @@ export enum ErrorCode {
TwoFactorSetupRequired = "two-factor-setup-required",
SecondFactorRequired = "second-factor-required",
IncorrectTwoFactorCode = "incorrect-two-factor-code",
IncorrectBackupCode = "incorrect-backup-code",
MissingBackupCodes = "missing-backup-codes",
IncorrectEmailVerificationCode = "incorrect_email_verification_code",
InternalServerError = "internal-server-error",
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
min = false, // Eight characters, or fifteen in strict mode.
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;
for (let i = 0; i < password.length; i++) {
if (!isNaN(parseInt(password[i]))) num = true;
else {
if (password[i] === password[i].toUpperCase()) cap = true;
if (password[i] === password[i].toLowerCase()) low = true;
}
}
if (password.match(/\d/)) num = true;
if (password.match(/[a-z]/)) low = true;
if (password.match(/[A-Z]/)) cap = 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 { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
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 { isENVDev } from "@calcom/lib/env";
import { randomString } from "@calcom/lib/random";
@ -62,6 +62,7 @@ const providers: Provider[] = [
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
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) {
if (!credentials) {
@ -85,6 +86,7 @@ const providers: Provider[] = [
organizationId: true,
twoFactorEnabled: true,
twoFactorSecret: true,
backupCodes: true,
locale: true,
organization: {
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) {
throw new Error(ErrorCode.SecondFactorRequired);
}

View File

@ -24,13 +24,8 @@ export const DatePicker = () => {
return (
<DatePickerComponent
isLoading={schedule.isLoading}
onChange={(date: Dayjs) => {
setSelectedDate(date.format("YYYY-MM-DD"));
}}
onMonthChange={(date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
setSelectedDate(date.format("YYYY-MM-DD"));
}}
onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)}
onMonthChange={(date) => setMonth(date.format("YYYY-MM"))}
includedDates={nonEmptyScheduleDays}
locale={i18n.language}
browsingDate={month ? dayjs(month) : undefined}

View File

@ -160,7 +160,8 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
updateQueryParam("date", selectedDate ?? "");
// 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") });
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
@ -193,7 +194,6 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
setMonth: (month: string | null) => {
set({ month, selectedTimeslot: null });
updateQueryParam("month", month ?? "");
get().setSelectedDate(null);
},
isTeamEvent: false,
seatedEventData: {

View File

@ -9,9 +9,10 @@ import type { PublicEvent } from "../../types";
export const EventDuration = ({ event }: { event: PublicEvent }) => {
const { t } = useLocale();
const [selectedDuration, setSelectedDuration] = useBookerStore((state) => [
const [selectedDuration, setSelectedDuration, state] = useBookerStore((state) => [
state.selectedDuration,
state.setSelectedDuration,
state.state,
]);
const isDynamicEvent = "isDynamic" in event && event.isDynamic;
@ -30,14 +31,16 @@ export const EventDuration = ({ event }: { event: PublicEvent }) => {
return (
<div className="flex flex-wrap gap-2">
{durations.map((duration) => (
<Badge
variant="gray"
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
size="md"
key={duration}
onClick={() => setSelectedDuration(duration)}>{`${duration} ${t("minute_timeUnit")}`}</Badge>
))}
{durations
.filter((dur) => state !== "booking" || dur === selectedDuration)
.map((duration) => (
<Badge
variant="gray"
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
size="md"
key={duration}
onClick={() => setSelectedDuration(duration)}>{`${duration} ${t("minute_timeUnit")}`}</Badge>
))}
</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 */
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Fires whenever a selected date is changed. */
onChange: (date: Dayjs) => void;
onChange: (date: Dayjs | null) => void;
/** Fires when the month is changed. */
onMonthChange?: (date: Dayjs) => void;
/** 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"] */
excludedDates?: string[];
/** defaults to all, which dates are bookable (inverse of excludedDates) */
includedDates?: string[];
includedDates?: string[] | null;
/** allows adding classes to the container */
className?: string;
/** 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
const weekdayOfFirst = browsingDate.date(1).day();
const currentDate = minDate.utcOffset(browsingDate.utcOffset());
const availableDates = (includedDates: string[] | undefined) => {
const availableDates = (includedDates: string[] | undefined | null) => {
const dates = [];
const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate));
for (
@ -148,6 +148,21 @@ const Days = ({
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 isActive = (day: dayjs.Dayjs) => {
@ -177,7 +192,7 @@ const Days = ({
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%]">
{day === null ? (
<div key={`e-${idx}`} />
@ -194,10 +209,7 @@ const Days = ({
onClick={() => {
props.onChange(day);
}}
disabled={
(includedDates && !includedDates.includes(yyyymmdd(day))) ||
excludedDates.includes(yyyymmdd(day))
}
disabled={disabled}
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;

View File

@ -1,10 +1,16 @@
/* Schedule any workflow reminder that falls within 72 hours for email */
import type { Prisma } from "@prisma/client";
import client from "@sendgrid/client";
import sgMail from "@sendgrid/mail";
import { createEvent } from "ics";
import type { DateArray } from "ics";
import type { NextApiRequest, NextApiResponse } from "next";
import { RRule } from "rrule";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { parseRecurringEvent } from "@calcom/lib";
import { defaultHandler } from "@calcom/lib/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
@ -20,6 +26,65 @@ const senderEmail = process.env.SENDGRID_EMAIL as string;
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) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
@ -258,6 +323,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
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,
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
numberVerificationPending: false,
includeCalendarEvent: false,
};
steps?.push(step);
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 || ""}
</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 && (
<div className="mt-3 ">
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>

View File

@ -1,8 +1,14 @@
import client from "@sendgrid/client";
import type { MailData } from "@sendgrid/helpers/classes/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 { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { TimeUnit } from "@calcom/prisma/enums";
@ -42,6 +48,47 @@ async function getBatchId() {
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<
WorkflowActions,
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
@ -62,7 +109,8 @@ export const scheduleEmailReminder = async (
template: WorkflowTemplates,
sender: string,
hideBranding?: boolean,
seatReferenceUid?: string
seatReferenceUid?: string,
includeCalendarEvent?: boolean
) => {
if (action === WorkflowActions.EMAIL_ADDRESS) return;
const { startTime, endTime } = evt;
@ -186,11 +234,19 @@ export const scheduleEmailReminder = async (
const batchId = await getBatchId();
function sendEmail(data: Partial<MailData>) {
function sendEmail(data: Partial<MailData>, triggerEvent?: WorkflowTriggerEvents) {
if (!process.env.SENDGRID_API_KEY) {
console.info("No sendgrid API key provided, skipping email");
return Promise.resolve();
}
const status: ParticipationStatus =
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT
? "COMPLETED"
: triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED
? "DECLINED"
: "ACCEPTED";
return sgMail.send({
to: data.to,
from: {
@ -206,6 +262,17 @@ export const scheduleEmailReminder = async (
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,
});
}
@ -218,7 +285,7 @@ export const scheduleEmailReminder = async (
try {
if (!sendTo) throw new Error("No email addresses provided");
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?
await Promise.all(promises);
} catch (error) {
@ -237,10 +304,13 @@ export const scheduleEmailReminder = async (
) {
try {
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
await sendEmail({
to: sendTo,
sendAt: scheduledDate.unix(),
});
await sendEmail(
{
to: sendTo,
sendAt: scheduledDate.unix(),
},
triggerEvent
);
await prisma.workflowReminder.create({
data: {
bookingUid: uid,

View File

@ -106,7 +106,8 @@ const processWorkflowStep = async (
step.template,
step.sender || SENDER_NAME,
hideBranding,
seatReferenceUid
seatReferenceUid,
step.includeCalendarEvent
);
} else if (isWhatsappAction(step.action)) {
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 { WorkflowTriggerEvents } from "@calcom/prisma/enums";
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 * as twilio from "./smsProviders/twilioProvider";
@ -44,6 +44,7 @@ export type BookingInfo = {
};
eventType: {
slug?: string;
recurringEvent?: RecurringEvent | null;
};
startTime: string;
endTime: string;

View File

@ -64,6 +64,7 @@ const formSchema = z.object({
emailSubject: z.string().nullable(),
template: z.nativeEnum(WorkflowTemplates),
numberRequired: z.boolean().nullable(),
includeCalendarEvent: z.boolean().nullable(),
sendTo: z
.string()
.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>
<DatePicker
isLoading={schedule.isLoading}
onChange={(date: Dayjs) => {
setSelectedDate(date.format("YYYY-MM-DD"));
}}
onMonthChange={(date: Dayjs) => {
onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)}
onMonthChange={(date) => {
setMonth(date.format("YYYY-MM"));
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") {
nameString = name;
} else {
nameString = name.firstName + " " + name.lastName;
nameString = name.firstName;
if (name.lastName) {
nameString = nameString + " " + name.lastName;
}
}
return nameString;
};

View File

@ -51,7 +51,11 @@ const DateOverrideForm = ({
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 (selectedDates.some((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: [],
avatar: "",
away: false,
backupCodes: null,
bio: null,
brandColor: "#292929",
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)
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
backupCodes String?
identityProvider IdentityProvider @default(CAL)
identityProviderId String?
availability Availability[]
@ -738,6 +739,7 @@ model WorkflowStep {
numberRequired Boolean?
sender String?
numberVerificationPending Boolean @default(true)
includeCalendarEvent Boolean @default(false)
@@index([workflowId])
}

View File

@ -14,6 +14,7 @@ import type {
import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
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 { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/schema";
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
@ -602,6 +603,28 @@ export const emailSchemaRefinement = (value: string) => {
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({
email: z.string().email(),
code: z.string(),
@ -610,3 +633,4 @@ export const ZVerifyCodeInputSchema = z.object({
export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;
export const coerceToDate = z.coerce.date();

View File

@ -272,7 +272,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) =>
...group.metadata,
teamId: group.teamId,
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,
}),
numberVerificationPending: false,
includeCalendarEvent: newStep.includeCalendarEvent,
},
});
//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(),
sender: z.string().optional().nullable(),
senderName: z.string().optional().nullable(),
includeCalendarEvent: z.boolean(),
})
.array(),
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";
export function HintsOrErrors<T extends FieldValues = FieldValues>(props: {
type hintsOrErrorsProps = {
hintErrors?: string[];
fieldName: string;
t: (key: string) => string;
}) {
};
export function HintsOrErrors<T extends FieldValues = FieldValues>({
hintErrors,
fieldName,
t,
}: hintsOrErrorsProps) {
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 (!methods) return null;
const { formState } = methods;
const { hintErrors, fieldName, t } = props;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const fieldErrors: FieldErrors<T> | undefined = formState.errors[fieldName];

View File

@ -44,7 +44,11 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
addOnFilled={false}
addOnSuffix={
<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 ? (
<EyeOff className="h-4 stroke-[2.5px]" />
) : (

View File

@ -43,13 +43,23 @@ function WizardForm<T extends DefaultStep>(props: {
}, [currentStep]);
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="px-6 py-5 sm:px-14">
<h1 className="font-cal text-emphasis text-2xl">{currentStep.title}</h1>
<p className="text-subtle text-sm">{currentStep.description}</p>
<h1 className="font-cal text-emphasis text-2xl" data-testid="step-title">
{currentStep.title}
</h1>
<p className="text-subtle text-sm" data-testid="step-description">
{currentStep.description}
</p>
{!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>

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