Improvement/teams (#1285)

* [WIP] checkpoint before pull & merge

- Added teams to sidebar
- Refactored team settings
- Improved team list UI

This code will be partly reverted next commit.

* [WIP]
- Moved team code back to components
- Removed team link from sidebar
- Built new team manager screen based on Event Type designs
- Component-ized frequently reused code (SettingInputContainer, FlatIconButton)

* [WIP]
- Created LinkIconButton as standalone component
- Added functionality to sidebar of team settings
- Fixed type bug on public team page induced by my normalization of members array in team query
- Removed teams-old which was kept as refrence
- Cleaned up loose ends

* [WIP]
- added create team model
- fixed profile missing label due to my removal of default label from component

* [WIP]
- Fixed TeamCreateModal trigger
- removed TeamShell, it didn't make the cut
- added getPlaceHolderAvatar
- renamed TeamCreate to TeamCreateModal
- removed deprecated UsernameInput and replaced uses with suggested TextField

* fix save button

* [WIP]
- Fixed drop down actions on team list
- Cleaned up state updates

* [WIP] converting teams to tRPC

* [WIP] Finished refactor to tRPC

* [WIP] Finishing touches

* [WIP] Team availability beginning

* team availability mvp

* - added validation to change role
- modified layout of team availability
- corrected types

* fix ui issue on team availability screen

* - added virtualization to team availability
- added flexChildrenContainer boolean to Shell to allow for flex on children

* availability style fix

* removed hard coded team type as teams now use inferred type from tRPC

* Removed unneeded vscode settings

* Reverted prisma schema

* Fixed migrations

* Removes unused dayjs plugins

* Reverts type regression

* Type fix

* Type fixes

* Type fixes

* Moves team availability code to ee

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Jamie Pine 2021-12-09 15:51:30 -08:00 committed by GitHub
parent 5902f78fb2
commit c1d90eb438
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 2295 additions and 998 deletions

View file

@ -5,9 +5,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.run": "onSave",
"workbench.colorCustomizations": {
"titleBar.activeBackground": "#292929",
"titleBar.inactiveBackground": "#888888"
}
"eslint.run": "onSave"
}

View file

