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>
pull/1362/head
Omar López 2021-12-20 17:59:06 -07:00 committed by GitHub
parent 0dd72888a9
commit 7bc7b241ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 113 additions and 72 deletions

View File

@ -29,7 +29,7 @@ import slugify from "@lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
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 { Button } from "@components/ui/Button";
import PhoneInput from "@components/ui/form/PhoneInput";
@ -98,9 +98,10 @@ const BookingPage = (props: BookingPageProps) => {
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.
const locations: { type: LocationType }[] = useMemo(
() => (props.eventType.locations as { type: LocationType }[]) || [],
const locations: Location[] = useMemo(
() => (props.eventType.locations as Location[]) || [],
[props.eventType.locations]
);
@ -171,14 +172,14 @@ const BookingPage = (props: BookingPageProps) => {
const { locationType } = booking;
switch (locationType) {
case LocationType.Phone: {
return booking.phone;
return booking.phone || "";
}
case LocationType.InPerson: {
return locationInfo(locationType).address;
return locationInfo(locationType)?.address || "";
}
// Catches all other location types, such as Google Meet, Zoom etc.
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">
<AvatarGroup
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
.filter((user) => user.name !== props.profile.name)
.map((user) => ({
image: user.avatar,
title: user.name,
image: user.avatar || "",
alt: user.name || "",
}))
)}
/>
@ -283,8 +284,8 @@ const BookingPage = (props: BookingPageProps) => {
)}
<p className="mb-4 text-green-500">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{date &&
parseZone(date).format(timeFormat) +
{(date && parseZone(date)?.format(timeFormat)) ||
"No date" +
", " +
dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" })}
</p>
@ -315,12 +316,8 @@ const BookingPage = (props: BookingPageProps) => {
{t("email_address")}
</label>
<div className="mt-1">
<input
<EmailInput
{...bookingForm.register("email")}
type="email"
name="email"
id="email"
inputMode="email"
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"
placeholder="you@example.com"

View File

@ -91,8 +91,25 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
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) {
return <InputField type="email" inputMode="email" ref={ref} {...props} />;
return <EmailInput ref={ref} {...props} />;
});
type FormProps<T> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<

View File

@ -7,6 +7,7 @@ import { useLocale } from "@lib/hooks/useLocale";
import { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
import { EmailInput } from "@components/form/fields";
import Button from "@components/ui/Button";
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">
{t("email_or_username")}
</label>
<input
<EmailInput
type="text"
name="inviteUser"
id="inviteUser"

View File

@ -80,9 +80,16 @@ export default function MemberListItem(props: Props) {
</div>
</div>
<div className="flex">
<Tooltip content={t("View user availability")}>
<Tooltip content={t("team_view_user_availability")}>
<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"
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" />

View File

@ -29,7 +29,7 @@ export default NextAuth({
async authorize(credentials) {
const user = await prisma.user.findUnique({
where: {
email: credentials.email,
email: credentials.email.toLowerCase(),
},
});

View File

@ -16,6 +16,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
/**
* TODO:
* We should add and extra check for non-paying customers in Stripe so we can
* downgrade them here.
*/
await prisma.user.updateMany({
data: {
plan: "FREE",

View File

@ -1,3 +1,4 @@
import { MembershipRole } from "@prisma/client";
import { randomBytes } from "crypto";
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 { TeamInvite } from "@lib/emails/templates/team-invite-email";
import prisma from "@lib/prisma";
import slugify from "@lib/slugify";
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" });
}
const session = await getSession({ req: req });
const session = await getSession({ req });
if (!session) {
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" });
}
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({
where: {
OR: [{ username: req.body.usernameOrEmail }, { email: req.body.usernameOrEmail }],
OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
},
});
if (!invitee) {
// liberal email match
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
if (!isEmail(req.body.usernameOrEmail)) {
if (!isEmail(usernameOrEmail)) {
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
await prisma.user
.create({
data: {
email: req.body.usernameOrEmail,
},
})
.then((invitee) =>
prisma.membership.create({
data: {
teamId: parseInt(req.query.team as string),
userId: invitee.id,
role: req.body.role,
await prisma.user.create({
data: {
email: usernameOrEmail,
teams: {
create: {
team: {
connect: {
id: parseInt(req.query.team as string),
},
},
role,
},
})
);
},
},
});
const token: string = randomBytes(32).toString("hex");
await prisma.verificationRequest.create({
data: {
identifier: req.body.usernameOrEmail,
identifier: usernameOrEmail,
token,
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 = {
language: t,
from: session.user.name,
to: req.body.usernameOrEmail,
to: usernameOrEmail,
teamName: team.name,
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: {
teamId: parseInt(req.query.team as string),
userId: invitee.id,
role: req.body.role,
role,
},
});
} catch (err: any) {
@ -109,11 +120,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
// 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 = {
language: t,
from: session.user.name,
to: req.body.usernameOrEmail,
to: usernameOrEmail,
teamName: team.name,
joinLink: BASE_URL + "/settings/teams",
};

View File

@ -6,7 +6,7 @@ import React, { SyntheticEvent } from "react";
import { getSession } from "@lib/auth";
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 Button from "@components/ui/Button";
@ -95,14 +95,11 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
<form className="space-y-6" onSubmit={handleSubmit} action="#">
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<TextField
<EmailField
onChange={handleChange}
id="email"
name="email"
label={t("email_address")}
type="email"
inputMode="email"
autoComplete="email"
placeholder="john.doe@example.com"
required
/>

View File

@ -10,6 +10,7 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
import AddToHomescreen from "@components/AddToHomescreen";
import Loader from "@components/Loader";
import { EmailInput } from "@components/form/fields";
import { HeadSeo } from "@components/seo/head-seo";
import { ssrInit } from "@server/lib/ssr";
@ -75,24 +76,24 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
}
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")} />
{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 />
</div>
)}
<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" />
<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")}
</h2>
</div>
<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}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken || undefined} hidden />
<div>
@ -100,16 +101,13 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
{t("email_address")}
</label>
<div className="mt-1">
<input
<EmailInput
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="email"
required
value={email}
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>
@ -123,7 +121,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
</div>
<div className="w-1/2 text-right">
<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")}
</a>
</Link>
@ -138,7 +136,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
required
value={password}
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>
@ -158,7 +156,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
inputMode="numeric"
value={code}
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>
@ -168,7 +166,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP
<button
type="submit"
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")}
</button>
</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>}
</form>
</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 */}
<a href="https://cal.com/signup" className="font-medium text-neutral-900">
{t("create_an_account")}

View File

@ -19,6 +19,7 @@ import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import CustomBranding from "@components/CustomBranding";
import { EmailInput } from "@components/form/fields";
import { HeadSeo } from "@components/seo/head-seo";
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);
}}
className="flex mt-4">
<input
type="email"
<EmailInput
name="email"
id="email"
inputMode="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"
placeholder="rick.astley@cal.com"

View File

@ -39,6 +39,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
periodEndDate: true,
periodCountCalendarDays: true,
disableGuests: true,
price: true,
currency: true,
team: {
select: {
slug: true,
@ -91,6 +93,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: "team/" + eventTypeObject.slug,
image: eventTypeObject.team?.logo || null,
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,
booking,

View File

@ -59,6 +59,7 @@ test.describe("integrations", () => {
body.createdAt = dynamic;
body.payload.startTime = dynamic;
body.payload.endTime = dynamic;
body.payload.location = dynamic;
for (const attendee of body.payload.attendees) {
attendee.timeZone = dynamic;
}

View File

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

View File

@ -0,0 +1,2 @@
-- UpdateTable
UPDATE users SET email=LOWER(email);

View File

@ -563,5 +563,7 @@
"calendar": "Calendar",
"not_installed": "Not installed",
"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"
}