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