@ -110,7 +110,7 @@ export default function ImageUploader({
(opened) => !opened && setFile(null) // unset file on close
}>
<DialogTrigger asChild>
<div className="flex items-center px-3">
<div className="flex items-center">
<Button color="secondary" type="button" className="py-1 text-xs">
{buttonMsg}
</Button>

View file

@ -1,6 +1,7 @@
import { SelectorIcon } from "@heroicons/react/outline";
import {
CalendarIcon,
ArrowLeftIcon,
ClockIcon,
CogIcon,
ExternalLinkIcon,
@ -36,6 +37,7 @@ import Dropdown, {
import { useViewerI18n } from "./I18nLanguageHandler";
import Logo from "./Logo";
import Button from "./ui/Button";
function useMeQuery() {
const meQuery = trpc.useQuery(["viewer.me"], {
@ -118,6 +120,10 @@ export default function Shell(props: {
subtitle?: ReactNode;
children: ReactNode;
CTA?: ReactNode;
HeadingLeftIcon?: ReactNode;
showBackButton?: boolean;
// use when content needs to expand with flex
flexChildrenContainer?: boolean;
}) {
const { t } = useLocale();
const router = useRouter();
@ -249,7 +255,11 @@ export default function Shell(props: {
</div>
<div className="flex flex-col flex-1 w-0 overflow-hidden">
<main className="flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]">
<main
className={classNames(
"flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]",
props.flexChildrenContainer && "flex flex-col"
)}>
{/* show top navigation for md and smaller (tablet and phones) */}
<nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 md:hidden">
<Link href="/event-types">
@ -269,8 +279,21 @@ export default function Shell(props: {
<UserDropdown small />
</div>
</nav>
<div className={classNames(props.centered && "md:max-w-5xl mx-auto", "py-8")}>
<div
className={classNames(
props.centered && "md:max-w-5xl mx-auto",
props.flexChildrenContainer && "flex flex-col flex-1",
"py-8"
)}>
{props.showBackButton && (
<div className="mx-3 mb-8 sm:mx-8">
<Button onClick={() => router.back()} StartIcon={ArrowLeftIcon} color="secondary">
Back
</Button>
</div>
)}
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8 min-h-[80px]">
{props.HeadingLeftIcon && <div className="mr-4">{props.HeadingLeftIcon}</div>}
<div className="w-full mb-8">
<h1 className="mb-1 text-xl font-bold tracking-wide text-gray-900 font-cal">
{props.heading}
@ -279,7 +302,13 @@ export default function Shell(props: {
</div>
<div className="flex-shrink-0 mb-4">{props.CTA}</div>
</div>
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
<div
className={classNames(
"px-4 sm:px-6 md:px-8",
props.flexChildrenContainer && "flex flex-col flex-1"
)}>
{props.children}
</div>
{/* show bottom navigation for md and smaller (tablet and phones) */}
<nav className="fixed bottom-0 flex w-full bg-white shadow bottom-nav md:hidden">
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}

View file

@ -16,7 +16,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
{...props}
ref={ref}
className={classNames(
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm",
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-1 focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm",
props.className
)}
/>
@ -54,9 +54,11 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
} = props;
return (
<div>
<Label htmlFor={id} {...labelProps}>
{label}
</Label>
{!!props.name && (
<Label htmlFor={id} {...labelProps}>
{label}
</Label>
)}
{addOnLeading ? (
<div className="flex mt-1 rounded-md shadow-sm">
{addOnLeading}

View file

@ -1,289 +0,0 @@
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
import React, { useEffect, useRef, useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { Member } from "@lib/member";
import showToast from "@lib/notification";
import { Team } from "@lib/team";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ImageUploader from "@components/ImageUploader";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import MemberInvitationModal from "@components/team/MemberInvitationModal";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import { UsernameInput } from "@components/ui/UsernameInput";
import ErrorAlert from "@components/ui/alerts/Error";
import MemberList from "./MemberList";
export default function EditTeam(props: { team: Team | undefined | null; onCloseEdit: () => void }) {
const [members, setMembers] = useState([]);
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const [hasErrors, setHasErrors] = useState(false);
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [inviteModalTeam, setInviteModalTeam] = useState<Team | null | undefined>();
const [errorMessage, setErrorMessage] = useState("");
const [imageSrc, setImageSrc] = useState<string>("");
const { t } = useLocale();
const loadMembers = () =>
fetch("/api/teams/" + props.team?.id + "/membership")
.then((res) => res.json())
.then((data) => setMembers(data.members));
useEffect(() => {
loadMembers();
}, []);
const deleteTeam = () => {
return fetch("/api/teams/" + props.team?.id, {
method: "DELETE",
}).then(props.onCloseEdit());
};
const onRemoveMember = (member: Member) => {
return fetch("/api/teams/" + props.team?.id + "/membership", {
method: "DELETE",
body: JSON.stringify({ userId: member.id }),
headers: {
"Content-Type": "application/json",
},
}).then(loadMembers);
};
const onInviteMember = (team: Team | null | undefined) => {
setShowMemberInvitationModal(true);
setInviteModalTeam(team);
};
const handleError = async (resp: Response) => {
if (!resp.ok) {
const error = await resp.json();
throw new Error(error.message);
}
};
async function updateTeamHandler(event) {
event.preventDefault();
const enteredUsername = teamUrlRef?.current?.value.toLowerCase();
const enteredName = nameRef?.current?.value;
const enteredDescription = descriptionRef?.current?.value;
const enteredLogo = logoRef?.current?.value;
const enteredHideBranding = hideBrandingRef?.current?.checked;
// TODO: Add validation
await fetch("/api/teams/" + props.team?.id + "/profile", {
method: "PATCH",
body: JSON.stringify({
username: enteredUsername,
name: enteredName,
description: enteredDescription,
logo: enteredLogo,
hideBranding: enteredHideBranding,
}),
headers: {
"Content-Type": "application/json",
},
})
.then(handleError)
.then(() => {
showToast(t("your_team_updated_successfully"), "success");
setHasErrors(false); // dismiss any open errors
})
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
});
}
const onMemberInvitationModalExit = () => {
loadMembers();
setShowMemberInvitationModal(false);
};
const handleLogoChange = (newLogo: string) => {
logoRef.current.value = newLogo;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement?.prototype, "value").set;
nativeInputValueSetter?.call(logoRef.current, newLogo);
const ev2 = new Event("input", { bubbles: true });
logoRef?.current?.dispatchEvent(ev2);
updateTeamHandler(ev2);
setImageSrc(newLogo);
};
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<div className="mb-4">
<Button
type="button"
color="secondary"
size="sm"
StartIcon={ArrowLeftIcon}
onClick={() => props.onCloseEdit()}>
{t("back")}
</Button>
</div>
<div>
<div className="pb-5 pr-4 sm:pb-6">
<h3 className="text-lg font-bold leading-6 text-gray-900">{props.team?.name}</h3>
<div className="max-w-xl mt-2 text-sm text-gray-500">
<p>{t("manage_your_team")}</p>
</div>
</div>
</div>
<hr className="mt-2" />
<h3 className="font-bold leading-6 text-gray-900 font-cal mt-7 text-md">{t("profile")}</h3>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateTeamHandler}>
{hasErrors && <ErrorAlert message={errorMessage} />}
<div className="py-6 lg:pb-8">
<div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<div className="block sm:flex">
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
<UsernameInput
ref={teamUrlRef}
defaultValue={props.team?.slug}
label={t("my_team_url")}
/>
</div>
<div className="w-full sm:w-1/2 sm:ml-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("team_name")}
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder={t("your_team_name")}
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={props.team?.name}
/>
</div>
</div>
<div>
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
{t("about")}
</label>
<div className="mt-1">
<textarea
ref={descriptionRef}
id="about"
name="about"
rows={3}
defaultValue={props.team?.bio}
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"></textarea>
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
</div>
</div>
<div>
<div className="flex mt-1">
<Avatar
className="relative w-10 h-10 rounded-full"
imageSrc={imageSrc ? imageSrc : props.team?.logo}
displayName="Logo"
/>
<input
ref={logoRef}
type="hidden"
name="avatar"
id="avatar"
placeholder="URL"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={imageSrc ?? props.team?.logo}
/>
<ImageUploader
target="logo"
id="logo-upload"
buttonMsg={imageSrc !== "" ? t("edit_logo") : t("upload_a_logo")}
handleAvatarChange={handleLogoChange}
imageSrc={imageSrc ?? props.team?.logo}
/>
</div>
<hr className="mt-6" />
</div>
<div className="flex justify-between mt-7">
<h3 className="font-bold leading-6 text-gray-900 font-cal text-md">{t("members")}</h3>
<div className="relative flex items-center">
<Button
type="button"
color="secondary"
StartIcon={PlusIcon}
onClick={() => onInviteMember(props.team)}>
{t("new_member")}
</Button>
</div>
</div>
<div>
{!!members.length && (
<MemberList members={members} onRemoveMember={onRemoveMember} onChange={loadMembers} />
)}
<hr className="mt-6" />
</div>
<div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="hide-branding"
name="hide-branding"
type="checkbox"
ref={hideBrandingRef}
defaultChecked={props.team?.hideBranding}
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("disable_cal_branding")}
</label>
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
</div>
</div>
<hr className="mt-6" />
</div>
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">{t("danger_zone")}</h3>
<div>
<div className="relative flex items-start">
<Dialog>
<DialogTrigger asChild>
<Button type="button" color="secondary" StartIcon={TrashIcon}>
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => deleteTeam()}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</div>
</div>
<hr className="mt-8" />
<div className="flex justify-end py-4">
<Button type="submit" color="primary">
{t("save")}
</Button>
</div>
</div>
</form>
{showMemberInvitationModal && (
<MemberInvitationModal team={inviteModalTeam} onExit={onMemberInvitationModalExit} />
)}
</div>
</div>
);
}

View file

@ -0,0 +1,86 @@
import { MembershipRole } from "@prisma/client";
import { useState } from "react";
import React, { SyntheticEvent } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import Button from "@components/ui/Button";
import ModalContainer from "@components/ui/ModalContainer";
export default function MemberChangeRoleModal(props: {
memberId: number;
teamId: number;
initialRole: MembershipRole;
onExit: () => void;
}) {
const [role, setRole] = useState(props.initialRole || MembershipRole.MEMBER);
const [errorMessage, setErrorMessage] = useState("");
const { t } = useLocale();
const utils = trpc.useContext();
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
props.onExit();
},
async onError(err) {
setErrorMessage(err.message);
},
});
function changeRole(e: SyntheticEvent) {
e.preventDefault();
changeRoleMutation.mutate({
teamId: props.teamId,
memberId: props.memberId,
role,
});
}
return (
<ModalContainer>
<>
<div className="mb-4 sm:flex sm:items-start">
<div className="text-center sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("change_member_role")}
</h3>
</div>
</div>
<form onSubmit={changeRole}>
<div className="mb-4">
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
{t("role")}
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as MembershipRole)}
id="role"
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
<option value="MEMBER">{t("member")}</option>
<option value="ADMIN">{t("admin")}</option>
<option value="OWNER">{t("owner")}</option>
</select>
</div>
{errorMessage && (
<p className="text-sm text-red-700">
<span className="font-bold">Error: </span>
{errorMessage}
</p>
)}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" color="primary" className="ml-2">
{t("save")}
</Button>
<Button type="button" color="secondary" onClick={props.onExit}>
{t("cancel")}
</Button>
</div>
</form>
</>
</ModalContainer>
);
}

View file

@ -1,58 +1,49 @@
import { UsersIcon } from "@heroicons/react/outline";
import { UserIcon } from "@heroicons/react/outline";
import { MembershipRole } from "@prisma/client";
import { useState } from "react";
import React, { SyntheticEvent } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { Team } from "@lib/team";
import { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
import Button from "@components/ui/Button";
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
const [errorMessage, setErrorMessage] = useState("");
const { t, i18n } = useLocale();
const utils = trpc.useContext();
const handleError = async (res: Response) => {
const responseData = await res.json();
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
props.onExit();
},
async onError(err) {
setErrorMessage(err.message);
},
});
if (res.ok === false) {
setErrorMessage(responseData.message);
throw new Error(responseData.message);
}
return responseData;
};
const inviteMember = (e: SyntheticEvent) => {
function inviteMember(e: SyntheticEvent) {
e.preventDefault();
if (!props.team) return;
const target = e.target as typeof e.target & {
elements: {
role: { value: string };
role: { value: MembershipRole };
inviteUser: { value: string };
sendInviteEmail: { checked: boolean };
};
};
const payload = {
inviteMemberMutation.mutate({
teamId: props.team.id,
language: i18n.language,
role: target.elements["role"].value,
usernameOrEmail: target.elements["inviteUser"].value,
sendEmailInvitation: 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(() => {
// do nothing.
});
};
});
}
return (
<div
@ -71,8 +62,8 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-brand rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="w-6 h-6 text-black" />
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-brand bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<UserIcon className="w-6 h-6 text-white" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
@ -106,6 +97,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
id="role"
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
<option value="MEMBER">{t("member")}</option>
<option value="ADMIN">{t("admin")}</option>
<option value="OWNER">{t("owner")}</option>
</select>
</div>

View file

@ -1,30 +1,20 @@
import { Member } from "@lib/member";
import { inferQueryOutput } from "@lib/trpc";
import MemberListItem from "./MemberListItem";
export default function MemberList(props: {
members: Member[];
onRemoveMember: (text: Member) => void;
onChange: (text: string) => void;
}) {
const selectAction = (action: string, member: Member) => {
switch (action) {
case "remove":
props.onRemoveMember(member);
break;
}
};
interface Props {
team: inferQueryOutput<"viewer.teams.get">;
members: inferQueryOutput<"viewer.teams.get">["members"];
}
export default function MemberList(props: Props) {
if (!props.members.length) return <></>;
return (
<div>
<ul className="px-6 mb-2 -mx-6 bg-white border divide-y divide-gray-200 rounded sm:px-4 sm:mx-0">
{props.members.map((member) => (
<MemberListItem
onChange={props.onChange}
key={member.id}
member={member}
onActionSelect={(action: string) => selectAction(action, member)}
/>
{props.members?.map((member) => (
<MemberListItem key={member.id} member={member} team={props.team} />
))}
</ul>
</div>

View file

@ -1,104 +1,162 @@
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
import { useState } from "react";
import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
import { ClockIcon, ExternalLinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
import Link from "next/link";
import React, { useState } from "react";
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import { Member } from "@lib/member";
import showToast from "@lib/notification";
import { trpc, inferQueryOutput } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import ModalContainer from "@components/ui/ModalContainer";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
import MemberChangeRoleModal from "./MemberChangeRoleModal";
import TeamRole from "./TeamRole";
import { MembershipRole } from ".prisma/client";
export default function MemberListItem(props: {
member: Member;
onActionSelect: (text: string) => void;
onChange: (text: string) => void;
}) {
const [member] = useState(props.member);
interface Props {
team: inferQueryOutput<"viewer.teams.get">;
member: inferQueryOutput<"viewer.teams.get">["members"][number];
}
export default function MemberListItem(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
const name =
props.member.name ||
(() => {
const emailName = props.member.email.split("@")[0];
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
})();
const removeMember = () =>
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
return (
member && (
<li className="divide-y">
<div className="flex justify-between my-4">
<div className="flex flex-col justify-between w-full sm:flex-row">
<div className="flex">
<Avatar
imageSrc={
props.member.avatar
? props.member.avatar
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
encodeURIComponent(props.member.name || "")
}
alt={props.member.name || ""}
className="rounded-full w-9 h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{props.member.name}</span>
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
</div>
</div>
<div>
{props.member.role === "INVITEE" && (
<>
<span className="self-center h-6 px-3 py-1 mr-2 text-xs text-yellow-700 capitalize rounded-md bg-yellow-50">
{t("pending")}
</span>
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
{t("member")}
</span>
</>
)}
{props.member.role === "MEMBER" && (
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
{t("member")}
</span>
)}
{props.member.role === "OWNER" && (
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-blue-700 capitalize rounded-md bg-blue-50">
{t("owner")}
</span>
)}
<li className="divide-y">
<div className="flex justify-between my-4">
<div className="flex flex-col justify-between w-full sm:flex-row">
<div className="flex">
<Avatar
imageSrc={getPlaceholderAvatar(props.member?.avatar, name)}
alt={name || ""}
className="rounded-full w-9 h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{name}</span>
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
</div>
</div>
<div className="flex">
{/* <div className="flex flex-col-reverse"> */}
<Dropdown>
<DropdownMenuTrigger>
<DotsHorizontalIcon className="w-5 h-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={UserRemoveIcon}
className="w-full">
{t("remove_member")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={() => props.onActionSelect("remove")}>
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
{/* </div> */}
<div className="flex justify-center mr-2">
{!props.member.accepted && <TeamRole invitePending />}
<TeamRole role={props.member.role} />
</div>
</div>
</li>
)
<div className="flex">
<Tooltip content={t("View user availability")}>
<Button
onClick={() => setShowTeamAvailabilityModal(true)}
color="minimal"
className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
</Button>
</Tooltip>
<Dropdown>
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Link href={"/" + props.member.username}>
<a target="_blank">
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full">
{t("view_public_page")}
</Button>
</a>
</Link>
</DropdownMenuItem>
{(props.team.membership.role === MembershipRole.OWNER ||
props.team.membership.role === MembershipRole.ADMIN) && (
<>
<DropdownMenuItem>
<Button
onClick={() => setShowChangeMemberRoleModal(true)}
color="minimal"
StartIcon={PencilIcon}
className="flex-shrink-0 w-full">
{t("edit_role")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={UserRemoveIcon}
className="w-full">
{t("remove_member")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={removeMember}>
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</Dropdown>
</div>
</div>
{showChangeMemberRoleModal && (
<MemberChangeRoleModal
teamId={props.team?.id}
memberId={props.member.id}
initialRole={props.member.role as MembershipRole}
onExit={() => setShowChangeMemberRoleModal(false)}
/>
)}
{showTeamAvailabilityModal && (
<ModalContainer wide noPadding>
<TeamAvailabilityModal team={props.team} member={props.member} />
<div className="p-5 space-x-2 border-t">
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
<Link href={`/settings/teams/${props.team.id}/availability`}>
<Button color="secondary">{t("Open Team Availability")}</Button>
</Link>
</div>
</ModalContainer>
)}
</li>
);
}

View file

@ -0,0 +1,86 @@
import { UsersIcon } from "@heroicons/react/outline";
import { useRef } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
interface Props {
onClose: () => void;
}
export default function TeamCreate(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
onSuccess: () => {
utils.invalidateQueries(["viewer.teams.list"]);
props.onClose();
},
});
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
createTeamMutation.mutate({ name: nameRef?.current?.value });
};
return (
<div
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-sm shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="w-6 h-6 text-neutral-900" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("create_new_team")}
</h3>
<div>
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
</div>
</div>
</div>
<form onSubmit={createTeam}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("name")}
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder="Acme Inc."
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-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">
{t("create_team")}
</button>
<button onClick={props.onClose} type="button" className="mr-2 btn btn-white">
{t("cancel")}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View file

@ -1,39 +1,44 @@
import { Team } from "@lib/team";
import showToast from "@lib/notification";
import { trpc, inferQueryOutput } from "@lib/trpc";
import TeamListItem from "./TeamListItem";
export default function TeamList(props: {
teams: Team[];
onChange: () => void;
onEditTeam: (text: Team) => void;
}) {
const selectAction = (action: string, team: Team) => {
interface Props {
teams: inferQueryOutput<"viewer.teams.list">;
}
export default function TeamList(props: Props) {
const utils = trpc.useContext();
function selectAction(action: string, teamId: number) {
switch (action) {
case "edit":
props.onEditTeam(team);
break;
case "disband":
deleteTeam(team);
deleteTeam(teamId);
break;
}
};
}
const deleteTeam = async (team: Team) => {
await fetch("/api/teams/" + team.id, {
method: "DELETE",
});
return props.onChange();
};
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.list"]);
},
async onError(err) {
showToast(err.message, "error");
},
});
function deleteTeam(teamId: number) {
deleteTeamMutation.mutate({ teamId });
}
return (
<div>
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{props.teams.map((team: Team) => (
<ul className="mb-2 bg-white border divide-y rounded divide-neutral-200">
{props.teams.map((team) => (
<TeamListItem
onChange={props.onChange}
key={team.id}
key={team?.id as number}
team={team}
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
onActionSelect={(action: string) => selectAction(action, team?.id as number)}></TeamListItem>
))}
</ul>
</div>

View file

@ -1,137 +1,137 @@
import {
DotsHorizontalIcon,
ExternalLinkIcon,
LinkIcon,
PencilAltIcon,
TrashIcon,
} from "@heroicons/react/outline";
import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline";
import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
import Link from "next/link";
import { useState } from "react";
import classNames from "@lib/classNames";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc, inferQueryOutput } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/ui/Dropdown";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
import TeamRole from "./TeamRole";
import { MembershipRole } from ".prisma/client";
interface Team {
id: number;
name: string | null;
slug: string | null;
logo: string | null;
bio: string | null;
role: string | null;
hideBranding: boolean;
prevState: null;
interface Props {
team: inferQueryOutput<"viewer.teams.list">[number];
key: number;
onActionSelect: (text: string) => void;
}
export default function TeamListItem(props: {
onChange: () => void;
key: number;
team: Team;
onActionSelect: (text: string) => void;
}) {
const [team, setTeam] = useState<Team | null>(props.team);
export default function TeamListItem(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const team = 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();
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
onSuccess: () => {
utils.invalidateQueries(["viewer.teams.list"]);
},
});
function acceptOrLeave(accept: boolean) {
acceptOrLeaveMutation.mutate({
teamId: team?.id as number,
accept,
});
}
const acceptInvite = () => acceptOrLeave(true);
const declineInvite = () => acceptOrLeave(false);
const isOwner = props.team.role === MembershipRole.OWNER;
const isInvitee = !props.team.accepted;
const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
if (!team) return <></>;
const teamInfo = (
<div className="flex px-5 py-5">
<Avatar
size={9}
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
alt="Team Logo"
className="rounded-full w-9 h-9 min-w-9 min-h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
<span className="block text-xs text-gray-400">
{process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug}
</span>
</div>
</div>
);
return (
team && (
<li className="divide-y">
<div className="flex justify-between my-4">
<div className="flex">
<Avatar
size={9}
imageSrc={
props.team.logo
? props.team.logo
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
encodeURIComponent(props.team.name || "")
}
alt="Team Logo"
className="rounded-full w-9 h-9"
/>
<div className="inline-block ml-3">
<span className="text-sm font-bold text-neutral-700">{props.team.name}</span>
<span className="block -mt-1 text-xs text-gray-400">
{process.env.NEXT_PUBLIC_APP_URL}/team/{props.team.slug}
</span>
</div>
</div>
{props.team.role === "INVITEE" && (
<div>
<li className="divide-y">
<div
className={classNames(
"flex justify-between items-center",
!isInvitee && "group hover:bg-neutral-50"
)}>
{!isInvitee ? (
<Link href={"/settings/teams/" + team.id}>
<a className="flex-grow text-sm truncate cursor-pointer" title={`${team.name}`}>
{teamInfo}
</a>
</Link>
) : (
teamInfo
)}
<div className="px-5 py-5">
{isInvitee && (
<>
<Button type="button" color="secondary" onClick={declineInvite}>
{t("reject")}
</Button>
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
<Button type="button" color="primary" className="ml-2" onClick={acceptInvite}>
{t("accept")}
</Button>
</div>
</>
)}
{props.team.role === "MEMBER" && (
<div>
<Button type="button" color="primary" onClick={declineInvite}>
{t("leave")}
</Button>
</div>
)}
{props.team.role === "OWNER" && (
<div className="flex space-x-4">
<span className="self-center h-6 px-3 py-1 text-xs text-gray-700 capitalize rounded-md bg-gray-50">
{t("owner")}
</span>
<Tooltip content={t("copy_link")}>
{!isInvitee && (
<div className="flex space-x-2">
<TeamRole role={team.role as MembershipRole} />
<Tooltip content={t("copy_link_team")}>
<Button
onClick={() => {
navigator.clipboard.writeText(
process.env.NEXT_PUBLIC_APP_URL + "/team/" + props.team.slug
);
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_APP_URL + "/team/" + team.slug);
showToast(t("link_copied"), "success");
}}
className="w-10 h-10 transition-none"
size="icon"
color="minimal"
StartIcon={LinkIcon}
type="button"
/>
type="button">
<LinkIcon className="w-5 h-5 group-hover:text-gray-600" />
</Button>
</Tooltip>
<Dropdown>
<DropdownMenuTrigger className="group w-10 h-10 p-0 border border-transparent text-neutral-400 hover:border-gray-200">
<DotsHorizontalIcon className="w-5 h-5" />
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 ">
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{isAdmin && (
<DropdownMenuItem>
<Link href={"/settings/teams/" + team.id}>
<a>
<Button type="button" color="minimal" className="w-full" StartIcon={PencilIcon}>
{t("edit_team")}
</Button>
</a>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<Button
type="button"
color="minimal"
className="w-full"
onClick={() => props.onActionSelect("edit")}
StartIcon={PencilAltIcon}>
{" "}
{t("edit_team")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team.slug}`} passHref={true}>
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
<a target="_blank">
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
{" "}
@ -140,34 +140,61 @@ export default function TeamListItem(props: {
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={TrashIcon}
className="w-full">
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => props.onActionSelect("disband")}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
{isOwner && (
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="warn"
StartIcon={TrashIcon}
className="w-full">
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={() => props.onActionSelect("disband")}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
)}
{!isOwner && (
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
color="warn"
StartIcon={LogoutIcon}
className="w-full"
onClick={(e) => {
e.stopPropagation();
}}>
{t("leave_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("leave_team")}
confirmBtnText={t("confirm_leave_team")}
onConfirm={declineInvite}>
{t("leave_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
</li>
)
</div>
</li>
);
}

View file

@ -0,0 +1,37 @@
import { MembershipRole } from "@prisma/client";
import classNames from "classnames";
import { useLocale } from "@lib/hooks/useLocale";
interface Props {
role?: MembershipRole;
invitePending?: boolean;
}
export default function TeamRole(props: Props) {
const { t } = useLocale();
return (
<span
className={classNames("self-center px-3 py-1 mr-2 text-xs capitalize border rounded-md", {
"bg-blue-50 border-blue-200 text-blue-700": props.role === "MEMBER",
"bg-gray-50 border-gray-200 text-gray-700": props.role === "OWNER",
"bg-red-50 border-red-200 text-red-700": props.role === "ADMIN",
"bg-yellow-50 border-yellow-200 text-yellow-700": props.invitePending,
})}>
{(() => {
if (props.invitePending) return t("invitee");
switch (props.role) {
case "OWNER":
return t("owner");
case "ADMIN":
return t("admin");
case "MEMBER":
return t("member");
default:
return "";
}
})()}
</span>
);
}

View file

@ -0,0 +1,210 @@
import { HashtagIcon, InformationCircleIcon, LinkIcon, PhotographIcon } from "@heroicons/react/solid";
import React, { useRef, useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
import ImageUploader from "@components/ImageUploader";
import { TextField } from "@components/form/fields";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
import SettingInputContainer from "@components/ui/SettingInputContainer";
interface Props {
team: TeamWithMembers | null | undefined;
}
export default function TeamSettings(props: Props) {
const { t } = useLocale();
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const team = props.team;
const hasLogo = !!team?.logo;
const utils = trpc.useContext();
const mutation = trpc.useMutation("viewer.teams.update", {
onError: (err) => {
setHasErrors(true);
setErrorMessage(err.message);
},
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("your_team_updated_successfully"), "success");
setHasErrors(false);
},
});
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
function updateTeamData() {
if (!team) return;
const variables = {
name: nameRef.current?.value,
slug: teamUrlRef.current?.value,
bio: descriptionRef.current?.value,
hideBranding: hideBrandingRef.current?.checked,
};
// remove unchanged variables
for (const key in variables) {
//@ts-expect-error will fix types
if (variables[key] === team?.[key]) delete variables[key];
}
mutation.mutate({ id: team.id, ...variables });
}
function updateLogo(newLogo: string) {
if (!team) return;
logoRef.current.value = newLogo;
mutation.mutate({ id: team.id, logo: newLogo });
}
const removeLogo = () => updateLogo("");
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="">
{hasErrors && <Alert severity="error" title={errorMessage} />}
<form
className="divide-y divide-gray-200 lg:col-span-9"
onSubmit={(e) => {
e.preventDefault();
updateTeamData();
}}>
<div className="py-6">
<div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<SettingInputContainer
Icon={LinkIcon}
label="Team URL"
htmlFor="team-url"
Input={
<TextField
name="team-url"
id="team-url"
addOnLeading={
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
</span>
}
ref={teamUrlRef}
defaultValue={team?.slug as string}
/>
}
/>
<SettingInputContainer
Icon={HashtagIcon}
label="Team Name"
htmlFor="name"
Input={
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder={t("your_team_name")}
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
defaultValue={team?.name as string}
/>
}
/>
<hr />
<div>
<SettingInputContainer
Icon={InformationCircleIcon}
label={t("about")}
htmlFor="about"
Input={
<>
<textarea
ref={descriptionRef}
id="about"
name="about"
rows={3}
defaultValue={team?.bio as string}
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"></textarea>
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
</>
}
/>
</div>
<div>
<SettingInputContainer
Icon={PhotographIcon}
label={"Logo"}
htmlFor="avatar"
Input={
<>
<div className="flex mt-1">
<input
ref={logoRef}
type="hidden"
name="avatar"
id="avatar"
placeholder="URL"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
defaultValue={team?.logo ?? undefined}
/>
<ImageUploader
target="logo"
id="logo-upload"
buttonMsg={hasLogo ? t("edit_logo") : t("upload_a_logo")}
handleAvatarChange={updateLogo}
imageSrc={team?.logo ?? undefined}
/>
{hasLogo && (
<Button
onClick={removeLogo}
color="secondary"
type="button"
className="py-1 ml-1 text-xs">
{t("remove_logo")}
</Button>
)}
</div>
</>
}
/>
<hr className="mt-6" />
</div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="hide-branding"
name="hide-branding"
type="checkbox"
ref={hideBrandingRef}
defaultChecked={team?.hideBranding}
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("disable_cal_branding")}
</label>
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-end py-4">
<Button type="submit" color="primary">
{t("save")}
</Button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,123 @@
import { ClockIcon, ExternalLinkIcon, LinkIcon, LogoutIcon, TrashIcon } from "@heroicons/react/solid";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import LinkIconButton from "@components/ui/LinkIconButton";
import { MembershipRole } from ".prisma/client";
// import Switch from "@components/ui/Switch";
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
const { t } = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team?.slug}`;
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("your_team_updated_successfully"), "success");
},
});
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
onSuccess: () => {
utils.invalidateQueries(["viewer.teams.list"]);
router.push(`/settings/teams`);
},
});
function deleteTeam() {
if (props.team?.id) deleteTeamMutation.mutate({ teamId: props.team.id });
}
function leaveTeam() {
if (props.team?.id)
acceptOrLeaveMutation.mutate({
teamId: props.team.id,
accept: false,
});
}
return (
<div className="px-2 space-y-6">
{/* <Switch
name="isHidden"
defaultChecked={hidden}
onCheckedChange={setHidden}
label={"Hide team from view"}
/> */}
<div className="space-y-1">
<Link href={permalink} passHref={true}>
<a target="_blank">
<LinkIconButton Icon={ExternalLinkIcon}>{t("preview")}</LinkIconButton>
</a>
</Link>
<LinkIconButton
Icon={LinkIcon}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Copied to clipboard", "success");
}}>
{t("copy_link_team")}
</LinkIconButton>
{props.role === "OWNER" ? (
<Dialog>
<DialogTrigger asChild>
<LinkIconButton
onClick={(e) => {
e.stopPropagation();
}}
Icon={TrashIcon}>
{t("disband_team")}
</LinkIconButton>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={deleteTeam}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
) : (
<Dialog>
<DialogTrigger asChild>
<LinkIconButton
Icon={LogoutIcon}
onClick={(e) => {
e.stopPropagation();
}}>
{t("leave_team")}
</LinkIconButton>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("leave_team")}
confirmBtnText={t("confirm_leave_team")}
onConfirm={leaveTeam}>
{t("leave_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
)}
</div>
{/* TODO: Team availability */}
{props.team?.id && (
<Link href={`/settings/teams/${props.team.id}/availability`}>
<div className="mt-5 space-y-1">
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
<p className="mt-2 text-sm text-gray-500">See your team members availability at a glance.</p>
</div>
</Link>
)}
</div>
);
}

View file

@ -2,6 +2,7 @@ import { ArrowRightIcon } from "@heroicons/react/outline";
import { ArrowLeftIcon } from "@heroicons/react/solid";
import classnames from "classnames";
import Link from "next/link";
import { TeamPageProps } from "pages/team/[slug]";
import React from "react";
import { useLocale } from "@lib/hooks/useLocale";
@ -10,10 +11,14 @@ import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import Text from "@components/ui/Text";
const Team = ({ team }) => {
type TeamType = TeamPageProps["team"];
type MembersType = TeamType["members"];
type MemberType = MembersType[number];
const Team = ({ team }: TeamPageProps) => {
const { t } = useLocale();
const Member = ({ member }) => {
const Member = ({ member }: { member: MemberType }) => {
const classes = classnames(
"group",
"relative",
@ -29,7 +34,7 @@ const Team = ({ team }) => {
);
return (
<Link key={member.id} href={`/${member.user.username}`}>
<Link key={member.id} href={`/${member.username}`}>
<div className={classes}>
<ArrowRightIcon
className={classnames(
@ -42,11 +47,11 @@ const Team = ({ team }) => {
/>
<div>
<Avatar displayName={member.user.name} imageSrc={member.user.avatar} className="w-12 h-12" />
<Avatar alt={member.name || ""} imageSrc={member.avatar} className="w-12 h-12" />
<section className="space-y-2">
<Text variant="title">{member.user.name}</Text>
<Text variant="subtitle" className="w-6/8 max-w-md">
{member.user.bio}
<Text variant="title">{member.name}</Text>
<Text variant="subtitle" className="w-6/8">
{member.bio}
</Text>
</section>
</div>
@ -55,15 +60,15 @@ const Team = ({ team }) => {
);
};
const Members = ({ members }) => {
const Members = ({ members }: { members: MembersType }) => {
if (!members || members.length === 0) {
return null;
}
return (
<section className="mx-auto min-w-full lg:min-w-lg max-w-5xl flex flex-wrap gap-x-12 gap-y-6 justify-center">
<section className="flex flex-wrap justify-center max-w-5xl min-w-full mx-auto lg:min-w-lg gap-x-12 gap-y-6">
{members.map((member) => {
return member.user.username !== null && <Member key={member.id} member={member} />;
return member.username !== null && <Member key={member.id} member={member} />;
})}
</section>
);
@ -73,7 +78,7 @@ const Team = ({ team }) => {
<div>
<Members members={team.members} />
{team.eventTypes.length > 0 && (
<aside className="text-center dark:text-white mt-8">
<aside className="mt-8 text-center dark:text-white">
<Button color="secondary" href={`/team/${team.slug}`} shallow={true} StartIcon={ArrowLeftIcon}>
{t("go_back")}
</Button>

View file

@ -90,12 +90,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
},
<>
{StartIcon && (
<StartIcon
className={classNames(
"inline",
size === "icon" ? "w-5 h-5 group-hover:text-black" : "w-5 h-5 mr-2 -ml-1"
)}
/>
<StartIcon className={classNames("inline", size === "icon" ? "w-5 h-5 " : "w-5 h-5 mr-2 -ml-1")} />
)}
{props.children}
{loading && (

View file

@ -0,0 +1,21 @@
import React from "react";
import { SVGComponent } from "@lib/types/SVGComponent";
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
Icon: SVGComponent;
}
export default function LinkIconButton(props: LinkIconButtonProps) {
return (
<div className="-ml-2">
<button
{...props}
type="button"
className="flex items-center px-2 py-1 text-sm font-medium text-gray-700 rounded-sm text-md hover:text-gray-900 hover:bg-gray-200">
<props.Icon className="w-4 h-4 mr-2 text-neutral-500" />
{props.children}
</button>
</div>
);
}

View file

@ -0,0 +1,39 @@
import classNames from "classnames";
import React from "react";
interface Props extends React.PropsWithChildren<any> {
wide?: boolean;
scroll?: boolean;
noPadding?: boolean;
}
export default function ModalContainer(props: Props) {
return (
<div
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div
className={classNames(
"inline-block min-w-96 px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:p-6",
{
"sm:max-w-lg sm:w-full ": !props.wide,
"sm:max-w-4xl sm:w-4xl": props.wide,
"overflow-scroll": props.scroll,
"!p-0": props.noPadding,
}
)}>
{props.children}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,25 @@
export default function SettingInputContainer({
Input,
Icon,
label,
htmlFor,
}: {
Input: React.ReactNode;
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element;
label: string;
htmlFor?: string;
}) {
return (
<div className="space-y-3">
<div className="block sm:flex">
<div className="mb-4 min-w-48 sm:mb-0">
<label htmlFor={htmlFor} className="flex mt-1 text-sm font-medium text-neutral-700">
<Icon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
{label}
</label>
</div>
<div className="flex-grow w-full">{Input}</div>
</div>
</div>
);
}

View file

@ -1,36 +0,0 @@
import React from "react";
interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> {
label?: string;
}
/**
* @deprecated Use <TextField addOnLeading={}> to achieve the same effect.
*/
const UsernameInput = React.forwardRef<HTMLInputElement, UsernameInputProps>((props, ref) => (
// todo, check if username is already taken here?
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
{props.label ? props.label : "Username"}
</label>
<div className="flex mt-1 rounded-md shadow-sm">
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{process.env.NEXT_PUBLIC_APP_URL}/{props.label && "team/"}
</span>
<input
ref={ref}
type="text"
name="username"
id="username"
autoComplete="username"
required
{...props}
className="flex-grow block w-full min-w-0 lowercase border-gray-300 rounded-none rounded-r-sm focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
</div>
));
UsernameInput.displayName = "UsernameInput";
export { UsernameInput };

View file

@ -0,0 +1,28 @@
import { CalendarIcon } from "@heroicons/react/solid";
import React from "react";
import "react-calendar/dist/Calendar.css";
import "react-date-picker/dist/DatePicker.css";
import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle";
import classNames from "@lib/classNames";
type Props = {
date: Date;
onDatesChange?: ((date: Date) => void) | undefined;
className?: string;
};
export const DatePicker = ({ date, onDatesChange, className }: Props) => {
return (
<PrimitiveDatePicker
className={classNames(
"p-1 pl-2 border border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
className
)}
clearIcon={null}
calendarIcon={<CalendarIcon className="w-5 h-5 text-gray-500" />}
value={date}
onChange={onDatesChange}
/>
);
};

View file

@ -1,24 +1,30 @@
import classNames from "classnames";
import React, { forwardRef, InputHTMLAttributes, ReactNode } from "react";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label: ReactNode;
label?: ReactNode;
};
const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, ref) => {
return (
<div className="block sm:flex">
<div className="mb-4 min-w-48 sm:mb-0">
<label htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
{label}
</label>
</div>
{!!label && (
<div className="mb-4 min-w-48 sm:mb-0">
<label htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
{label}
</label>
</div>
)}
<div className="w-full">
<div className="relative rounded-sm shadow-sm">
<input
{...rest}
ref={ref}
type="number"
className="block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
className={classNames(
"block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
rest.className
)}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<span className="text-gray-500 sm:text-sm" id="duration">

View file

@ -0,0 +1,92 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import React, { useState, useEffect } from "react";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { trpc, inferQueryOutput } from "@lib/trpc";
import Avatar from "@components/ui/Avatar";
import { DatePicker } from "@components/ui/form/DatePicker";
import MinutesField from "@components/ui/form/MinutesField";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
dayjs.extend(utc);
interface Props {
team?: inferQueryOutput<"viewer.teams.get">;
member?: inferQueryOutput<"viewer.teams.get">["members"][number];
}
export default function TeamAvailabilityModal(props: Props) {
const utils = trpc.useContext();
const [selectedDate, setSelectedDate] = useState(dayjs());
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(dayjs.tz.guess);
const [frequency, setFrequency] = useState<number>(30);
useEffect(() => {
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
}, [utils, selectedTimeZone, selectedDate]);
return (
<div className="flex flex-row max-h-[500px] min-h-[500px] space-x-8">
<div className="w-64 p-5 pr-0 space-y-5 min-w-64">
<div className="flex">
<Avatar
imageSrc={getPlaceholderAvatar(props.member?.avatar, props.member?.name as string)}
alt={props.member?.name || ""}
className="rounded-full w-14 h-14"
/>
<div className="inline-block pt-1 ml-3">
<span className="text-lg font-bold text-neutral-700">{props.member?.name}</span>
<span className="block -mt-1 text-sm text-gray-400">{props.member?.email}</span>
</div>
</div>
<div className="flex flex-col">
<span className="font-bold text-gray-600">Date</span>
<DatePicker
date={selectedDate.toDate()}
onDatesChange={(newDate) => {
setSelectedDate(dayjs(newDate));
}}
/>
</div>
<div>
<span className="font-bold text-gray-600">Timezone</span>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
classNamePrefix="react-select"
className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
/>
</div>
<div>
<span className="font-bold text-gray-600">Slot Length</span>
<MinutesField
id="length"
label=""
required
min="10"
placeholder="15"
defaultValue={frequency}
onChange={(e) => {
setFrequency(Number(e.target.value));
}}
/>
</div>
</div>
{props.team && props.member && (
<TeamAvailabilityTimes
className="overflow-scroll"
team={props.team}
member={props.member}
frequency={frequency}
selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone}
/>
)}
</div>
);
}

View file

@ -0,0 +1,120 @@
import dayjs from "dayjs";
import React, { useState, useEffect, CSSProperties } from "react";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { trpc, inferQueryOutput } from "@lib/trpc";
import Avatar from "@components/ui/Avatar";
import { DatePicker } from "@components/ui/form/DatePicker";
import MinutesField from "@components/ui/form/MinutesField";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
interface Props {
team?: inferQueryOutput<"viewer.teams.get">;
members?: inferQueryOutput<"viewer.teams.get">["members"];
}
// type Member = inferQueryOutput<"viewer.teams.get">["members"][number];
export default function TeamAvailabilityScreen(props: Props) {
const utils = trpc.useContext();
const [selectedDate, setSelectedDate] = useState(dayjs());
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(dayjs.tz.guess());
const [frequency, setFrequency] = useState<number>(30);
useEffect(() => {
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTimeZone, selectedDate]);
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
const member = props.members?.[index];
if (!member) return <></>;
return (
<div key={member.id} style={style} className="flex pl-4 border-r border-gray-200 ">
<TeamAvailabilityTimes
team={props.team as inferQueryOutput<"viewer.teams.get">}
member={member}
frequency={frequency}
selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone}
HeaderComponent={
<div className="flex items-center mb-6">
<Avatar
imageSrc={getPlaceholderAvatar(member?.avatar, member?.name as string)}
alt={member?.name || ""}
className="w-10 h-10 mt-1 rounded-full min-w-10 min-h-10"
/>
<div className="inline-block pt-1 ml-3 overflow-hidden">
<span className="text-lg font-bold truncate text-neutral-700">{member?.name}</span>
<span className="block -mt-1 text-sm text-gray-400 truncate">{member?.email}</span>
</div>
</div>
}
/>
</div>
);
};
return (
<div className="flex flex-col flex-1 bg-white border rounded-sm border-neutral-200">
<div className="flex w-full p-5 pr-0 space-x-5 border-b border-gray-200">
<div className="flex flex-col">
<span className="font-bold text-gray-600">Date</span>
<DatePicker
date={selectedDate.toDate()}
className="p-1.5"
onDatesChange={(newDate) => {
setSelectedDate(dayjs(newDate));
}}
/>
</div>
<div className="flex flex-col">
<span className="font-bold text-gray-600">Timezone</span>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
classNamePrefix="react-select"
className="w-full border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
/>
</div>
<div>
<span className="font-bold text-gray-600">Slot Length</span>
<MinutesField
id="length"
label=""
required
min="10"
className="p-2.5"
placeholder="15"
defaultValue={frequency}
onChange={(e) => {
setFrequency(Number(e.target.value));
}}
/>
</div>
</div>
<div className="flex flex-1 h-full">
<AutoSizer>
{({ height, width }) => (
<List
itemSize={240}
itemCount={props.members?.length || 0}
className="List"
height={height}
direction="horizontal"
width={width}>
{Item}
</List>
)}
</AutoSizer>
</div>
</div>
);
}

View file

@ -0,0 +1,70 @@
import classNames from "classnames";
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
import React from "react";
import { ITimezone } from "react-timezone-select";
import getSlots from "@lib/slots";
import { inferQueryOutput, trpc } from "@lib/trpc";
import Loader from "@components/Loader";
interface Props {
team: inferQueryOutput<"viewer.teams.get">;
member: inferQueryOutput<"viewer.teams.get">["members"][number];
selectedDate: Dayjs;
selectedTimeZone: ITimezone;
frequency: number;
HeaderComponent?: React.ReactNode;
className?: string;
}
dayjs.extend(utc);
export default function TeamAvailabilityTimes(props: Props) {
const { data, isLoading } = trpc.useQuery(
[
"viewer.teams.getMemberAvailability",
{
teamId: props.team.id,
memberId: props.member.id,
dateFrom: props.selectedDate.toString(),
dateTo: props.selectedDate.add(1, "day").toString(),
timezone: `${props.selectedTimeZone.toString()}`,
},
],
{
refetchOnWindowFocus: false,
}
);
const times = !isLoading
? getSlots({
frequency: props.frequency,
inviteeDate: props.selectedDate,
workingHours: data?.workingHours || [],
minimumBookingNotice: 0,
})
: [];
return (
<div className={classNames("flex-grow p-5 pl-0 min-w-60", props.className)}>
{props.HeaderComponent}
{isLoading && times.length === 0 && <Loader />}
{!isLoading && times.length === 0 && (
<div className="flex flex-col items-center justify-center pt-4">
<span className="text-sm text-gray-500">No Available Slots</span>
</div>
)}
{times.map((time) => (
<div key={time.format()} className="flex flex-row items-center">
<a
className="flex-grow block py-2 mb-2 mr-3 font-medium text-center bg-white border rounded-sm min-w-48 dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border-brand dark:border-transparent hover:text-white hover:bg-brand dark:hover:border-black dark:hover:bg-black"
data-testid="time">
{time.format("HH:mm")}
</a>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,49 @@
import { useRouter } from "next/router";
import { useState } from "react";
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
import { Alert } from "@components/ui/Alert";
import Avatar from "@components/ui/Avatar";
export function TeamSettingsPage() {
const router = useRouter();
const [errorMessage, setErrorMessage] = useState("");
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
refetchOnWindowFocus: false,
onError: (e) => {
setErrorMessage(e.message);
},
});
return (
<Shell
showBackButton={!errorMessage}
heading={team?.name}
flexChildrenContainer
subtitle={team && "Your team's availability at a glance"}
HeadingLeftIcon={
team && (
<Avatar
size={12}
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
alt="Team Logo"
className="mt-1"
/>
)
}>
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
{isLoading && <Loader />}
{team && <TeamAvailabilityScreen team={team} members={team.members} />}
</Shell>
);
}
export default TeamSettingsPage;

View file

@ -0,0 +1,6 @@
export function getPlaceholderAvatar(avatar: string | null | undefined, name: string | null) {
return avatar
? avatar
: "https://eu.ui-avatars.com/api/?background=fff&color=f9f9f9&bold=true&background=000000&name=" +
encodeURIComponent(name || "");
}

View file

@ -0,0 +1,88 @@
// import { getBusyVideoTimes } from "@lib/videoClient";
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import { getBusyCalendarTimes } from "@lib/calendarClient";
import prisma from "@lib/prisma";
export async function getUserAvailability(query: {
username: string;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
timezone?: string;
}) {
const username = asStringOrNull(query.username);
const dateFrom = dayjs(asStringOrNull(query.dateFrom));
const dateTo = dayjs(asStringOrNull(query.dateTo));
if (!username) throw new Error("Missing username");
if (!dateFrom.isValid() || !dateTo.isValid()) throw new Error("Invalid time range given.");
const rawUser = await prisma.user.findUnique({
where: {
username: username,
},
select: {
credentials: true,
timeZone: true,
bufferTime: true,
availability: true,
id: true,
startTime: true,
endTime: true,
selectedCalendars: true,
},
});
const getEventType = (id: number) =>
prisma.eventType.findUnique({
where: { id },
select: {
timeZone: true,
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
},
});
type EventType = Prisma.PromiseReturnType<typeof getEventType>;
let eventType: EventType | null = null;
if (query.eventTypeId) eventType = await getEventType(query.eventTypeId);
if (!rawUser) throw new Error("No user found");
const { selectedCalendars, ...currentUser } = rawUser;
const busyTimes = await getBusyCalendarTimes(
currentUser.credentials,
dateFrom.format(),
dateTo.format(),
selectedCalendars
);
// busyTimes.push(...await getBusyVideoTimes(currentUser.credentials, dateFrom.format(), dateTo.format()));
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
const timeZone = query.timezone || eventType?.timeZone || currentUser.timeZone;
const workingHours = getWorkingHours(
{ timeZone },
eventType?.availability.length ? eventType.availability : currentUser.availability
);
return {
busy: bufferedBusyTimes,
timeZone,
workingHours,
};
}

View file

@ -0,0 +1,99 @@
import { Prisma } from "@prisma/client";
import prisma from "@lib/prisma";
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (...args: any) => Promise<infer R>
? R
: any;
export type TeamWithMembers = AsyncReturnType<typeof getTeamWithMembers>;
export async function getTeamWithMembers(id?: number, slug?: string) {
const userSelect = Prisma.validator<Prisma.UserSelect>()({
username: true,
avatar: true,
email: true,
name: true,
id: true,
bio: true,
});
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
id: true,
name: true,
slug: true,
logo: true,
bio: true,
hideBranding: true,
members: {
select: {
user: {
select: userSelect,
},
},
},
eventTypes: {
where: {
hidden: false,
},
select: {
id: true,
title: true,
description: true,
length: true,
slug: true,
schedulingType: true,
price: true,
currency: true,
users: {
select: userSelect,
},
},
},
});
const team = await prisma.team.findUnique({
where: id ? { id } : { slug },
select: teamSelect,
});
if (!team) return null;
const memberships = await prisma.membership.findMany({
where: {
teamId: team.id,
},
});
const members = team.members.map((obj) => {
const membership = memberships.find((membership) => obj.user.id === membership.userId);
return {
...obj.user,
role: membership?.role,
accepted: membership?.role === "OWNER" ? true : membership?.accepted,
};
});
return { ...team, members };
}
// also returns team
export async function isTeamAdmin(userId: number, teamId: number) {
return (
(await prisma.membership.findFirst({
where: {
userId,
teamId,
OR: [{ role: "ADMIN" }, { role: "OWNER" }],
},
})) || false
);
}
export async function isTeamOwner(userId: number, teamId: number) {
return !!(await prisma.membership.findFirst({
where: {
userId,
teamId,
role: "OWNER",
},
}));
}

View file

@ -1,10 +0,0 @@
export interface Team {
id: number;
name: string | null;
slug: string | null;
logo: string | null;
bio: string | null;
role: string | null;
hideBranding: boolean;
prevState: null;
}

View file

@ -81,6 +81,7 @@
"otplib": "^12.0.1",
"qrcode": "^1.5.0",
"react": "^17.0.2",
"react-date-picker": "^8.3.6",
"react-dom": "^17.0.2",
"react-easy-crop": "^3.5.2",
"react-hook-form": "^7.20.2",
@ -93,6 +94,8 @@
"react-select": "^5.2.1",
"react-timezone-select": "^1.1.15",
"react-use-intercom": "1.4.0",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6",
"short-uuid": "^4.2.0",
"stripe": "^8.191.0",
"superjson": "1.8.0",
@ -115,6 +118,8 @@
"@types/qrcode": "^1.4.1",
"@types/react": "^17.0.37",
"@types/react-phone-number-input": "^3.0.13",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1",
"@typescript-eslint/eslint-plugin": "^4.33.0",

View file

@ -1,14 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import slugify from "@lib/slugify";
import prisma from "../../lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
}
@ -23,9 +22,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
if (nameCollisions > 0) {
return res
.status(409)
.json({ errorCode: "TeamNameCollision", message: "Team username already taken." });
return res.status(409).json({ errorCode: "TeamNameCollision", message: "Team name already taken." });
}
const createTeam = await prisma.team.create({

View file

@ -2,24 +2,35 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import { getTeamWithMembers } from "@lib/queries/teams";
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 (!session.user?.id) {
console.log("Received session token without a user id.");
return res.status(500).json({ message: "Something went wrong." });
}
if (!req.query.team) {
console.log("Missing team query param.");
return res.status(500).json({ message: "Something went wrong." });
}
const teamId = parseInt(req.query.team as string);
// GET /api/teams/{team}
if (req.method === "GET") {
const team = await getTeamWithMembers(teamId);
return res.status(200).json({ team });
}
// DELETE /api/teams/{team}
if (req.method === "DELETE") {
if (!session.user?.id) {
console.log("Received session token without a user id.");
return res.status(500).json({ message: "Something went wrong." });
}
const membership = await prisma.membership.findFirst({
where: {
userId: session.user.id,
teamId: parseInt(req.query.team as string),
teamId,
},
});
@ -30,12 +41,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await prisma.membership.delete({
where: {
userId_teamId: { userId: session.user.id, teamId: parseInt(req.query.team) },
userId_teamId: { userId: session.user.id, teamId },
},
});
await prisma.team.delete({
where: {
id: parseInt(req.query.team),
id: teamId,
},
});
return res.status(204).send(null);

View file

@ -1,8 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "../../../../lib/prisma";
import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
@ -14,7 +13,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const isTeamOwner = !!(await prisma.membership.findFirst({
where: {
userId: session.user.id,
userId: session.user?.id,
teamId: parseInt(req.query.team as string),
role: "OWNER",
},
@ -54,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const membership = memberships.find((membership) => member.id === membership.userId);
return {
...member,
role: membership.accepted ? membership.role : "INVITEE",
role: membership?.accepted ? membership?.role : "INVITEE",
};
});
@ -65,7 +64,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (req.method === "DELETE") {
await prisma.membership.delete({
where: {
userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team) },
userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team as string) },
},
});
return res.status(204).send(null);

View file

@ -1,11 +1,12 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/client";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
// @deprecated - USE TRPC
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
const session = await getSession({ req });
if (!session?.user?.id) {
return res.status(401).json({ message: "Not authenticated" });
}

View file

@ -1,12 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "../../../lib/prisma";
import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
if (!session || !session.user?.id) {
return res.status(401).json({ message: "Not authenticated" });
}

View file

@ -2,8 +2,8 @@ import { InformationCircleIcon } from "@heroicons/react/outline";
import crypto from "crypto";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { ComponentProps, FormEvent, RefObject, useEffect, useRef, useState, useMemo } from "react";
import Select, { OptionTypeBase } from "react-select";
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
import Select from "react-select";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
import { QueryCell } from "@lib/QueryCell";
@ -21,11 +21,11 @@ import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
import ImageUploader from "@components/ImageUploader";
import SettingsShell from "@components/SettingsShell";
import Shell from "@components/Shell";
import { TextField } from "@components/form/fields";
import { Alert } from "@components/ui/Alert";
import Avatar from "@components/ui/Avatar";
import Badge from "@components/ui/Badge";
import Button from "@components/ui/Button";
import { UsernameInput } from "@components/ui/UsernameInput";
type Props = inferSSRProps<typeof getServerSideProps>;
@ -42,7 +42,7 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
ref={props.hideBrandingRef}
defaultChecked={isBrandingHidden(props.user)}
className={
"focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm disabled:opacity-50"
"focus:ring-neutral-800 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm disabled:opacity-50"
}
onClick={(e) => {
if (!e.currentTarget.checked || props.user.plan !== "FREE") {
@ -125,37 +125,32 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
{ value: "light", label: t("light") },
{ value: "dark", label: t("dark") },
];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const usernameRef = useRef<HTMLInputElement>(null!);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const nameRef = useRef<HTMLInputElement>(null!);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const avatarRef = useRef<HTMLInputElement>(null!);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const brandColorRef = useRef<HTMLInputElement>(null!);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const hideBrandingRef = useRef<HTMLInputElement>(null!);
const [selectedTheme, setSelectedTheme] = useState<OptionTypeBase>();
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(props.user.timeZone);
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState<OptionTypeBase>({
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({
value: props.user.weekStart,
label: nameOfDay(props.localeProp, props.user.weekStart === "Sunday" ? 0 : 1),
});
const [selectedLanguage, setSelectedLanguage] = useState<OptionTypeBase>({
value: props.localeProp,
label: localeOptions.find((option) => option.value === props.localeProp)?.label,
const [selectedLanguage, setSelectedLanguage] = useState({
value: props.localeProp || "",
label: localeOptions.find((option) => option.value === props.localeProp)?.label || "",
});
const [imageSrc, setImageSrc] = useState<string>(props.user.avatar || "");
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
setSelectedTheme(
props.user.theme ? themeOptions.find((theme) => theme.value === props.user.theme) : undefined
);
if (!props.user.theme) return;
const userTheme = themeOptions.find((theme) => theme.value === props.user.theme);
if (!userTheme) return;
setSelectedTheme(userTheme);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -196,7 +191,16 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
<div className="flex-grow space-y-6">
<div className="block sm:flex">
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
<UsernameInput ref={usernameRef} defaultValue={props.user.username || undefined} />
<TextField
name="username"
addOnLeading={
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
</span>
}
ref={usernameRef}
defaultValue={props.user.username || undefined}
/>
</div>
<div className="w-full sm:w-1/2 sm:ml-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
@ -210,7 +214,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
autoComplete="given-name"
placeholder={t("your_name")}
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
defaultValue={props.user.name || undefined}
/>
</div>
@ -251,7 +255,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
placeholder={t("little_something_about")}
rows={3}
defaultValue={props.user.bio || undefined}
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"></textarea>
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"></textarea>
</div>
</div>
<div>
@ -268,27 +272,29 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
name="avatar"
id="avatar"
placeholder="URL"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
defaultValue={imageSrc}
/>
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
avatarRef.current.value = newAvatar;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
const ev2 = new Event("input", { bubbles: true });
avatarRef.current.dispatchEvent(ev2);
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
setImageSrc(newAvatar);
}}
imageSrc={imageSrc}
/>
<div className="flex items-center px-5">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
avatarRef.current.value = newAvatar;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
const ev2 = new Event("input", { bubbles: true });
avatarRef.current.dispatchEvent(ev2);
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
setImageSrc(newAvatar);
}}
imageSrc={imageSrc}
/>
</div>
</div>
<hr className="mt-6" />
</div>
@ -300,9 +306,9 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
<Select
id="languageSelect"
value={selectedLanguage || props.localeProp}
onChange={setSelectedLanguage}
onChange={(v) => v && setSelectedLanguage(v)}
classNamePrefix="react-select"
className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
options={localeOptions}
/>
</div>
@ -315,9 +321,9 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={setSelectedTimeZone}
onChange={(v) => v && setSelectedTimeZone(v)}
classNamePrefix="react-select"
className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
/>
</div>
</div>
@ -329,9 +335,9 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
<Select
id="weekStart"
value={selectedWeekStartDay}
onChange={setSelectedWeekStartDay}
onChange={(v) => v && setSelectedWeekStartDay(v)}
classNamePrefix="react-select"
className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
options={[
{ value: "Sunday", label: nameOfDay(props.localeProp, 0) },
{ value: "Monday", label: nameOfDay(props.localeProp, 1) },
@ -349,8 +355,8 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
isDisabled={!selectedTheme}
defaultValue={selectedTheme || themeOptions[0]}
value={selectedTheme || themeOptions[0]}
onChange={setSelectedTheme}
className="shadow-sm | { value: string } focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm"
onChange={(v) => v && setSelectedTheme(v)}
className="shadow-sm | { value: string } focus:ring-neutral-800 focus:border-neutral-800 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm"
options={themeOptions}
/>
</div>
@ -362,7 +368,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
type="checkbox"
onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])}
checked={!selectedTheme}
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-800 text-neutral-900"
/>
</div>
<div className="ml-3 text-sm">
@ -383,7 +389,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
name="brandColor"
id="brandColor"
placeholder="#hex-code"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
defaultValue={props.user.brandColor}
/>
</div>

View file

@ -1,207 +1,54 @@
import { UsersIcon } from "@heroicons/react/outline";
import { PlusIcon } from "@heroicons/react/solid";
import { useSession } from "next-auth/client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import { Member } from "@lib/member";
import { Team } from "@lib/team";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
import SettingsShell from "@components/SettingsShell";
import Shell from "@components/Shell";
import EditTeam from "@components/team/EditTeam";
import TeamCreateModal from "@components/team/TeamCreateModal";
import TeamList from "@components/team/TeamList";
import TeamListItem from "@components/team/TeamListItem";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
export default function Teams() {
const { t } = useLocale();
const noop = () => undefined;
const [, loading] = useSession();
const [teams, setTeams] = useState([]);
const [invites, setInvites] = useState([]);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [editTeamEnabled, setEditTeamEnabled] = useState(false);
const [teamToEdit, setTeamToEdit] = useState<Team | null>();
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
return resp.json();
};
const { data } = trpc.useQuery(["viewer.teams.list"], {
onError: (e) => {
setErrorMessage(e.message);
},
});
const loadData = () => {
fetch("/api/user/membership")
.then(handleErrors)
.then((data) => {
setTeams(data.membership.filter((m: Member) => m.role !== "INVITEE"));
setInvites(data.membership.filter((m: Member) => m.role === "INVITEE"));
})
.catch(console.log);
};
if (loading) return <Loader />;
useEffect(() => {
loadData();
}, []);
useEffect(() => {
setHasErrors(false);
setErrorMessage("");
}, [showCreateTeamModal]);
if (loading) {
return <Loader />;
}
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
return fetch("/api/teams", {
method: "POST",
body: JSON.stringify({ name: nameRef?.current?.value }),
headers: {
"Content-Type": "application/json",
},
})
.then(handleErrors)
.then(() => {
loadData();
setShowCreateTeamModal(false);
})
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
});
};
const editTeam = (team: Team) => {
setEditTeamEnabled(true);
setTeamToEdit(team);
};
const onCloseEdit = () => {
loadData();
setEditTeamEnabled(false);
};
const teams = data?.filter((m) => m.accepted) || [];
const invites = data?.filter((m) => !m.accepted) || [];
return (
<Shell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
<SettingsShell>
{!editTeamEnabled && (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<div className="flex flex-col justify-between md:flex-row">
<div>
{!(invites.length || teams.length) && (
<div className="sm:rounded-sm">
<div className="pb-5 pr-4 sm:pb-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">
{t("create_team_to_get_started")}
</h3>
<div className="max-w-xl mt-2 text-sm text-gray-500">
<p>{t("create_first_team_and_invite_others")}</p>
</div>
</div>
</div>
)}
</div>
<div className="flex items-start mb-4">
<Button type="button" onClick={() => setShowCreateTeamModal(true)} color="secondary">
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
{t("new_team")}
</Button>
</div>
</div>
<div>
{!!teams.length && (
<TeamList teams={teams} onChange={loadData} onEditTeam={editTeam}></TeamList>
)}
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
{!!invites.length && (
<div>
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">Open Invitations</h2>
<ul className="px-4 mt-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{invites.map((team: Team) => (
<TeamListItem
onChange={loadData}
key={team.id}
team={team}
onActionSelect={noop}></TeamListItem>
))}
</ul>
</div>
)}
</div>
</div>
</div>
)}
{!!editTeamEnabled && <EditTeam team={teamToEdit} onCloseEdit={onCloseEdit} />}
{showCreateTeamModal && (
<div
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-sm shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
<UsersIcon className="w-6 h-6 text-neutral-900" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("create_new_team")}
</h3>
<div>
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
</div>
</div>
</div>
<form onSubmit={createTeam}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("name")}
</label>
{hasErrors && <Alert className="mt-1 mb-2" severity="error" message={errorMessage} />}
<input
ref={nameRef}
type="text"
name="name"
id="name"
placeholder="Acme Inc."
required
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit">{t("create_team")}</Button>
<Button
onClick={() => setShowCreateTeamModal(false)}
type="button"
className="mr-2"
color="secondary">
{t("cancel")}
</Button>
</div>
</form>
</div>
</div>
{showCreateTeamModal && <TeamCreateModal onClose={() => setShowCreateTeamModal(false)} />}
<div className="flex justify-end my-4">
<Button type="button" className="btn btn-white" onClick={() => setShowCreateTeamModal(true)}>
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
{t("new_team")}
</Button>
</div>
{invites.length > 0 && (
<div className="mb-4">
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
<TeamList teams={invites}></TeamList>
</div>
)}
{teams.length > 0 && <TeamList teams={teams}></TeamList>}
</SettingsShell>
</Shell>
);

View file

@ -0,0 +1,94 @@
import { PlusIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import { useState } from "react";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
import MemberInvitationModal from "@components/team/MemberInvitationModal";
import MemberList from "@components/team/MemberList";
import TeamSettings from "@components/team/TeamSettings";
import TeamSettingsRightSidebar from "@components/team/TeamSettingsRightSidebar";
import { Alert } from "@components/ui/Alert";
import Avatar from "@components/ui/Avatar";
import { Button } from "@components/ui/Button";
export function TeamSettingsPage() {
const { t } = useLocale();
const router = useRouter();
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
onError: (e) => {
setErrorMessage(e.message);
},
});
const isAdmin = team && (team.membership.role === "OWNER" || team.membership.role === "ADMIN");
return (
<Shell
showBackButton={!errorMessage}
heading={team?.name}
subtitle={team && "Manage this team"}
HeadingLeftIcon={
team && (
<Avatar
size={12}
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
alt="Team Logo"
className="mt-1"
/>
)
}>
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
{isLoading && <Loader />}
{team && (
<>
<div className="block sm:flex md:max-w-5xl">
<div className="w-full mr-2 sm:w-9/12">
<div className="px-4 -mx-0 bg-white border rounded-sm border-neutral-200 sm:px-6">
{isAdmin ? (
<TeamSettings team={team} />
) : (
<div className="py-5">
<span className="mb-1 font-bold">Team Info</span>
<p className="text-sm text-gray-700">{team.bio}</p>
</div>
)}
</div>
<div className="flex items-center justify-between mb-3 mt-7">
<h3 className="text-xl font-bold leading-6 text-gray-900 font-cal">{t("members")}</h3>
{isAdmin && (
<div className="relative flex items-center">
<Button
type="button"
color="secondary"
StartIcon={PlusIcon}
onClick={() => setShowMemberInvitationModal(true)}>
{t("new_member")}
</Button>
</div>
)}
</div>
<MemberList team={team} members={team.members || []} />
</div>
<div className="w-full px-2 mt-8 ml-2 md:w-3/12 sm:mt-0 min-w-32">
<TeamSettingsRightSidebar role={team.membership.role} team={team} />
</div>
</div>
{showMemberInvitationModal && (
<MemberInvitationModal team={team} onExit={() => setShowMemberInvitationModal(false)} />
)}
</>
)}
</Shell>
);
}
export default TeamSettingsPage;

View file

@ -0,0 +1 @@
export { default } from "@ee/pages/settings/teams/[id]/availability";

View file

@ -1,5 +1,4 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import React from "react";
@ -7,8 +6,8 @@ import React from "react";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import prisma from "@lib/prisma";
import { defaultAvatarSrc } from "@lib/profile";
import { getTeamWithMembers } from "@lib/queries/teams";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
@ -17,8 +16,11 @@ import Team from "@components/team/screens/Team";
import Avatar from "@components/ui/Avatar";
import AvatarGroup from "@components/ui/AvatarGroup";
import Button from "@components/ui/Button";
import Text from "@components/ui/Text";
function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
function TeamPage({ team }: TeamPageProps) {
const { isReady } = useTheme();
const showMembers = useToggleQuery("members");
const { t } = useLocale();
@ -28,12 +30,12 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
{team.eventTypes.map((type) => (
<li
key={type.id}
className="group relative dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 bg-white hover:bg-gray-50 border border-neutral-200 hover:border-brand rounded-sm">
<ArrowRightIcon className="absolute transition-opacity h-4 w-4 right-3 top-3 text-black dark:text-white opacity-0 group-hover:opacity-100" />
className="relative bg-white border rounded-sm group dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 hover:bg-gray-50 border-neutral-200 hover:border-brand">
<ArrowRightIcon className="absolute w-4 h-4 text-black transition-opacity opacity-0 right-3 top-3 dark:text-white group-hover:opacity-100" />
<Link href={`${team.slug}/${type.slug}`}>
<a className="px-6 py-4 flex justify-between">
<a className="flex justify-between px-6 py-4">
<div className="flex-shrink">
<h2 className="font-cal font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<h2 className="font-semibold font-cal text-neutral-900 dark:text-white">{type.title}</h2>
<EventTypeDescription className="text-sm" eventType={type} />
</div>
<div className="mt-1">
@ -60,43 +62,38 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
isReady && (
<div>
<HeadSeo title={teamName} description={teamName} />
<div className="h-screen bg-neutral-50 dark:bg-black">
<main className="max-w-3xl px-4 py-24 mx-auto">
<div className="mb-8 text-center">
<Avatar alt={teamName} imageSrc={team.logo} className="mx-auto w-20 h-20 rounded-full mb-4" />
<h1 className="mb-1 text-3xl font-bold font-cal text-neutral-900 dark:text-white">
{teamName}
</h1>
<p className="text-neutral-500 dark:text-white">{team.bio}</p>
</div>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl">
{eventTypes}
<div className="px-4 pt-24 pb-12">
<div className="mb-8 text-center">
<Avatar alt={teamName} imageSrc={team.logo} className="w-20 h-20 mx-auto mb-4 rounded-full" />
<Text variant="headline">{teamName}</Text>
</div>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="max-w-3xl mx-auto">
{eventTypes}
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200 dark:border-gray-900" />
</div>
<div className="relative flex justify-center">
<span className="px-2 bg-gray-100 text-sm text-gray-500 dark:bg-brand dark:text-gray-500">
{t("or")}
</span>
</div>
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200 dark:border-gray-900" />
</div>
<div className="relative flex justify-center">
<span className="px-2 text-sm text-gray-500 bg-gray-100 dark:bg-brand dark:text-gray-500">
{t("or")}
</span>
</div>
<aside className="text-center dark:text-white mt-8">
<Button
color="secondary"
EndIcon={ArrowRightIcon}
href={`/team/${team.slug}?members=1`}
shallow={true}>
{t("book_a_team_member")}
</Button>
</aside>
</div>
)}
</main>
<aside className="mt-8 text-center dark:text-white">
<Button
color="secondary"
EndIcon={ArrowRightIcon}
href={`/team/${team.slug}?members=1`}
shallow={true}>
{t("book_a_team_member")}
</Button>
</aside>
</div>
)}
</div>
</div>
)
@ -106,54 +103,7 @@ function TeamPage({ team }: inferSSRProps<typeof getServerSideProps>) {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
const userSelect = Prisma.validator<Prisma.UserSelect>()({
username: true,
avatar: true,
email: true,
name: true,
id: true,
bio: true,
});
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
id: true,
name: true,
slug: true,
logo: true,
bio: true,
members: {
select: {
user: {
select: userSelect,
},
},
},
eventTypes: {
where: {
hidden: false,
},
select: {
id: true,
title: true,
description: true,
length: true,
slug: true,
schedulingType: true,
price: true,
currency: true,
users: {
select: userSelect,
},
},
},
});
const team = await prisma.team.findUnique({
where: {
slug,
},
select: teamSelect,
});
const team = await getTeamWithMembers(undefined, slug);
if (!team) return { notFound: true };

View file

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "MembershipRole" ADD VALUE 'ADMIN';

View file

@ -135,6 +135,7 @@ model Team {
enum MembershipRole {
MEMBER
ADMIN
OWNER
}

View file

@ -303,6 +303,7 @@
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
"edit_logo": "Edit logo",
"upload_a_logo": "Upload a logo",
"remove_logo": "Remove logo",
"enable": "Enable",
"code": "Code",
"code_is_incorrect": "Code is incorrect.",
@ -379,6 +380,7 @@
"email_or_username": "Email or Username",
"send_invite_email": "Send an invite email",
"role": "Role",
"edit_role": "Edit Role",
"edit_team": "Edit team",
"reject": "Reject",
"accept": "Accept",
@ -394,10 +396,12 @@
"members": "Members",
"member": "Member",
"owner": "Owner",
"admin": "Admin",
"new_member": "New Member",
"invite": "Invite",
"invite_new_member": "Invite a new member",
"invite_new_team_member": "Invite someone to your team.",
"change_member_role": "Change team member role",
"disable_cal_branding": "Disable Cal.com branding",
"disable_cal_branding_description": "Hide all Cal.com branding from your public pages.",
"danger_zone": "Danger Zone",
@ -419,6 +423,10 @@
"pending": "Pending",
"open_options": "Open options",
"copy_link": "Copy link to event",
"copy_link_team": "Copy link to team",
"leave_team": "Leave team",
"confirm_leave_team": "Yes, leave team",
"leave_team_confirmation_message": "Are you sure you want to leave this team? You will no longer be able to book using it.",
"preview": "Preview",
"link_copied": "Link copied!",
"title": "Title",

View file

@ -15,6 +15,7 @@ import { TRPCError } from "@trpc/server";
import { createProtectedRouter, createRouter } from "../createRouter";
import { resizeBase64Image } from "../lib/resizeBase64Image";
import { viewerTeamsRouter } from "./viewer/teams";
import { webhookRouter } from "./viewer/webhook";
const checkUsername =
@ -630,4 +631,5 @@ const loggedInViewerRouter = createProtectedRouter()
export const viewerRouter = createRouter()
.merge(publicViewerRouter)
.merge(loggedInViewerRouter)
.merge("teams.", viewerTeamsRouter)
.merge("webhook.", webhookRouter);

View file

@ -0,0 +1,377 @@
import { MembershipRole } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { randomBytes } from "crypto";
import { z } from "zod";
import { BASE_URL } from "@lib/config/constants";
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
import { getUserAvailability } from "@lib/queries/availability";
import { getTeamWithMembers, isTeamAdmin, isTeamOwner } from "@lib/queries/teams";
import slugify from "@lib/slugify";
import { createProtectedRouter } from "@server/createRouter";
import { getTranslation } from "@server/lib/i18n";
import { TRPCError } from "@trpc/server";
export const viewerTeamsRouter = createProtectedRouter()
// Retrieves team by id
.query("get", {
input: z.object({
teamId: z.number(),
}),
async resolve({ ctx, input }) {
const team = await getTeamWithMembers(input.teamId);
if (!team?.members.find((m) => m.id === ctx.user.id)) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
}
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
return { ...team, membership: { role: membership?.role as MembershipRole } };
},
})
// Returns teams I a member of
.query("list", {
async resolve({ ctx }) {
const memberships = await ctx.prisma.membership.findMany({
where: {
userId: ctx.user.id,
},
orderBy: { role: "desc" },
});
const teams = await ctx.prisma.team.findMany({
where: {
id: {
in: memberships.map((membership) => membership.teamId),
},
},
});
return memberships.map((membership) => ({
role: membership.role,
accepted: membership.role === "OWNER" ? true : membership.accepted,
...teams.find((team) => team.id === membership.teamId),
}));
},
})
.mutation("create", {
input: z.object({
name: z.string(),
}),
async resolve({ ctx, input }) {
const slug = slugify(input.name);
const nameCollisions = await ctx.prisma.team.count({
where: {
OR: [{ name: input.name }, { slug: slug }],
},
});
if (nameCollisions > 0)
throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
const createTeam = await ctx.prisma.team.create({
data: {
name: input.name,
slug: slug,
},
});
await ctx.prisma.membership.create({
data: {
teamId: createTeam.id,
userId: ctx.user.id,
role: "OWNER",
accepted: true,
},
});
},
})
// Allows team owner to update team metadata
.mutation("update", {
input: z.object({
id: z.number(),
bio: z.string().optional(),
name: z.string().optional(),
logo: z.string().optional(),
slug: z.string().optional(),
hideBranding: z.boolean().optional(),
}),
async resolve({ ctx, input }) {
if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
if (input.slug) {
const userConflict = await ctx.prisma.team.findMany({
where: {
slug: input.slug,
},
});
if (userConflict.some((t) => t.id !== input.id)) return;
}
await ctx.prisma.team.update({
where: {
id: input.id,
},
data: {
name: input.name,
slug: input.slug,
logo: input.logo,
bio: input.bio,
hideBranding: input.hideBranding,
},
});
},
})
.mutation("delete", {
input: z.object({
teamId: z.number(),
}),
async resolve({ ctx, input }) {
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
// delete all memberships
await ctx.prisma.membership.deleteMany({
where: {
teamId: input.teamId,
},
});
await ctx.prisma.team.delete({
where: {
id: input.teamId,
},
});
},
})
// Allows owner to remove member from team
.mutation("removeMember", {
input: z.object({
teamId: z.number(),
memberId: z.number(),
}),
async resolve({ ctx, input }) {
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
if (ctx.user?.id === input.memberId)
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
});
await ctx.prisma.membership.delete({
where: {
userId_teamId: { userId: ctx.user?.id, teamId: input.teamId },
},
});
},
})
.mutation("inviteMember", {
input: z.object({
teamId: z.number(),
usernameOrEmail: z.string(),
role: z.nativeEnum(MembershipRole),
language: z.string(),
sendEmailInvitation: z.boolean(),
}),
async resolve({ ctx, input }) {
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const translation = await getTranslation(input.language ?? "en", "common");
const team = await ctx.prisma.team.findFirst({
where: {
id: input.teamId,
},
});
if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
const invitee = await ctx.prisma.user.findFirst({
where: {
OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }],
},
});
if (!invitee) {
// liberal email match
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
if (!isEmail(input.usernameOrEmail))
throw new TRPCError({
code: "NOT_FOUND",
message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`,
});
// valid email given, create User
await ctx.prisma.user.create({ data: { email: input.usernameOrEmail } }).then((invitee) =>
ctx.prisma.membership.create({
data: {
teamId: input.teamId,
userId: invitee.id,
role: input.role as MembershipRole,
},
})
);
const token: string = randomBytes(32).toString("hex");
await ctx.prisma.verificationRequest.create({
data: {
identifier: input.usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
},
});
if (ctx?.user?.name && team?.name) {
const teamInviteEvent: TeamInvite = {
language: translation,
from: ctx.user.name,
to: input.usernameOrEmail,
teamName: team.name,
joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=${BASE_URL + "/settings/teams"}`,
};
await sendTeamInviteEmail(teamInviteEvent);
}
} else {
// create provisional membership
try {
await ctx.prisma.membership.create({
data: {
teamId: input.teamId,
userId: invitee.id,
role: input.role as MembershipRole,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new TRPCError({
code: "FORBIDDEN",
message: "This user is a member of this team / has a pending invitation.",
});
}
} else throw e;
}
// inform user of membership by email
if (input.sendEmailInvitation && ctx?.user?.name && team?.name) {
const teamInviteEvent: TeamInvite = {
language: translation,
from: ctx.user.name,
to: input.usernameOrEmail,
teamName: team.name,
joinLink: BASE_URL + "/settings/teams",
};
await sendTeamInviteEmail(teamInviteEvent);
}
}
},
})
.mutation("acceptOrLeave", {
input: z.object({
teamId: z.number(),
accept: z.boolean(),
}),
async resolve({ ctx, input }) {
if (input.accept) {
await ctx.prisma.membership.update({
where: {
userId_teamId: { userId: ctx.user.id, teamId: input.teamId },
},
data: {
accepted: true,
},
});
} else {
await ctx.prisma.membership.delete({
where: {
userId_teamId: { userId: ctx.user.id, teamId: input.teamId },
},
});
}
},
})
.mutation("changeMemberRole", {
input: z.object({
teamId: z.number(),
memberId: z.number(),
role: z.nativeEnum(MembershipRole),
}),
async resolve({ ctx, input }) {
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const memberships = await ctx.prisma.membership.findMany({
where: {
teamId: input.teamId,
},
});
const targetMembership = memberships.find((m) => m.userId === input.memberId);
const myMembership = memberships.find((m) => m.userId === ctx.user.id);
const teamHasMoreThanOneOwner = memberships.some((m) => m.role === MembershipRole.OWNER);
if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not change the role of an owner if you are an admin.",
});
}
if (!teamHasMoreThanOneOwner) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not change the role of the only owner of a team.",
});
}
if (myMembership?.role === MembershipRole.ADMIN && input.memberId === ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not change yourself to a higher role.",
});
}
await ctx.prisma.membership.update({
where: {
userId_teamId: { userId: input.memberId, teamId: input.teamId },
},
data: {
role: input.role,
},
});
},
})
.query("getMemberAvailability", {
input: z.object({
teamId: z.number(),
memberId: z.number(),
timezone: z.string(),
dateFrom: z.string(),
dateTo: z.string(),
}),
async resolve({ ctx, input }) {
const team = await isTeamAdmin(ctx.user?.id, input.teamId);
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
// verify member is in team
const members = await ctx.prisma.membership.findMany({
where: { teamId: input.teamId },
include: { user: true },
});
const member = members?.find((m) => m.userId === input.memberId);
if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
if (!member.user.username)
throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" });
// get availability for this member
const availability = await getUserAvailability({
username: member.user.username,
timezone: input.timezone,
dateFrom: input.dateFrom,
dateTo: input.dateTo,
});
return availability;
},
});

View file

@ -374,3 +374,8 @@ body {
background: #000;
color: #fff;
}
/* react-date-picker forces a border upon us, cast it away */
.react-date-picker__wrapper {
border: none !important;
}

View file

@ -315,7 +315,7 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.0":
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.0":
version "7.16.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
@ -2111,6 +2111,20 @@
dependencies:
"@types/react" "*"
"@types/react-virtualized-auto-sizer@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz#b3187dae1dfc4c15880c9cfc5b45f2719ea6ebd4"
integrity sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==
dependencies:
"@types/react" "*"
"@types/react-window@^1.8.5":
version "1.8.5"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@16 || 17":
version "17.0.34"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102"
@ -6886,7 +6900,7 @@ md5.js@^1.3.4:
inherits "^2.0.1"
safe-buffer "^5.1.2"
memoize-one@^5.0.0:
"memoize-one@>=3.1.1 <6", memoize-one@^5.0.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
@ -8340,6 +8354,22 @@ react-date-picker@^8.3.3:
react-fit "^1.0.3"
update-input-width "^1.2.2"
react-date-picker@^8.3.6:
version "8.3.6"
resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.3.6.tgz#446142bee5691aea66a2bac53313357aca561cd4"
integrity sha512-c1rThf0jSKROoSGLpUEPtcC8VE+XoVgqxh+ng9aLYQvjDMGWQBgoat6Qrj8nRVzvCPpdXV4jqiCB3z2vVVuseA==
dependencies:
"@types/react-calendar" "^3.0.0"
"@wojtekmaj/date-utils" "^1.0.3"
get-user-locale "^1.2.0"
make-event-props "^1.1.0"
merge-class-names "^1.1.1"
merge-refs "^1.0.0"
prop-types "^15.6.0"
react-calendar "^3.3.1"
react-fit "^1.0.3"
update-input-width "^1.2.2"
react-dom@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
@ -8540,6 +8570,19 @@ react-use-intercom@1.4.0:
resolved "https://registry.yarnpkg.com/react-use-intercom/-/react-use-intercom-1.4.0.tgz#796527728c131ebf132186385bf78f69dbcd84cc"
integrity sha512-HqPp7nRnftREE01i88w2kYWOV45zvJt0Of6jtHflIBa3eKl1bAs/izZUINGCJ0DOdgAdlbLweAvJlP4VTzsJjQ==
react-virtualized-auto-sizer@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca"
integrity sha512-7tQ0BmZqfVF6YYEWcIGuoR3OdYe8I/ZFbNclFlGOC3pMqunkYF/oL30NCjSGl9sMEb17AnzixDz98Kqc3N76HQ==
react-window@^1.8.6:
version "1.8.6"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.6.tgz#d011950ac643a994118632665aad0c6382e2a112"
integrity sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==
dependencies:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"