Skip to content

Commit

Permalink
Zomars/cal 794 normalize emails in db (calcom#1361)
Browse files Browse the repository at this point in the history
* 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)=([email protected]) 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)=([email protected]) 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 <[email protected]>
  • Loading branch information
zomars and emrysal authored Dec 21, 2021
1 parent 0dd7288 commit 7bc7b24
Show file tree
Hide file tree
Showing 15 changed files with 113 additions and 72 deletions.
29 changes: 13 additions & 16 deletions components/booking/pages/BookingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]
);

Expand Down Expand Up @@ -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 || "";
}
};

Expand Down Expand Up @@ -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 || "",
}))
)}
/>
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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="[email protected]"
Expand Down
19 changes: 18 additions & 1 deletion components/form/fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
3 changes: 2 additions & 1 deletion components/team/MemberInvitationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 9 additions & 2 deletions components/team/MemberListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
2 changes: 1 addition & 1 deletion pages/api/auth/[...nextauth].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default NextAuth({
async authorize(credentials) {
const user = await prisma.user.findUnique({
where: {
email: credentials.email,
email: credentials.email.toLowerCase(),
},
});

Expand Down
6 changes: 6 additions & 0 deletions pages/api/cron/downgradeUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 37 additions & 26 deletions pages/api/teams/[team]/invite.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MembershipRole } from "@prisma/client";
import { randomBytes } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";

Expand All @@ -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";

Expand All @@ -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" });
}
Expand All @@ -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
},
Expand All @@ -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"}`,
};
Expand All @@ -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) {
Expand All @@ -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",
};
Expand Down
7 changes: 2 additions & 5 deletions pages/auth/forgot-password/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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="[email protected]"
required
/>
Expand Down
Loading

0 comments on commit 7bc7b24

Please sign in to comment.