+
);
+}
\ No newline at end of file
diff --git a/components/team/MemberInvitationModal.tsx b/components/team/MemberInvitationModal.tsx
new file mode 100644
index 0000000000..2e9a82610d
--- /dev/null
+++ b/components/team/MemberInvitationModal.tsx
@@ -0,0 +1,97 @@
+import { UsersIcon } from "@heroicons/react/outline";
+import { useState } from "react";
+
+export default function MemberInvitationModal(props) {
+
+ const [ errorMessage, setErrorMessage ] = useState('');
+
+ const handleError = async (res) => {
+
+ const responseData = await res.json();
+
+ if (res.ok === false) {
+ setErrorMessage(responseData.message);
+ throw new Error(responseData.message);
+ }
+
+ return responseData;
+ };
+
+ const inviteMember = (e) => {
+
+ e.preventDefault();
+
+ const payload = {
+ role: e.target.elements['role'].value,
+ usernameOrEmail: e.target.elements['inviteUser'].value,
+ sendEmailInvitation: e.target.elements['sendInviteEmail'].checked,
+ }
+
+ return fetch('/api/teams/' + props.team.id + '/invite', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }).then(handleError).then(props.onExit).catch( (e) => {
+ // do nothing.
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit the {props.team.name} team
+
+
+ + Manage and delete your team. +
+
+
);
+}
\ No newline at end of file
diff --git a/components/team/TeamList.tsx b/components/team/TeamList.tsx
new file mode 100644
index 0000000000..7f04d322df
--- /dev/null
+++ b/components/team/TeamList.tsx
@@ -0,0 +1,42 @@
+import {useEffect, useState} from "react";
+import TeamListItem from "./TeamListItem";
+import EditTeamModal from "./EditTeamModal";
+import MemberInvitationModal from "./MemberInvitationModal";
+
+export default function TeamList(props) {
+
+ const [ showMemberInvitationModal, setShowMemberInvitationModal ] = useState(false);
+ const [ showEditTeamModal, setShowEditTeamModal ] = useState(false);
+ const [ team, setTeam ] = useState(null);
+
+ const selectAction = (action: string, team: any) => {
+ setTeam(team);
+ switch (action) {
+ case 'edit':
+ setShowEditTeamModal(true);
+ break;
+ case 'invite':
+ setShowMemberInvitationModal(true);
+ break;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Invite a new member
+
+
+ + Invite someone to your team. +
+
+ {
+ props.onChange();
+ setShowEditTeamModal(false);
+ }}> }
+ {showMemberInvitationModal &&
+ setShowMemberInvitationModal(false)}>
+ }
+
);
+}
\ No newline at end of file
diff --git a/components/team/TeamListItem.tsx b/components/team/TeamListItem.tsx
new file mode 100644
index 0000000000..a7413d721c
--- /dev/null
+++ b/components/team/TeamListItem.tsx
@@ -0,0 +1,77 @@
+import {CogIcon, TrashIcon, UserAddIcon, UsersIcon} from "@heroicons/react/outline";
+import Dropdown from "../ui/Dropdown";
+import {useState} from "react";
+
+export default function TeamListItem(props) {
+
+ const [ team, setTeam ] = useState(props.team);
+
+ const acceptInvite = () => invitationResponse(true);
+ const declineInvite = () => invitationResponse(false);
+
+ const invitationResponse = (accept: boolean) => fetch('/api/user/membership', {
+ method: accept ? 'PATCH' : 'DELETE',
+ body: JSON.stringify({ teamId: props.team.id }),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }).then( () => {
+ // success
+ setTeam(null);
+ props.onChange();
+ });
+
+ return (team && -
+ {props.teams.map(
+ (team: any) =>
+
+ {/*{props.team.userRole === 'Owner' && expanded &&
+
+
+ {props.team.role === 'INVITEE' &&
+ {props.team.name}
+ {props.team.role.toLowerCase()}
+
+
+
+
+
}
+ {props.team.role === 'MEMBER' &&
+
+
}
+ {props.team.role === 'OWNER' &&
+
+
+
+
+
}
+
+ {props.team.members.length > 0 &&
}*/}
+
+
+
+ {props.team.members.map( (member) =>
+
}
+
+
+ Members
+Alex van Andel ({ member.email }) | +Owner | ++ + | +
setOpen(!open)} {...props}>
+ {props.children[0]}
+ {open && props.children[1]}
+
);
+}
\ No newline at end of file
diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx
new file mode 100644
index 0000000000..31d2ba0333
--- /dev/null
+++ b/components/ui/UsernameInput.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+
+export const UsernameInput = React.forwardRef( (props, ref) => (
+ // todo, check if username is already taken here?
+
+
+
+));
\ No newline at end of file
diff --git a/components/ui/alerts/Error.tsx b/components/ui/alerts/Error.tsx
new file mode 100644
index 0000000000..8cc239988d
--- /dev/null
+++ b/components/ui/alerts/Error.tsx
@@ -0,0 +1,22 @@
+
+import { XCircleIcon } from '@heroicons/react/solid'
+
+export default function ErrorAlert(props) {
+ return (
+
+
+ {typeof window !== "undefined" && window.location.hostname}/
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/lib/emails/invitation.ts b/lib/emails/invitation.ts
new file mode 100644
index 0000000000..2ee584b44e
--- /dev/null
+++ b/lib/emails/invitation.ts
@@ -0,0 +1,77 @@
+
+import {serverConfig} from "../serverConfig";
+import nodemailer from 'nodemailer';
+
+export default function createInvitationEmail(data: any, options: any = {}) {
+ return sendEmail(data, {
+ provider: {
+ transport: serverConfig.transport,
+ from: serverConfig.from,
+ },
+ ...options
+ });
+}
+
+const sendEmail = (invitation: any, {
+ provider,
+}) => new Promise( (resolve, reject) => {
+ const { transport, from } = provider;
+
+ nodemailer.createTransport(transport).sendMail(
+ {
+ from: `Calendso <${from}>`,
+ to: invitation.toEmail,
+ subject: `${invitation.from} invited you to join ${invitation.teamName}`,
+ html: html(invitation),
+ text: text(invitation),
+ },
+ (error) => {
+ if (error) {
+ console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, error);
+ return reject(new Error(error));
+ }
+ return resolve();
+ });
+});
+
+const html = (invitation: any) => `
+
+
+
+
+
+
+
+ Something went wrong
+
+
+ + {props.message} +
+
+
|
+
to \n +const text = (evt: any) => html(evt).replace('
', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file diff --git a/next.config.js b/next.config.js index 78aada020b..ab513115f4 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,11 @@ const withTM = require('next-transpile-modules')(['react-timezone-select']); +// TODO: Revisit this later with getStaticProps in App +if (process.env.NEXTAUTH_URL) { + process.env.BASE_URL = process.env.NEXTAUTH_URL.replace('/api/auth', ''); +} + if ( ! process.env.EMAIL_FROM ) { console.warn('\x1b[33mwarn', '\x1b[0m', 'EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file.'); } diff --git a/pages/api/integrations/googlecalendar/add.ts b/pages/api/integrations/googlecalendar/add.ts index c568e65231..5d2c4ae0a3 100644 --- a/pages/api/integrations/googlecalendar/add.ts +++ b/pages/api/integrations/googlecalendar/add.ts @@ -4,7 +4,7 @@ import prisma from '../../../../lib/prisma'; const {google} = require('googleapis'); const credentials = process.env.GOOGLE_API_CREDENTIALS; -const scopes = ['https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar']; +const scopes = ['https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/calendar.events']; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'GET') { diff --git a/pages/api/teams.ts b/pages/api/teams.ts new file mode 100644 index 0000000000..1ab69adbc1 --- /dev/null +++ b/pages/api/teams.ts @@ -0,0 +1,37 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../lib/prisma'; +import {getSession} from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + const session = await getSession({req: req}); + + if (!session) { + res.status(401).json({message: "Not authenticated"}); + return; + } + + if (req.method === "POST") { + + // TODO: Prevent creating a team with identical names? + + const createTeam = await prisma.team.create({ + data: { + name: req.body.name, + }, + }); + + const createMembership = await prisma.membership.create({ + data: { + teamId: createTeam.id, + userId: session.user.id, + role: 'OWNER', + accepted: true, + } + }); + + return res.status(201).setHeader('Location', process.env.BASE_URL + '/api/teams/1').send(null); + } + + res.status(404).send(null); +} diff --git a/pages/api/teams/[team]/index.ts b/pages/api/teams/[team]/index.ts new file mode 100644 index 0000000000..3fde85bc80 --- /dev/null +++ b/pages/api/teams/[team]/index.ts @@ -0,0 +1,26 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../../../lib/prisma'; +import {getSession} from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + const session = await getSession({req: req}); + if (!session) { + return res.status(401).json({message: "Not authenticated"}); + } + + // DELETE /api/teams/{team} + if (req.method === "DELETE") { + const deleteMembership = await prisma.membership.delete({ + where: { + userId_teamId: { userId: session.user.id, teamId: parseInt(req.query.team) } + } + }); + const deleteTeam = await prisma.team.delete({ + where: { + id: parseInt(req.query.team), + }, + }); + return res.status(204).send(null); + } +} \ No newline at end of file diff --git a/pages/api/teams/[team]/invite.ts b/pages/api/teams/[team]/invite.ts new file mode 100644 index 0000000000..1a8f2415cb --- /dev/null +++ b/pages/api/teams/[team]/invite.ts @@ -0,0 +1,71 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../../../lib/prisma'; +import createInvitationEmail from "../../../../lib/emails/invitation"; +import {getSession} from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + if (req.method !== "POST") { + return res.status(400).json({ message: "Bad request" }); + } + + const session = await getSession({req: req}); + if (!session) { + return res.status(401).json({message: "Not authenticated"}); + } + + const team = await prisma.team.findFirst({ + where: { + id: parseInt(req.query.team) + } + }); + + if (!team) { + return res.status(404).json({message: "Invalid team"}); + } + + const invitee = await prisma.user.findFirst({ + where: { + OR: [ + { username: req.body.usernameOrEmail }, + { email: req.body.usernameOrEmail } + ] + } + }); + + if (!invitee) { + return res.status(400).json({ + message: `Invite failed because there is no corresponding user for ${req.body.usernameOrEmail}`}); + } + + // create provisional membership + try { + const createMembership = await prisma.membership.create({ + data: { + teamId: parseInt(req.query.team), + userId: invitee.id, + role: req.body.role, + }, + }); + } + catch (err) { + if (err.code === "P2002") { // unique constraint violation + return res.status(409).json({ + message: 'This user is a member of this team / has a pending invitation.', + }); + } else { + throw err; // rethrow + } + }; + + // inform user of membership by email + if (req.body.sendEmailInvitation) { + createInvitationEmail({ + toEmail: invitee.email, + from: session.user.name, + teamName: team.name + }); + } + + res.status(201).json({}); +} diff --git a/pages/api/teams/[team]/membership.ts b/pages/api/teams/[team]/membership.ts new file mode 100644 index 0000000000..19d93abe91 --- /dev/null +++ b/pages/api/teams/[team]/membership.ts @@ -0,0 +1,67 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../../../lib/prisma'; +import {getSession} from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + const session = await getSession({req}); + + if (!session) { + res.status(401).json({message: "Not authenticated"}); + return; + } + + const isTeamOwner = !!await prisma.membership.findFirst({ + where: { + userId: session.user.id, + teamId: parseInt(req.query.team), + role: 'OWNER' + } + }); + + if ( ! isTeamOwner) { + res.status(403).json({message: "You are not authorized to manage this team"}); + return; + } + + // List members + if (req.method === "GET") { + const memberships = await prisma.membership.findMany({ + where: { + teamId: parseInt(req.query.team), + } + }); + + let members = await prisma.user.findMany({ + where: { + id: { + in: memberships.map( (membership) => membership.userId ), + } + } + }); + + members = members.map( (member) => { + const membership = memberships.find( (membership) => member.id === membership.userId ); + return { + ...member, + role: membership.accepted ? membership.role : 'INVITEE', + } + }); + + return res.status(200).json({ members: members }); + } + + // Cancel a membership (invite) + if (req.method === "DELETE") { + const memberships = await prisma.membership.delete({ + where: { + userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team) }, + } + }); + return res.status(204).send(null); + } + + // Promote or demote a member of the team + + res.status(200).json({}); +} diff --git a/pages/api/user/membership.ts b/pages/api/user/membership.ts new file mode 100644 index 0000000000..d05ae5b39d --- /dev/null +++ b/pages/api/user/membership.ts @@ -0,0 +1,62 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import prisma from '../../../lib/prisma'; +import { getSession } from "next-auth/client"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + const session = await getSession({req: req}); + if (!session) { + return res.status(401).json({message: "Not authenticated"}); + } + + if (req.method === "GET") { + const memberships = await prisma.membership.findMany({ + where: { + userId: session.user.id, + } + }); + + const teams = await prisma.team.findMany({ + where: { + id: { + in: memberships.map(membership => membership.teamId), + } + } + }); + + return res.status(200).json({ + membership: memberships.map((membership) => ({ + role: membership.accepted ? membership.role : 'INVITEE', + ...teams.find(team => team.id === membership.teamId) + })) + }); + } + + if (!req.body.teamId) { + return res.status(400).json({ message: "Bad request" }); + } + + // Leave team or decline membership invite of current user + if (req.method === "DELETE") { + const memberships = await prisma.membership.delete({ + where: { + userId_teamId: { userId: session.user.id, teamId: req.body.teamId } + } + }); + return res.status(204).send(null); + } + + // Accept team invitation + if (req.method === "PATCH") { + const memberships = await prisma.membership.update({ + where: { + userId_teamId: { userId: session.user.id, teamId: req.body.teamId } + }, + data: { + accepted: true + } + }); + + return res.status(204).send(null); + } +} \ No newline at end of file diff --git a/pages/api/user/profile.ts b/pages/api/user/profile.ts index f1b2e18d68..cc9aef6a5d 100644 --- a/pages/api/user/profile.ts +++ b/pages/api/user/profile.ts @@ -3,46 +3,58 @@ import { getSession } from 'next-auth/client'; import prisma from '../../../lib/prisma'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getSession({req: req}); + const session = await getSession({req: req}); - if (!session) { - res.status(401).json({message: "Not authenticated"}); - return; + if (!session) { + res.status(401).json({message: "Not authenticated"}); + return; + } + + // Get user + const user = await prisma.user.findUnique({ + where: { + email: session.user.email, + }, + select: { + id: true, + password: true } + }); - // Get user - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true, - password: true - } + if (!user) { res.status(404).json({message: 'User not found'}); return; } + + const username = req.body.username; + // username is changed: username is optional but it is necessary to be unique, enforce here + if (username !== session.user.username) { + const userConflict = await prisma.user.findFirst({ + where: { + username, + } }); + if (userConflict) { + return res.status(409).json({ message: 'Username already taken' }); + } + } - if (!user) { res.status(404).json({message: 'User not found'}); return; } + const name = req.body.name; + const description = req.body.description; + const avatar = req.body.avatar; + const timeZone = req.body.timeZone; + const weekStart = req.body.weekStart; - const username = req.body.username; - const name = req.body.name; - const description = req.body.description; - const avatar = req.body.avatar; - const timeZone = req.body.timeZone; - const weekStart = req.body.weekStart; + const updateUser = await prisma.user.update({ + where: { + id: user.id, + }, + data: { + username, + name, + avatar, + bio: description, + timeZone: timeZone, + weekStart: weekStart, + }, + }); - const updateUser = await prisma.user.update({ - where: { - id: user.id, - }, - data: { - username, - name, - avatar, - bio: description, - timeZone: timeZone, - weekStart: weekStart, - }, - }); - - res.status(200).json({message: 'Profile updated successfully'}); + return res.status(200).json({message: 'Profile updated successfully'}); } \ No newline at end of file diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index d0a112943b..a00f9efaf6 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -9,6 +9,8 @@ import SettingsShell from '../../components/Settings'; import Avatar from '../../components/Avatar'; import { signIn, useSession, getSession } from 'next-auth/client'; import TimezoneSelect from 'react-timezone-select'; +import {UsernameInput} from "../../components/ui/UsernameInput"; +import ErrorAlert from "../../components/ui/alerts/Error"; export default function Settings(props) { const [ session, loading ] = useSession(); @@ -22,12 +24,22 @@ export default function Settings(props) { const [ selectedTimeZone, setSelectedTimeZone ] = useState({ value: props.user.timeZone }); const [ selectedWeekStartDay, setSelectedWeekStartDay ] = useState(props.user.weekStart || 'Sunday'); + const [ hasErrors, setHasErrors ] = useState(false); + const [ errorMessage, setErrorMessage ] = useState(''); + if (loading) { return
Loading...
; } const closeSuccessModal = () => { setSuccessModalOpen(false); } + const handleError = async (resp) => { + if (!resp.ok) { + const error = await resp.json(); + throw new Error(error.message); + } + } + async function updateProfileHandler(event) { event.preventDefault(); @@ -46,10 +58,13 @@ export default function Settings(props) { headers: { 'Content-Type': 'application/json' } + }).then(handleError).then( () => { + setSuccessModalOpen(true); + setHasErrors(false); // dismiss any open errors + }).catch( (err) => { + setHasErrors(true); + setErrorMessage(err.message); }); - - router.replace(router.asPath); - setSuccessModalOpen(true); } return( @@ -60,6 +75,7 @@ export default function Settings(props) {