Merge branch 'main' into feature/cancel-reschedule-links
This commit is contained in:
commit
3d4222c631
23 changed files with 990 additions and 65 deletions
|
@ -1,9 +1,7 @@
|
|||
import ActiveLink from '../components/ActiveLink';
|
||||
import { useRouter } from "next/router";
|
||||
import { UserCircleIcon, KeyIcon, CodeIcon, UserGroupIcon } from '@heroicons/react/outline';
|
||||
|
||||
export default function SettingsShell(props) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div>
|
||||
<main className="relative -mt-32">
|
||||
|
@ -35,9 +33,9 @@ export default function SettingsShell(props) {
|
|||
<ActiveLink href="/settings/embed">
|
||||
<a><CodeIcon /> Embed</a>
|
||||
</ActiveLink>
|
||||
{/*<ActiveLink href="/settings/teams">
|
||||
<ActiveLink href="/settings/teams">
|
||||
<a><UserGroupIcon /> Teams</a>
|
||||
</ActiveLink>*/}
|
||||
</ActiveLink>
|
||||
|
||||
{/* <Link href="/settings/notifications">
|
||||
<a className={router.pathname == "/settings/notifications" ? "bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700 group border-l-4 px-3 py-2 flex items-center text-sm font-medium" : "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"}>
|
||||
|
|
105
components/team/EditTeamModal.tsx
Normal file
105
components/team/EditTeamModal.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import {useEffect, useState} from "react";
|
||||
import {UsersIcon,UserRemoveIcon} from "@heroicons/react/outline";
|
||||
import {useSession} from "next-auth/client";
|
||||
|
||||
export default function EditTeamModal(props) {
|
||||
|
||||
const [ session, loading ] = useSession();
|
||||
const [ members, setMembers ] = useState([]);
|
||||
const [ checkedDisbandTeam, setCheckedDisbandTeam ] = useState(false);
|
||||
|
||||
const loadMembers = () => fetch('/api/teams/' + props.team.id + '/membership')
|
||||
.then( (res: any) => res.json() ).then( (data) => setMembers(data.members) );
|
||||
|
||||
useEffect( () => {
|
||||
loadMembers();
|
||||
}, []);
|
||||
|
||||
const deleteTeam = (e) => {
|
||||
e.preventDefault();
|
||||
return fetch('/api/teams/' + props.team.id, {
|
||||
method: 'DELETE',
|
||||
}).then(props.onExit);
|
||||
}
|
||||
|
||||
const removeMember = (member) => {
|
||||
return fetch('/api/teams/' + props.team.id + '/membership', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ userId: member.id }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(loadMembers);
|
||||
}
|
||||
|
||||
return (<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start mb-4">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit the {props.team.name} team</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
Manage and delete your team.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
{members.length > 0 && <div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<h2 className="text-lg font-medium text-gray-900">Members</h2>
|
||||
</div>
|
||||
<table className="table-auto mb-2 w-full text-sm">
|
||||
<tbody>
|
||||
{members.map( (member) => <tr key={member.email}>
|
||||
<td className="p-1">{member.name} {member.name && '(' + member.email + ')' }{!member.name && member.email}</td>
|
||||
<td className="capitalize">{member.role.toLowerCase()}</td>
|
||||
<td className="text-right py-2 px-1">
|
||||
{member.email !== session.user.email &&
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => removeMember(member)}
|
||||
className="btn-sm text-xs bg-transparent px-3 py-1 rounded ml-2">
|
||||
<UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="mb-4 border border-red-400 rounded p-2 px-4">
|
||||
<p className="block text-sm font-medium text-gray-700">Tick the box to disband this team.</p>
|
||||
<label className="mt-1">
|
||||
<input type="checkbox" onChange={(e) => setCheckedDisbandTeam(e.target.checked)} className="shadow-sm mr-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md" />
|
||||
Disband this team
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
{/*!checkedDisbandTeam && <button type="submit" className="btn btn-primary">
|
||||
Update
|
||||
</button>*/}
|
||||
{checkedDisbandTeam && <button onClick={deleteTeam} className="btn bg-red-700 rounded text-white px-2 font-medium text-sm">
|
||||
Disband Team
|
||||
</button>}
|
||||
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
97
components/team/MemberInvitationModal.tsx
Normal file
97
components/team/MemberInvitationModal.tsx
Normal file
|
@ -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 (<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start mb-4">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Invite a new member</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
Invite someone to your team.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={inviteMember}>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">Email or Username</label>
|
||||
<input type="text" name="inviteUser" id="inviteUser" placeholder="email@example.com" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block tracking-wide text-gray-700 text-sm font-medium mb-2"
|
||||
htmlFor="role">
|
||||
Role
|
||||
</label>
|
||||
<select id="role" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md">
|
||||
<option value="MEMBER">Member</option>
|
||||
<option value="OWNER">Owner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="mt-1 text-gray-600">
|
||||
<input type="checkbox" name="sendInviteEmail" defaultChecked id="sendInviteEmail" className="shadow-sm mr-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 rounded-md" />
|
||||
Send an invite email
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && <p className="text-red-700 text-sm"><span className="font-bold">Error: </span>{errorMessage}</p>}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Invite
|
||||
</button>
|
||||
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
42
components/team/TeamList.tsx
Normal file
42
components/team/TeamList.tsx
Normal file
|
@ -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 (<div>
|
||||
<ul className="border px-2 mb-2 rounded divide-y divide-gray-200">
|
||||
{props.teams.map(
|
||||
(team: any) => <TeamListItem onChange={props.onChange} key={team.id} team={team} onActionSelect={
|
||||
(action: string) => selectAction(action, team)
|
||||
}></TeamListItem>
|
||||
)}
|
||||
</ul>
|
||||
{showEditTeamModal && <EditTeamModal team={team} onExit={() => {
|
||||
props.onChange();
|
||||
setShowEditTeamModal(false);
|
||||
}}></EditTeamModal>}
|
||||
{showMemberInvitationModal &&
|
||||
<MemberInvitationModal
|
||||
team={team}
|
||||
onExit={() => setShowMemberInvitationModal(false)}></MemberInvitationModal>
|
||||
}
|
||||
</div>);
|
||||
}
|
77
components/team/TeamListItem.tsx
Normal file
77
components/team/TeamListItem.tsx
Normal file
|
@ -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 && <li className="mb-2 mt-2 divide-y">
|
||||
<div className="flex justify-between mb-2 mt-2">
|
||||
<div>
|
||||
<UsersIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-4 mr-2 h-6 w-6 inline"/>
|
||||
<div className="inline-block -mt-1">
|
||||
<span className="font-bold text-blue-700 text-sm">{props.team.name}</span>
|
||||
<span className="text-xs text-gray-400 -mt-1 block capitalize">{props.team.role.toLowerCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{props.team.role === 'INVITEE' && <div>
|
||||
<button className="btn-sm bg-transparent text-green-500 border border-green-500 px-3 py-1 rounded ml-2" onClick={acceptInvite}>Accept invitation</button>
|
||||
<button className="btn-sm bg-transparent px-2 py-1 ml-1">
|
||||
<TrashIcon className="h-6 w-6 inline text-gray-400 -mt-1" onClick={declineInvite} />
|
||||
</button>
|
||||
</div>}
|
||||
{props.team.role === 'MEMBER' && <div>
|
||||
<button onClick={declineInvite} className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded ml-2">Leave</button>
|
||||
</div>}
|
||||
{props.team.role === 'OWNER' && <div>
|
||||
<Dropdown className="relative inline-block text-left">
|
||||
<button className="btn-sm bg-transparent text-gray-400 px-3 py-1 rounded ml-2">
|
||||
<CogIcon className="h-6 w-6 inline text-gray-400" />
|
||||
</button>
|
||||
<ul role="menu" className="z-10 origin-top-right absolute right-0 w-36 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
|
||||
<a className="block px-4 py-2" onClick={() => props.onActionSelect('invite')}>Invite member(s)</a>
|
||||
</li>
|
||||
<li className="text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" role="menuitem">
|
||||
<a className="block px-4 py-2" onClick={() => props.onActionSelect('edit')}>Manage team</a>
|
||||
</li>
|
||||
</ul>
|
||||
</Dropdown>
|
||||
</div>}
|
||||
</div>
|
||||
{/*{props.team.userRole === 'Owner' && expanded && <div className="pt-2">
|
||||
{props.team.members.length > 0 && <div>
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-1">Members</h2>
|
||||
<table className="table-auto mb-2 w-full">
|
||||
<tbody>
|
||||
{props.team.members.map( (member) => <tr key={member.email}>
|
||||
<td className="py-1 pl-2">Alex van Andel ({ member.email })</td>
|
||||
<td>Owner</td>
|
||||
<td className="text-right p-1">
|
||||
<button className="btn-sm text-xs bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded ml-2"><UserRemoveIcon className="text-red-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 mr-1 h-4 w-4 inline"/>Remove</button>
|
||||
</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
<button className="btn-sm bg-transparent text-gray-400 border border-gray-400 px-3 py-1 rounded"><UserAddIcon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -mt-1 h-6 w-6 inline"/> Invite member</button>
|
||||
<button className="btn-sm bg-transparent text-red-400 border border-red-400 px-3 py-1 rounded ml-2">Disband</button>
|
||||
</div>}*/}
|
||||
</li>);
|
||||
}
|
|
@ -1,17 +1,16 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
export default function Button(props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return(
|
||||
<button type="submit" className="btn btn-primary" onClick={setLoading}>
|
||||
{!loading && props.children}
|
||||
{loading &&
|
||||
<svg className="animate-spin mx-4 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
return(
|
||||
<button type="submit" className="btn btn-primary" onClick={setLoading}>
|
||||
{!loading && props.children}
|
||||
{loading &&
|
||||
<svg className="animate-spin mx-4 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
);
|
||||
}
|
19
components/ui/Dropdown.tsx
Normal file
19
components/ui/Dropdown.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function Dropdown(props) {
|
||||
|
||||
const [ open, setOpen ] = useState(false);
|
||||
|
||||
useEffect( () => {
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
return (<div onClick={() => setOpen(!open)} {...props}>
|
||||
{props.children[0]}
|
||||
{open && props.children[1]}
|
||||
</div>);
|
||||
}
|
17
components/ui/UsernameInput.tsx
Normal file
17
components/ui/UsernameInput.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React from "react";
|
||||
|
||||
export const UsernameInput = React.forwardRef( (props, ref) => (
|
||||
// todo, check if username is already taken here?
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Username
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm flex">
|
||||
<span className="bg-gray-50 border border-r-0 border-gray-300 rounded-l-md px-3 inline-flex items-center text-gray-500 sm:text-sm">
|
||||
{typeof window !== "undefined" && window.location.hostname}/
|
||||
</span>
|
||||
<input ref={ref} type="text" name="username" id="username" autoComplete="username" required defaultValue={props.defaultValue}
|
||||
className="focus:ring-blue-500 focus:border-blue-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"/>
|
||||
</div>
|
||||
</div>
|
||||
));
|
22
components/ui/alerts/Error.tsx
Normal file
22
components/ui/alerts/Error.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
|
||||
import { XCircleIcon } from '@heroicons/react/solid'
|
||||
|
||||
export default function ErrorAlert(props) {
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Something went wrong</h3>
|
||||
<div className="text-sm text-red-700">
|
||||
<p>
|
||||
{props.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
77
lib/emails/invitation.ts
Normal file
77
lib/emails/invitation.ts
Normal file
|
@ -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) => `
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td>
|
||||
<center>
|
||||
<table style="width: 640px; border: 1px solid gray; padding: 15px; margin: 0 auto; text-align: left;">
|
||||
<tr>
|
||||
<td>
|
||||
Hi,<br />
|
||||
<br />
|
||||
${invitation.from} invited you to join the team "${invitation.teamName}" in Calendso.<br />
|
||||
<br />
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${process.env.BASE_URL}/settings/teams" style="height:40px;v-text-anchor:middle;width:130px;" arcsize="5%" strokecolor="#19cca3" fillcolor="#19cca3;width: 130;">
|
||||
<w:anchorlock/>
|
||||
<center style="color:#ffffff;font-family:Helvetica, sans-serif;font-size:18px; font-weight: 600;">Join team</center>
|
||||
</v:roundrect>
|
||||
|
||||
<![endif]-->
|
||||
<a href="${process.env.BASE_URL}/settings/teams" style="display: inline-block; mso-hide:all; background-color: #19cca3; color: #FFFFFF; border:1px solid #19cca3; border-radius: 6px; line-height: 220%; width: 200px; font-family: Helvetica, sans-serif; font-size:18px; font-weight:600; text-align: center; text-decoration: none; -webkit-text-size-adjust:none; " target="_blank">Join team</a>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table><br />
|
||||
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.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// just strip all HTML and convert <br /> to \n
|
||||
const text = (evt: any) => html(evt).replace('<br />', "\n").replace(/<[^>]+>/g, '');
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
@ -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') {
|
||||
|
|
37
pages/api/teams.ts
Normal file
37
pages/api/teams.ts
Normal file
|
@ -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);
|
||||
}
|
26
pages/api/teams/[team]/index.ts
Normal file
26
pages/api/teams/[team]/index.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
71
pages/api/teams/[team]/invite.ts
Normal file
71
pages/api/teams/[team]/invite.ts
Normal file
|
@ -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({});
|
||||
}
|
67
pages/api/teams/[team]/membership.ts
Normal file
67
pages/api/teams/[team]/membership.ts
Normal file
|
@ -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({});
|
||||
}
|
62
pages/api/user/membership.ts
Normal file
62
pages/api/user/membership.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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'});
|
||||
}
|
|
@ -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 <p className="text-gray-400">Loading...</p>;
|
||||
}
|
||||
|
||||
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) {
|
|||
</Head>
|
||||
<SettingsShell>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
|
||||
{hasErrors && <ErrorAlert message={errorMessage} />}
|
||||
<div className="py-6 px-4 sm:p-6 lg:pb-8">
|
||||
<div>
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Profile</h2>
|
||||
|
@ -72,15 +88,7 @@ export default function Settings(props) {
|
|||
<div className="flex-grow space-y-6">
|
||||
<div className="flex">
|
||||
<div className="w-1/2 mr-2">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Username
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm flex">
|
||||
<span className="bg-gray-50 border border-r-0 border-gray-300 rounded-l-md px-3 inline-flex items-center text-gray-500 sm:text-sm">
|
||||
{window.location.hostname}/
|
||||
</span>
|
||||
<input ref={usernameRef} type="text" name="username" id="username" autoComplete="username" required className="focus:ring-blue-500 focus:border-blue-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300" defaultValue={props.user.username} />
|
||||
</div>
|
||||
<UsernameInput ref={usernameRef} defaultValue={props.user.username} />
|
||||
</div>
|
||||
<div className="w-1/2 ml-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Full name</label>
|
||||
|
|
156
pages/settings/teams.tsx
Normal file
156
pages/settings/teams.tsx
Normal file
|
@ -0,0 +1,156 @@
|
|||
import Head from 'next/head';
|
||||
import prisma from '../../lib/prisma';
|
||||
import Modal from '../../components/Modal';
|
||||
import Shell from '../../components/Shell';
|
||||
import SettingsShell from '../../components/Settings';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession, getSession } from 'next-auth/client';
|
||||
import {
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import TeamList from "../../components/team/TeamList";
|
||||
import TeamListItem from "../../components/team/TeamListItem";
|
||||
|
||||
export default function Teams(props) {
|
||||
|
||||
const [session, loading] = useSession();
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [invites, setInvites] = useState([]);
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
|
||||
const loadTeams = () => fetch('/api/user/membership').then((res: any) => res.json()).then(
|
||||
(data) => {
|
||||
setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
|
||||
setInvites(data.membership.filter((m) => m.role === "INVITEE"));
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => { loadTeams(); }, []);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
}
|
||||
|
||||
const createTeam = (e) => {
|
||||
e.preventDefault();
|
||||
return fetch('/api/teams', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: e.target.elements['name'].value }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(() => {
|
||||
loadTeams();
|
||||
setShowCreateTeamModal(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell heading="Teams">
|
||||
<Head>
|
||||
<title>Teams | Calendso</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<SettingsShell>
|
||||
<div className="divide-y divide-gray-200 lg:col-span-9">
|
||||
<div className="py-6 px-4 sm:p-6 lg:pb-8">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Your teams</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 mb-4">
|
||||
View, edit and create teams to organise relationships between users
|
||||
</p>
|
||||
{!(invites.length || teams.length) &&
|
||||
<div className="bg-gray-50 sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Create a team to get started</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>Create your first team and invite other users to work together with you.</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateTeamModal(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Create new team
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{!!(invites.length || teams.length) && <div>
|
||||
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>Create new team</button>
|
||||
</div>}
|
||||
</div>
|
||||
<div>
|
||||
{!!teams.length &&
|
||||
<TeamList teams={teams} onChange={loadTeams}>
|
||||
</TeamList>
|
||||
}
|
||||
|
||||
{!!invites.length && <div>
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2>
|
||||
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
|
||||
{invites.map((team) => <TeamListItem onChange={loadTeams} key={team.id} team={team}></TeamListItem>)}
|
||||
</ul>
|
||||
</div>}
|
||||
</div>
|
||||
{/*{teamsLoaded && <div className="flex justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900 mb-1">Transform account</h2>
|
||||
<p className="text-sm text-gray-500 mb-1">
|
||||
{membership.length !== 0 && "You cannot convert this account into a team until you leave all teams that you’re a member of."}
|
||||
{membership.length === 0 && "A user account can be turned into a team, as a team ...."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button className="mt-2 btn-sm btn-primary opacity-50 cursor-not-allowed" disabled>Convert {session.user.username} into a team</button>
|
||||
</div>
|
||||
</div>}*/}
|
||||
</div>
|
||||
</div>
|
||||
{showCreateTeamModal &&
|
||||
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start mb-4">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Create a new team</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
Create a new team to collaborate with users.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={createTeam}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input type="text" name="name" id="name" placeholder="Acme Inc." required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create team
|
||||
</button>
|
||||
<button onClick={() => setShowCreateTeamModal(false)} type="button" className="btn btn-white mr-2">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</SettingsShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
|
@ -46,10 +46,33 @@ model User {
|
|||
createdDate DateTime @default(now()) @map(name: "created")
|
||||
eventTypes EventType[]
|
||||
credentials Credential[]
|
||||
teams Membership[]
|
||||
bookings Booking[]
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
||||
model Team {
|
||||
id Int @default(autoincrement()) @id
|
||||
name String?
|
||||
members Membership[]
|
||||
}
|
||||
|
||||
enum MembershipRole {
|
||||
MEMBER
|
||||
OWNER
|
||||
}
|
||||
|
||||
model Membership {
|
||||
teamId Int
|
||||
userId Int
|
||||
accepted Boolean @default(false)
|
||||
role MembershipRole
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@id([userId,teamId])
|
||||
}
|
||||
|
||||
model BookingReference {
|
||||
id Int @default(autoincrement()) @id
|
||||
type String
|
||||
|
|
4
styles/components/table.css
Normal file
4
styles/components/table.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
table tbody tr:nth-child(odd) {
|
||||
@apply bg-gray-50;
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
@import './components/buttons.css';
|
||||
@import './components/spinner.css';
|
||||
@import './components/activelink.css';
|
||||
@import './components/table.css';
|
||||
|
||||
body {
|
||||
background-color: #f3f4f6;
|
||||
|
|
Loading…
Reference in a new issue