Zomars/cal 794 normalize emails in db (#1361)
* Email input UX improvements * Makes email queries case insensitive * Lowercases all emails * Type fixes * Re adds lowercase email to login * Removes citext dependency * Updates schema * Migration fixes * Added failsafes to team invites * Team invite improvements * Deleting the index, lowercasing ``` calendso=> UPDATE users SET email=LOWER(email); ERROR: duplicate key value violates unique constraint "users.email_unique" DETAIL: Key (email)=(free@example.com) already exists. ``` vs. ``` calendso=> CREATE UNIQUE INDEX "users.email_unique" ON "users" (email); ERROR: could not create unique index "users.email_unique" DETAIL: Key (email)=(Free@example.com) is duplicated. ``` I think it'll be easier to rectify for users if they try to run the migrations if the index stays in place. Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
parent
0dd72888a9
commit
7bc7b241ac
15 changed files with 113 additions and 72 deletions
|
@ -29,7 +29,7 @@ import slugify from "@lib/slugify";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||||
|
|
||||||
import CustomBranding from "@components/CustomBranding";
|
import CustomBranding from "@components/CustomBranding";
|
||||||
import { Form } from "@components/form/fields";
|
import { EmailInput, Form } from "@components/form/fields";
|
||||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
import { Button } from "@components/ui/Button";
|
import { Button } from "@components/ui/Button";
|
||||||
import PhoneInput from "@components/ui/form/PhoneInput";
|
import PhoneInput from "@components/ui/form/PhoneInput";
|
||||||
|
@ -98,9 +98,10 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
|
|
||||||
const [guestToggle, setGuestToggle] = useState(false);
|
const [guestToggle, setGuestToggle] = useState(false);
|
||||||
|
|
||||||
|
type Location = { type: LocationType; address?: string };
|
||||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||||
const locations: { type: LocationType }[] = useMemo(
|
const locations: Location[] = useMemo(
|
||||||
() => (props.eventType.locations as { type: LocationType }[]) || [],
|
() => (props.eventType.locations as Location[]) || [],
|
||||||
[props.eventType.locations]
|
[props.eventType.locations]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -171,14 +172,14 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
const { locationType } = booking;
|
const { locationType } = booking;
|
||||||
switch (locationType) {
|
switch (locationType) {
|
||||||
case LocationType.Phone: {
|
case LocationType.Phone: {
|
||||||
return booking.phone;
|
return booking.phone || "";
|
||||||
}
|
}
|
||||||
case LocationType.InPerson: {
|
case LocationType.InPerson: {
|
||||||
return locationInfo(locationType).address;
|
return locationInfo(locationType)?.address || "";
|
||||||
}
|
}
|
||||||
// Catches all other location types, such as Google Meet, Zoom etc.
|
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||||
default:
|
default:
|
||||||
return selectedLocation;
|
return selectedLocation || "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -244,12 +245,12 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
|
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
size={14}
|
size={14}
|
||||||
items={[{ image: props.profile.image, alt: props.profile.name }].concat(
|
items={[{ image: props.profile.image || "", alt: props.profile.name || "" }].concat(
|
||||||
props.eventType.users
|
props.eventType.users
|
||||||
.filter((user) => user.name !== props.profile.name)
|
.filter((user) => user.name !== props.profile.name)
|
||||||
.map((user) => ({
|
.map((user) => ({
|
||||||
image: user.avatar,
|
image: user.avatar || "",
|
||||||
title: user.name,
|
alt: user.name || "",
|
||||||
}))
|
}))
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -283,8 +284,8 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
)}
|
)}
|
||||||
<p className="mb-4 text-green-500">
|
<p className="mb-4 text-green-500">
|
||||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
{date &&
|
{(date && parseZone(date)?.format(timeFormat)) ||
|
||||||
parseZone(date).format(timeFormat) +
|
"No date" +
|
||||||
", " +
|
", " +
|
||||||
dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" })}
|
dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" })}
|
||||||
</p>
|
</p>
|
||||||
|
@ -315,12 +316,8 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
{t("email_address")}
|
{t("email_address")}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<input
|
<EmailInput
|
||||||
{...bookingForm.register("email")}
|
{...bookingForm.register("email")}
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
id="email"
|
|
||||||
inputMode="email"
|
|
||||||
required
|
required
|
||||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
|
|
|
@ -91,8 +91,25 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
|
||||||
return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />;
|
return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const EmailInput = forwardRef<HTMLInputElement, JSX.IntrinsicElements["input"]>(function EmailInput(
|
||||||
|
props,
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
autoCorrect="off"
|
||||||
|
inputMode="email"
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
|
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
|
||||||
return <InputField type="email" inputMode="email" ref={ref} {...props} />;
|
return <EmailInput ref={ref} {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormProps<T> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
|
type FormProps<T> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { TeamWithMembers } from "@lib/queries/teams";
|
import { TeamWithMembers } from "@lib/queries/teams";
|
||||||
import { trpc } from "@lib/trpc";
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
|
import { EmailInput } from "@components/form/fields";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
||||||
|
@ -80,7 +81,7 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
||||||
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
|
||||||
{t("email_or_username")}
|
{t("email_or_username")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<EmailInput
|
||||||
type="text"
|
type="text"
|
||||||
name="inviteUser"
|
name="inviteUser"
|
||||||
id="inviteUser"
|
id="inviteUser"
|
||||||
|
|
|
@ -80,9 +80,16 @@ export default function MemberListItem(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Tooltip content={t("View user availability")}>
|
<Tooltip content={t("team_view_user_availability")}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowTeamAvailabilityModal(true)}
|
// Disabled buttons don't trigger Tooltips
|
||||||
|
title={
|
||||||
|
props.member.accepted
|
||||||
|
? t("team_view_user_availability")
|
||||||
|
: t("team_view_user_availability_disabled")
|
||||||
|
}
|
||||||
|
disabled={!props.member.accepted}
|
||||||
|
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
||||||
color="minimal"
|
color="minimal"
|
||||||
className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
|
className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
|
||||||
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
|
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||||
|
|
|
@ -29,7 +29,7 @@ export default NextAuth({
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
email: credentials.email,
|
email: credentials.email.toLowerCase(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO:
|
||||||
|
* We should add and extra check for non-paying customers in Stripe so we can
|
||||||
|
* downgrade them here.
|
||||||
|
*/
|
||||||
|
|
||||||
await prisma.user.updateMany({
|
await prisma.user.updateMany({
|
||||||
data: {
|
data: {
|
||||||
plan: "FREE",
|
plan: "FREE",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { MembershipRole } from "@prisma/client";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
@ -6,6 +7,7 @@ import { BASE_URL } from "@lib/config/constants";
|
||||||
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
|
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
|
||||||
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import slugify from "@lib/slugify";
|
||||||
|
|
||||||
import { getTranslation } from "@server/lib/i18n";
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
|
@ -16,7 +18,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(400).json({ message: "Bad request" });
|
return res.status(400).json({ message: "Bad request" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req });
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return res.status(401).json({ message: "Not authenticated" });
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
}
|
}
|
||||||
|
@ -31,43 +33,52 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(404).json({ message: "Invalid team" });
|
return res.status(404).json({ message: "Invalid team" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reqBody = req.body as {
|
||||||
|
usernameOrEmail: string;
|
||||||
|
role: MembershipRole;
|
||||||
|
sendEmailInvitation: boolean;
|
||||||
|
};
|
||||||
|
const { role, sendEmailInvitation } = reqBody;
|
||||||
|
// liberal email match
|
||||||
|
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
||||||
|
const usernameOrEmail = isEmail(reqBody.usernameOrEmail)
|
||||||
|
? reqBody.usernameOrEmail.toLowerCase()
|
||||||
|
: slugify(reqBody.usernameOrEmail);
|
||||||
|
|
||||||
const invitee = await prisma.user.findFirst({
|
const invitee = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ username: req.body.usernameOrEmail }, { email: req.body.usernameOrEmail }],
|
OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!invitee) {
|
if (!invitee) {
|
||||||
// liberal email match
|
if (!isEmail(usernameOrEmail)) {
|
||||||
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
|
||||||
|
|
||||||
if (!isEmail(req.body.usernameOrEmail)) {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
message: `Invite failed because there is no corresponding user for ${req.body.usernameOrEmail}`,
|
message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// valid email given, create User
|
// valid email given, create User
|
||||||
await prisma.user
|
await prisma.user.create({
|
||||||
.create({
|
data: {
|
||||||
data: {
|
email: usernameOrEmail,
|
||||||
email: req.body.usernameOrEmail,
|
teams: {
|
||||||
},
|
create: {
|
||||||
})
|
team: {
|
||||||
.then((invitee) =>
|
connect: {
|
||||||
prisma.membership.create({
|
id: parseInt(req.query.team as string),
|
||||||
data: {
|
},
|
||||||
teamId: parseInt(req.query.team as string),
|
},
|
||||||
userId: invitee.id,
|
role,
|
||||||
role: req.body.role,
|
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
);
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const token: string = randomBytes(32).toString("hex");
|
const token: string = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
await prisma.verificationRequest.create({
|
await prisma.verificationRequest.create({
|
||||||
data: {
|
data: {
|
||||||
identifier: req.body.usernameOrEmail,
|
identifier: usernameOrEmail,
|
||||||
token,
|
token,
|
||||||
expires: new Date(new Date().setHours(168)), // +1 week
|
expires: new Date(new Date().setHours(168)), // +1 week
|
||||||
},
|
},
|
||||||
|
@ -77,7 +88,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
const teamInviteEvent: TeamInvite = {
|
const teamInviteEvent: TeamInvite = {
|
||||||
language: t,
|
language: t,
|
||||||
from: session.user.name,
|
from: session.user.name,
|
||||||
to: req.body.usernameOrEmail,
|
to: usernameOrEmail,
|
||||||
teamName: team.name,
|
teamName: team.name,
|
||||||
joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=${BASE_URL + "/settings/teams"}`,
|
joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=${BASE_URL + "/settings/teams"}`,
|
||||||
};
|
};
|
||||||
|
@ -94,7 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
data: {
|
data: {
|
||||||
teamId: parseInt(req.query.team as string),
|
teamId: parseInt(req.query.team as string),
|
||||||
userId: invitee.id,
|
userId: invitee.id,
|
||||||
role: req.body.role,
|
role,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
@ -109,11 +120,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
// inform user of membership by email
|
// inform user of membership by email
|
||||||
if (req.body.sendEmailInvitation && session?.user?.name && team?.name) {
|
if (sendEmailInvitation && session?.user?.name && team?.name) {
|
||||||
const teamInviteEvent: TeamInvite = {
|
const teamInviteEvent: TeamInvite = {
|
||||||
language: t,
|
language: t,
|
||||||
from: session.user.name,
|
from: session.user.name,
|
||||||
to: req.body.usernameOrEmail,
|
to: usernameOrEmail,
|
||||||
teamName: team.name,
|
teamName: team.name,
|
||||||
joinLink: BASE_URL + "/settings/teams",
|
joinLink: BASE_URL + "/settings/teams",
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,7 +6,7 @@ import React, { SyntheticEvent } from "react";
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
|
||||||
import { TextField } from "@components/form/fields";
|
import { EmailField } from "@components/form/fields";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
|
@ -95,14 +95,11 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||||
<form className="space-y-6" onSubmit={handleSubmit} action="#">
|
<form className="space-y-6" onSubmit={handleSubmit} action="#">
|
||||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||||
|
|
||||||
<TextField
|
<EmailField
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
label={t("email_address")}
|
label={t("email_address")}
|
||||||
type="email"
|
|
||||||
inputMode="email"
|
|
||||||
autoComplete="email"
|
|
||||||
placeholder="john.doe@example.com"
|
placeholder="john.doe@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import AddToHomescreen from "@components/AddToHomescreen";
|
import AddToHomescreen from "@components/AddToHomescreen";
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
|
import { EmailInput } from "@components/form/fields";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
@ -75,24 +76,24 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
<div className="flex flex-col justify-center min-h-screen py-12 bg-neutral-50 sm:px-6 lg:px-8">
|
||||||
<HeadSeo title={t("login")} description={t("login")} />
|
<HeadSeo title={t("login")} description={t("login")} />
|
||||||
|
|
||||||
{isSubmitting && (
|
{isSubmitting && (
|
||||||
<div className="z-50 absolute w-full h-screen bg-gray-50 flex items-center">
|
<div className="absolute z-50 flex items-center w-full h-screen bg-gray-50">
|
||||||
<Loader />
|
<Loader />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<img className="h-6 mx-auto" src="/calendso-logo-white-word.svg" alt="Cal.com Logo" />
|
<img className="h-6 mx-auto" src="/calendso-logo-white-word.svg" alt="Cal.com Logo" />
|
||||||
<h2 className="font-cal mt-6 text-center text-3xl font-bold text-neutral-900">
|
<h2 className="mt-6 text-3xl font-bold text-center font-cal text-neutral-900">
|
||||||
{t("sign_in_account")}
|
{t("sign_in_account")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<div className="bg-white py-8 px-4 mx-2 rounded-sm sm:px-10 border border-neutral-200">
|
<div className="px-4 py-8 mx-2 bg-white border rounded-sm sm:px-10 border-neutral-200">
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken || undefined} hidden />
|
<input name="csrfToken" type="hidden" defaultValue={csrfToken || undefined} hidden />
|
||||||
<div>
|
<div>
|
||||||
|
@ -100,16 +101,13 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
||||||
{t("email_address")}
|
{t("email_address")}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<input
|
<EmailInput
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
|
||||||
inputMode="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||||
className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
className="block w-full px-3 py-2 placeholder-gray-400 border rounded-sm shadow-sm appearance-none border-neutral-300 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -123,7 +121,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
||||||
</div>
|
</div>
|
||||||
<div className="w-1/2 text-right">
|
<div className="w-1/2 text-right">
|
||||||
<Link href="/auth/forgot-password">
|
<Link href="/auth/forgot-password">
|
||||||
<a tabIndex={-1} className="font-medium text-primary-600 text-sm">
|
<a tabIndex={-1} className="text-sm font-medium text-primary-600">
|
||||||
{t("forgot")}
|
{t("forgot")}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -138,7 +136,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||||
className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
className="block w-full px-3 py-2 placeholder-gray-400 border rounded-sm shadow-sm appearance-none border-neutral-300 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -158,7 +156,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={code}
|
value={code}
|
||||||
onInput={(e) => setCode(e.currentTarget.value)}
|
onInput={(e) => setCode(e.currentTarget.value)}
|
||||||
className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
className="block w-full px-3 py-2 placeholder-gray-400 border rounded-sm shadow-sm appearance-none border-neutral-300 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -168,7 +166,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
className="flex justify-center w-full px-4 py-2 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||||
{t("sign_in")}
|
{t("sign_in")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -176,7 +174,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
|
||||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-neutral-600 text-center text-sm">
|
<div className="mt-4 text-sm text-center text-neutral-600">
|
||||||
{t("dont_have_an_account")} {/* replace this with your account creation flow */}
|
{t("dont_have_an_account")} {/* replace this with your account creation flow */}
|
||||||
<a href="https://cal.com/signup" className="font-medium text-neutral-900">
|
<a href="https://cal.com/signup" className="font-medium text-neutral-900">
|
||||||
{t("create_an_account")}
|
{t("create_an_account")}
|
||||||
|
|
|
@ -19,6 +19,7 @@ import prisma from "@lib/prisma";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import CustomBranding from "@components/CustomBranding";
|
import CustomBranding from "@components/CustomBranding";
|
||||||
|
import { EmailInput } from "@components/form/fields";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
|
@ -254,11 +255,9 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||||
router.push(`https://cal.com/signup?email=` + (e as any).target.email.value);
|
router.push(`https://cal.com/signup?email=` + (e as any).target.email.value);
|
||||||
}}
|
}}
|
||||||
className="flex mt-4">
|
className="flex mt-4">
|
||||||
<input
|
<EmailInput
|
||||||
type="email"
|
|
||||||
name="email"
|
name="email"
|
||||||
id="email"
|
id="email"
|
||||||
inputMode="email"
|
|
||||||
defaultValue={router.query.email}
|
defaultValue={router.query.email}
|
||||||
className="block w-full text-gray-600 border-gray-300 shadow-sm dark:bg-brand dark:text-brandcontrast dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
className="block w-full text-gray-600 border-gray-300 shadow-sm dark:bg-brand dark:text-brandcontrast dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||||
placeholder="rick.astley@cal.com"
|
placeholder="rick.astley@cal.com"
|
||||||
|
|
|
@ -39,6 +39,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
slug: true,
|
slug: true,
|
||||||
|
@ -91,6 +93,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
slug: "team/" + eventTypeObject.slug,
|
slug: "team/" + eventTypeObject.slug,
|
||||||
image: eventTypeObject.team?.logo || null,
|
image: eventTypeObject.team?.logo || null,
|
||||||
theme: null /* Teams don't have a theme, and `BookingPage` uses it */,
|
theme: null /* Teams don't have a theme, and `BookingPage` uses it */,
|
||||||
|
brandColor: null /* Teams don't have a brandColor, and `BookingPage` uses it */,
|
||||||
},
|
},
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
booking,
|
booking,
|
||||||
|
|
|
@ -59,6 +59,7 @@ test.describe("integrations", () => {
|
||||||
body.createdAt = dynamic;
|
body.createdAt = dynamic;
|
||||||
body.payload.startTime = dynamic;
|
body.payload.startTime = dynamic;
|
||||||
body.payload.endTime = dynamic;
|
body.payload.endTime = dynamic;
|
||||||
|
body.payload.location = dynamic;
|
||||||
for (const attendee of body.payload.attendees) {
|
for (const attendee of body.payload.attendees) {
|
||||||
attendee.timeZone = dynamic;
|
attendee.timeZone = dynamic;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]"}],"destinationCalendar":null,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}
|
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- UpdateTable
|
||||||
|
UPDATE users SET email=LOWER(email);
|
|
@ -563,5 +563,7 @@
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"not_installed": "Not installed",
|
"not_installed": "Not installed",
|
||||||
"error_password_mismatch": "Passwords don't match.",
|
"error_password_mismatch": "Passwords don't match.",
|
||||||
"error_required_field": "This field is required."
|
"error_required_field": "This field is required.",
|
||||||
}
|
"team_view_user_availability": "View user availability",
|
||||||
|
"team_view_user_availability_disabled": "User needs to accept invite to view availability"
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue