diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx index 31d2ba0333..8b1110d0a1 100644 --- a/components/ui/UsernameInput.tsx +++ b/components/ui/UsernameInput.tsx @@ -10,7 +10,7 @@ export const UsernameInput = React.forwardRef( (props, ref) => ( {typeof window !== "undefined" && window.location.hostname}/ - diff --git a/lib/emails/invitation.ts b/lib/emails/invitation.ts index 2ee584b44e..542880c5ef 100644 --- a/lib/emails/invitation.ts +++ b/lib/emails/invitation.ts @@ -21,7 +21,9 @@ const sendEmail = (invitation: any, { { from: `Calendso <${from}>`, to: invitation.toEmail, - subject: `${invitation.from} invited you to join ${invitation.teamName}`, + subject: ( + invitation.from ? invitation.from + ' invited you' : 'You have been invited' + ) + ` to join ${invitation.teamName}`, html: html(invitation), text: text(invitation), }, @@ -34,44 +36,51 @@ const sendEmail = (invitation: any, { }); }); -const html = (invitation: any) => ` -
-
to \n const text = (evt: any) => html(evt).replace(' ', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file diff --git a/pages/api/auth/signup.ts b/pages/api/auth/signup.ts index d949c7bcf2..9d11f38f26 100644 --- a/pages/api/auth/signup.ts +++ b/pages/api/auth/signup.ts @@ -2,55 +2,71 @@ import prisma from '../../../lib/prisma'; import { hashPassword } from "../../../lib/auth"; export default async function handler(req, res) { - if (req.method !== 'POST') { - return; - } + if (req.method !== 'POST') { + return; + } - const data = req.body; - const { username, email, password } = data; + const data = req.body; + const { username, email, password } = data; - if (!username) { - res.status(422).json({message: 'Invalid username'}); - return; - } + if (!username) { + res.status(422).json({message: 'Invalid username'}); + return; + } - if (!email || !email.includes('@')) { - res.status(422).json({message: 'Invalid email'}); - return; - } + if (!email || !email.includes('@')) { + res.status(422).json({message: 'Invalid email'}); + return; + } - if (!password || password.trim().length < 7) { - res.status(422).json({message: 'Invalid input - password should be at least 7 characters long.'}); - return; - } + if (!password || password.trim().length < 7) { + res.status(422).json({message: 'Invalid input - password should be at least 7 characters long.'}); + return; + } - const existingUser = await prisma.user.findFirst({ - where: { - OR: [ - { - username: username - }, - { - email: email - } - ] + const existingUser = await prisma.user.findFirst({ + where: { + OR: [ + { + username: username + }, + { + email: email } - }); - - if (existingUser) { - res.status(422).json({message: 'A user exists with that username or email address'}); - return; - } - - const hashedPassword = await hashPassword(password); - - const user = await prisma.user.create({ - data: { - username, - email, - password: hashedPassword + ], + AND: [ + { + emailVerified: { + not: null, + }, } - }); + ] + } + }); - res.status(201).json({message: 'Created user'}); + if (existingUser) { + let message: string = ( + existingUser.email !== email + ) ? 'Username already taken' : 'Email address is already registered'; + + return res.status(409).json({message}); + } + + const hashedPassword = await hashPassword(password); + + const user = await prisma.user.upsert({ + where: { email, }, + update: { + username, + password: hashedPassword, + emailVerified: new Date(Date.now()), + }, + create: { + username, + email, + password: hashedPassword, + } + }); + + res.status(201).json({message: 'Created user'}); } \ No newline at end of file diff --git a/pages/api/teams/[team]/invite.ts b/pages/api/teams/[team]/invite.ts index 1a8f2415cb..b595e87eda 100644 --- a/pages/api/teams/[team]/invite.ts +++ b/pages/api/teams/[team]/invite.ts @@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import prisma from '../../../../lib/prisma'; import createInvitationEmail from "../../../../lib/emails/invitation"; import {getSession} from "next-auth/client"; +import {randomBytes} from "crypto"; +import {create} from "domain"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -34,8 +36,48 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); if (!invitee) { - return res.status(400).json({ - message: `Invite failed because there is no corresponding user for ${req.body.usernameOrEmail}`}); + // liberal email match + const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str); + + if (!isEmail(req.body.usernameOrEmail)) { + return res.status(400).json({ + message: `Invite failed because there is no corresponding user for ${req.body.usernameOrEmail}` + }); + } + // valid email given, create User + const createUser = await prisma.user.create( + { + data: { + email: req.body.usernameOrEmail, + } + }) + .then( (invitee) => prisma.membership.create( + { + data: { + teamId: parseInt(req.query.team), + userId: invitee.id, + role: req.body.role, + }, + })); + + const token: string = randomBytes(32).toString("hex"); + + const createVerificationRequest = await prisma.verificationRequest.create({ + data: { + identifier: req.body.usernameOrEmail, + token, + expires: new Date((new Date()).setHours(168)) // +1 week + } + }); + + createInvitationEmail({ + toEmail: req.body.usernameOrEmail, + from: session.user.name, + teamName: team.name, + token, + }); + + return res.status(201).json({}); } // create provisional membership diff --git a/pages/auth/signup.tsx b/pages/auth/signup.tsx new file mode 100644 index 0000000000..7527e45c3f --- /dev/null +++ b/pages/auth/signup.tsx @@ -0,0 +1,141 @@ +import Head from 'next/head'; +import {useRouter} from "next/router"; +import {signIn} from 'next-auth/client' +import ErrorAlert from "../../components/ui/alerts/Error"; +import {useState} from "react"; +import {UsernameInput} from "../../components/ui/UsernameInput"; +import prisma from "../../lib/prisma"; + +export default function Signup(props) { + + const router = useRouter(); + + const [ hasErrors, setHasErrors ] = useState(false); + const [ errorMessage, setErrorMessage ] = useState(''); + + const handleErrors = async (resp) => { + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.message); + } + } + + const signUp = (e) => { + + e.preventDefault(); + + if (e.target.password.value !== e.target.passwordcheck.value) { + throw new Error("Password mismatch"); + } + + const email: string = e.target.email.value; + const password: string = e.target.password.value; + + fetch('/api/auth/signup', + { + body: JSON.stringify({ + username: e.target.username.value, + password, + email, + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST' + } + ) + .then(handleErrors) + .then( + () => signIn('Calendso', { callbackUrl: (router.query.callbackUrl || '') as string }) + ) + .catch( (err) => { + setHasErrors(true); + setErrorMessage(err.message); + }); + }; + + return ( +
+
+
+ );
+}
+
+export async function getServerSideProps(ctx) {
+ if (!ctx.query.token) {
+ return {
+ notFound: true,
+ }
+ }
+ const verificationRequest = await prisma.verificationRequest.findUnique({
+ where: {
+ token: ctx.query.token,
+ }
+ });
+
+ // for now, disable if no verificationRequestToken given or token expired
+ if ( ! verificationRequest || verificationRequest.expires < new Date() ) {
+ return {
+ notFound: true,
+ }
+ }
+
+ const existingUser = await prisma.user.findFirst({
+ where: {
+ AND: [
+ {
+ email: verificationRequest.identifier
+ },
+ {
+ emailVerified: {
+ not: null,
+ },
+ }
+ ]
+ }
+ });
+
+ if (existingUser) {
+ return { redirect: { permanent: false, destination: '/auth/login?callbackUrl=' + ctx.query.callbackUrl } };
+ }
+
+ return { props: { email: verificationRequest.identifier } };
+}
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 79847ece60..88a8187dda 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -36,6 +36,7 @@ model User {
username String?
name String?
email String? @unique
+ emailVerified DateTime?
password String?
bio String?
avatar String?
@@ -73,6 +74,16 @@ model Membership {
@@id([userId,teamId])
}
+model VerificationRequest {
+ id Int @default(autoincrement()) @id
+ identifier String
+ token String @unique
+ expires DateTime
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ @@unique([identifier, token])
+}
+
model BookingReference {
id Int @default(autoincrement()) @id
type String
+
+ + Create your account ++
+
+
+
+
+ |