commit
4939415a48
17 changed files with 843 additions and 4 deletions
|
@ -1,9 +1,7 @@
|
||||||
import ActiveLink from '../components/ActiveLink';
|
import ActiveLink from '../components/ActiveLink';
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { UserCircleIcon, KeyIcon, CodeIcon, UserGroupIcon } from '@heroicons/react/outline';
|
import { UserCircleIcon, KeyIcon, CodeIcon, UserGroupIcon } from '@heroicons/react/outline';
|
||||||
|
|
||||||
export default function SettingsShell(props) {
|
export default function SettingsShell(props) {
|
||||||
const router = useRouter();
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<main className="relative -mt-32">
|
<main className="relative -mt-32">
|
||||||
|
@ -35,9 +33,9 @@ export default function SettingsShell(props) {
|
||||||
<ActiveLink href="/settings/embed">
|
<ActiveLink href="/settings/embed">
|
||||||
<a><CodeIcon /> Embed</a>
|
<a><CodeIcon /> Embed</a>
|
||||||
</ActiveLink>
|
</ActiveLink>
|
||||||
{/*<ActiveLink href="/settings/teams">
|
<ActiveLink href="/settings/teams">
|
||||||
<a><UserGroupIcon /> Teams</a>
|
<a><UserGroupIcon /> Teams</a>
|
||||||
</ActiveLink>*/}
|
</ActiveLink>
|
||||||
|
|
||||||
{/* <Link href="/settings/notifications">
|
{/* <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"}>
|
<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"}>
|
||||||
|
|
100
components/team/EditTeamModal.tsx
Normal file
100
components/team/EditTeamModal.tsx
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
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 {props.team.name}</h3>
|
||||||
|
</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>);
|
||||||
|
}
|
92
components/team/MemberInvitationModal.tsx
Normal file
92
components/team/MemberInvitationModal.tsx
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
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">Member Invitation</h3>
|
||||||
|
</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">
|
||||||
|
<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 invite email
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errorMessage && <p className="text-red-700 text-sm"><span class="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 font-bold -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>);
|
||||||
|
}
|
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>);
|
||||||
|
}
|
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']);
|
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 ) {
|
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.');
|
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.');
|
||||||
}
|
}
|
||||||
|
|
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);
|
||||||
|
}
|
||||||
|
}
|
137
pages/settings/teams.tsx
Normal file
137
pages/settings/teams.tsx
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
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-2">
|
||||||
|
View, edit and create teams to organise relationships between users
|
||||||
|
</p>
|
||||||
|
{!(invites.length || teams.length) && <div className="border rounded text-center p-4 pt-3 m-4">
|
||||||
|
<p className="text-sm text-gray-500">Team up with other users<br /> by adding a new team</p>
|
||||||
|
<UsersIcon className="text-blue-500 w-32 h-32 mx-auto"/>
|
||||||
|
<button className="btn-lg btn-primary" onClick={() => setShowCreateTeamModal(true)}>New team</button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
{!!(invites.length || teams.length) && <div>
|
||||||
|
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>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">New team</h3>
|
||||||
|
</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" 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -45,5 +45,29 @@ model User {
|
||||||
createdDate DateTime @default(now()) @map(name: "created")
|
createdDate DateTime @default(now()) @map(name: "created")
|
||||||
eventTypes EventType[]
|
eventTypes EventType[]
|
||||||
credentials Credential[]
|
credentials Credential[]
|
||||||
|
teams Membership[]
|
||||||
|
|
||||||
@@map(name: "users")
|
@@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])
|
||||||
|
}
|
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/buttons.css';
|
||||||
@import './components/spinner.css';
|
@import './components/spinner.css';
|
||||||
@import './components/activelink.css';
|
@import './components/activelink.css';
|
||||||
|
@import './components/table.css';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
|
|
Loading…
Reference in a new issue