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) => ` - - - -
-
- - +const html = (invitation: any) => { + let url: string = process.env.BASE_URL + "/settings/teams"; + if (invitation.token) { + url = `${process.env.BASE_URL}/auth/signup?token=${invitation.token}&callbackUrl=${url}`; + } + + return ` +
+ + +
- Hi,
-
- ${invitation.from} invited you to join the team "${invitation.teamName}" in Calendso.
-
- - +
+
+ + Hi,
+
` + + (invitation.from ? invitation.from + ' invited you' : 'You have been invited' ) + + ` to join the team "${invitation.teamName}" in Calendso.
+
+
-
- - Join team - -
-
+ + + +
+
+ + Join team + +
+

+ If you prefer not to use "${invitation.toEmail}" as your Calendso email or already have a Calendso account, please request another invitation to that email. +
+
+

- If you prefer not to use "${invitation.toEmail}" as your Calendso email or already have a Calendso account, please request another invitation to that email. - - - - - - -`; + `; +} // just strip all HTML and convert
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 ( +
+ + Sign up + + +
+

+ Create your account +

+
+
+
+
+ {hasErrors && } +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ); +} + +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