+
+
+
-
+
Invite a new member
@@ -79,16 +81,16 @@ export default function MemberInvitationModal(props) {
id="inviteUser"
placeholder="email@example.com"
required
- className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
+ className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
-
+
Role
+ className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm">
Member
Owner
@@ -100,7 +102,7 @@ export default function MemberInvitationModal(props) {
name="sendInviteEmail"
defaultChecked
id="sendInviteEmail"
- className="text-black shadow-sm focus:ring-black focus:border-black sm:text-sm border-gray-300 rounded-md"
+ className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm"
/>
@@ -111,18 +113,18 @@ export default function MemberInvitationModal(props) {
{errorMessage && (
-
+
Error:
{errorMessage}
)}
-
+
Invite
-
-
+
+
Cancel
-
+
diff --git a/components/team/MemberList.tsx b/components/team/MemberList.tsx
new file mode 100644
index 00000000..89536a28
--- /dev/null
+++ b/components/team/MemberList.tsx
@@ -0,0 +1,31 @@
+import MemberListItem from "./MemberListItem";
+import { Member } from "@lib/member";
+
+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;
+ }
+ };
+
+ return (
+
+
+ {props.members.map((member) => (
+ selectAction(action, member)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/components/team/MemberListItem.tsx b/components/team/MemberListItem.tsx
new file mode 100644
index 00000000..0502b969
--- /dev/null
+++ b/components/team/MemberListItem.tsx
@@ -0,0 +1,94 @@
+import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
+import Dropdown from "../ui/Dropdown";
+import { useState } from "react";
+import { Dialog, DialogTrigger } from "@components/Dialog";
+import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
+import Avatar from "@components/Avatar";
+import { Member } from "@lib/member";
+import Button from "@components/ui/Button";
+
+export default function MemberListItem(props: {
+ member: Member;
+ onActionSelect: (text: string) => void;
+ onChange: (text: string) => void;
+}) {
+ const [member] = useState(props.member);
+
+ return (
+ member && (
+
+
+
+
+
+ {props.member.name}
+ {props.member.email}
+
+
+
+ {props.member.role === "INVITEE" && (
+ <>
+
+ Pending
+
+
+ Member
+
+ >
+ )}
+ {props.member.role === "MEMBER" && (
+
+ Member
+
+ )}
+ {props.member.role === "OWNER" && (
+
+ Owner
+
+ )}
+
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ }}
+ color="warn"
+ StartIcon={UserRemoveIcon}
+ className="w-full">
+ Remove User
+
+ props.onActionSelect("remove")}>
+ Are you sure you want to remove this member from the team?
+
+
+
+
+
+
+
+
+ )
+ );
+}
diff --git a/components/team/TeamList.tsx b/components/team/TeamList.tsx
index abeaf733..b2e258a9 100644
--- a/components/team/TeamList.tsx
+++ b/components/team/TeamList.tsx
@@ -1,29 +1,32 @@
-import { useState } from "react";
import TeamListItem from "./TeamListItem";
-import EditTeamModal from "./EditTeamModal";
-import MemberInvitationModal from "./MemberInvitationModal";
+import { Team } from "@lib/team";
-export default function TeamList(props) {
- const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
- const [showEditTeamModal, setShowEditTeamModal] = useState(false);
- const [team, setTeam] = useState(null);
-
- const selectAction = (action: string, team: any) => {
- setTeam(team);
+export default function TeamList(props: {
+ teams: Team[];
+ onChange: () => void;
+ onEditTeam: (text: Team) => void;
+}) {
+ const selectAction = (action: string, team: Team) => {
switch (action) {
case "edit":
- setShowEditTeamModal(true);
+ props.onEditTeam(team);
break;
- case "invite":
- setShowMemberInvitationModal(true);
+ case "disband":
+ deleteTeam(team);
break;
}
};
+ const deleteTeam = (team: Team) => {
+ return fetch("/api/teams/" + team.id, {
+ method: "DELETE",
+ }).then(props.onChange());
+ };
+
return (
-
- {props.teams.map((team: any) => (
+
+ {props.teams.map((team: Team) => (
selectAction(action, team)}>
))}
- {showEditTeamModal && (
- {
- props.onChange();
- setShowEditTeamModal(false);
- }}>
- )}
- {showMemberInvitationModal && (
- setShowMemberInvitationModal(false)}>
- )}
);
}
diff --git a/components/team/TeamListItem.tsx b/components/team/TeamListItem.tsx
index ebfac5fe..4333b9bb 100644
--- a/components/team/TeamListItem.tsx
+++ b/components/team/TeamListItem.tsx
@@ -1,9 +1,38 @@
-import { CogIcon, TrashIcon, UsersIcon } from "@heroicons/react/outline";
+import {
+ TrashIcon,
+ DotsHorizontalIcon,
+ LinkIcon,
+ PencilAltIcon,
+ ExternalLinkIcon,
+} from "@heroicons/react/outline";
import Dropdown from "../ui/Dropdown";
import { useState } from "react";
+import { Tooltip } from "@components/Tooltip";
+import Link from "next/link";
+import { Dialog, DialogTrigger } from "@components/Dialog";
+import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
+import Avatar from "@components/Avatar";
+import Button from "@components/ui/Button";
+import showToast from "@lib/notification";
-export default function TeamListItem(props) {
- const [team, setTeam] = useState(props.team);
+interface Team {
+ id: number;
+ name: string | null;
+ slug: string | null;
+ logo: string | null;
+ bio: string | null;
+ role: string | null;
+ hideBranding: boolean;
+ prevState: null;
+}
+
+export default function TeamListItem(props: {
+ onChange: () => void;
+ key: number;
+ team: Team;
+ onActionSelect: (text: string) => void;
+}) {
+ const [team, setTeam] = useState
(props.team);
const acceptInvite = () => invitationResponse(true);
const declineInvite = () => invitationResponse(false);
@@ -23,84 +52,117 @@ export default function TeamListItem(props) {
return (
team && (
-
-
-
-
-
-
{props.team.name}
-
- {props.team.role.toLowerCase()}
+
+
+
+
+
+ {props.team.name}
+
+ {window.location.hostname}/{props.team.slug}
{props.team.role === "INVITEE" && (
-
- Accept invitation
-
-
-
-
+
+ Reject
+
+
+ Accept
+
)}
{props.team.role === "MEMBER" && (
-
+
Leave
-
+
)}
{props.team.role === "OWNER" && (
-
-
-
-
-
+
+
+ Owner
+
+
+ {
+ navigator.clipboard.writeText(window.location.hostname + "/team/" + props.team.slug);
+ showToast("Link copied!", "success");
+ }}
+ color="minimal"
+ className="w-full pl-5 ml-8"
+ StartIcon={LinkIcon}
+ type="button">
+
+
+
-
- props.onActionSelect("invite")}>
- Invite members
-
+ className="absolute right-0 z-10 origin-top-right bg-white rounded-sm shadow-lg top-10 w-44 ring-1 ring-black ring-opacity-5 focus:outline-none">
+
+ props.onActionSelect("edit")}
+ StartIcon={PencilAltIcon}>
+ {" "}
+ Edit team
+
-
- props.onActionSelect("edit")}>
- Manage team
-
+
+
+
+
+ {" "}
+ Preview team page
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ }}
+ color="warn"
+ StartIcon={TrashIcon}
+ className="w-full">
+ Disband Team
+
+ props.onActionSelect("disband")}>
+ Are you sure you want to disband this team? Anyone who you've shared this team
+ link with will no longer be able to book using it.
+
+
)}
- {/*{props.team.userRole === 'Owner' && expanded &&
- {props.team.members.length > 0 &&
-
Members
-
-
- {props.team.members.map( (member) =>
- Alex van Andel ({ member.email })
- Owner
-
- Remove
-
- )}
-
-
-
}
-
Invite member
-
Disband
-
}*/}
)
);
diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx
index 9c05316e..2ef72e9b 100644
--- a/components/ui/Button.tsx
+++ b/components/ui/Button.tsx
@@ -5,7 +5,7 @@ import React from "react";
type SVGComponent = React.FunctionComponent
>;
export type ButtonProps = {
- color?: "primary" | "secondary" | "minimal";
+ color?: "primary" | "secondary" | "minimal" | "warn";
size?: "base" | "sm" | "lg" | "fab";
loading?: boolean;
disabled?: boolean;
@@ -63,7 +63,10 @@ export const Button = function Button(props: ButtonProps) {
(disabled
? "text-gray-400 bg-transparent"
: "text-gray-700 bg-transparent hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-gray-100 focus:ring-neutral-500"),
-
+ color === "warn" &&
+ (disabled
+ ? "text-gray-400 bg-transparent"
+ : "text-red-700 bg-transparent hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-50 focus:ring-red-500"),
// set not-allowed cursor if disabled
disabled && "cursor-not-allowed",
props.className
diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx
index 61aa858e..2b454e13 100644
--- a/components/ui/UsernameInput.tsx
+++ b/components/ui/UsernameInput.tsx
@@ -4,11 +4,11 @@ const UsernameInput = React.forwardRef((props, ref) => (
// todo, check if username is already taken here?
- Username
+ {props.label ? props.label : "Username"}
-
diff --git a/lib/member.ts b/lib/member.ts
new file mode 100644
index 00000000..8b64c230
--- /dev/null
+++ b/lib/member.ts
@@ -0,0 +1,8 @@
+export interface Member {
+ id: number;
+ avatar: string | null;
+ role: string;
+ email: string;
+ name: string | null;
+ username: string | null;
+}
diff --git a/lib/team.ts b/lib/team.ts
new file mode 100644
index 00000000..e6e290e0
--- /dev/null
+++ b/lib/team.ts
@@ -0,0 +1,10 @@
+export interface Team {
+ id: number;
+ name: string | null;
+ slug: string | null;
+ logo: string | null;
+ bio: string | null;
+ role: string | null;
+ hideBranding: boolean;
+ prevState: null;
+}
diff --git a/pages/api/teams/[team]/profile.ts b/pages/api/teams/[team]/profile.ts
new file mode 100644
index 00000000..9084aef2
--- /dev/null
+++ b/pages/api/teams/[team]/profile.ts
@@ -0,0 +1,68 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import prisma from "@lib/prisma";
+import { getSession } from "next-auth/client";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await getSession({ req: req });
+ if (!session) {
+ return res.status(401).json({ message: "Not authenticated" });
+ }
+
+ const isTeamOwner = !!(await prisma.membership.findFirst({
+ where: {
+ userId: session.user.id,
+ teamId: parseInt(req.query.team as string),
+ role: "OWNER",
+ },
+ }));
+
+ if (!isTeamOwner) {
+ res.status(403).json({ message: "You are not authorized to manage this team" });
+ return;
+ }
+
+ // PATCH /api/teams/profile/{team}
+ if (req.method === "PATCH") {
+ const team = await prisma.team.findFirst({
+ where: {
+ id: parseInt(req.query.team as string),
+ },
+ });
+
+ if (!team) {
+ return res.status(404).json({ message: "Invalid team" });
+ }
+
+ const username = req.body.username;
+ const userConflict = await prisma.team.findMany({
+ where: {
+ slug: username,
+ },
+ });
+ const teamId = Number(req.query.team);
+ if (userConflict.some((team) => team.id !== teamId)) {
+ return res.status(409).json({ message: "Team username already taken" });
+ }
+
+ const name = req.body.name;
+ const slug = req.body.username;
+ const bio = req.body.description;
+ const logo = req.body.logo;
+ const hideBranding = req.body.hideBranding;
+
+ await prisma.team.update({
+ where: {
+ id: team.id,
+ },
+ data: {
+ name,
+ slug,
+ logo,
+ bio,
+ hideBranding,
+ },
+ });
+
+ return res.status(200).json({ message: "Team updated successfully" });
+ }
+}
diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx
index ce807e7a..2847567e 100644
--- a/pages/settings/teams.tsx
+++ b/pages/settings/teams.tsx
@@ -1,7 +1,7 @@
import { GetServerSideProps } from "next";
import Shell from "@components/Shell";
import SettingsShell from "@components/Settings";
-import { useEffect, useState } from "react";
+import { useEffect, useState, useRef } from "react";
import type { Session } from "next-auth";
import { useSession } from "next-auth/client";
import { UsersIcon } from "@heroicons/react/outline";
@@ -9,14 +9,23 @@ import TeamList from "@components/team/TeamList";
import TeamListItem from "@components/team/TeamListItem";
import Loader from "@components/Loader";
import { getSession } from "@lib/auth";
+import EditTeam from "@components/team/EditTeam";
+import Button from "@components/ui/Button";
+import { Member } from "@lib/member";
+import { Team } from "@lib/team";
+import { PlusIcon } from "@heroicons/react/solid";
export default function Teams() {
+ 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
();
+ const nameRef = useRef() as React.MutableRefObject;
- const handleErrors = async (resp) => {
+ const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
@@ -28,8 +37,8 @@ export default function Teams() {
fetch("/api/user/membership")
.then(handleErrors)
.then((data) => {
- setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
- setInvites(data.membership.filter((m) => m.role === "INVITEE"));
+ setTeams(data.membership.filter((m: Member) => m.role !== "INVITEE"));
+ setInvites(data.membership.filter((m: Member) => m.role === "INVITEE"));
})
.catch(console.log);
};
@@ -42,12 +51,11 @@ export default function Teams() {
return ;
}
- const createTeam = (e) => {
+ const createTeam = (e: React.FormEvent) => {
e.preventDefault();
-
return fetch("/api/teams", {
method: "POST",
- body: JSON.stringify({ name: e.target.elements["name"].value }),
+ body: JSON.stringify({ name: nameRef?.current?.value }),
headers: {
"Content-Type": "application/json",
},
@@ -57,92 +65,93 @@ export default function Teams() {
});
};
+ const editTeam = (team: Team) => {
+ setEditTeamEnabled(true);
+ setTeamToEdit(team);
+ };
+
+ const onCloseEdit = () => {
+ loadData();
+ setEditTeamEnabled(false);
+ };
+
return (
-
-
-
-
- {!(invites.length || teams.length) && (
-
-
-
- Create a team to get started
-
-
-
Create your first team and invite other users to work together with you.
-
-
-
setShowCreateTeamModal(true)}
- className="btn btn-primary">
- Create new team
-
+ {!editTeamEnabled && (
+
+
+
+
+ {!(invites.length || teams.length) && (
+
+
+
+ Create a team to get started
+
+
+
Create your first team and invite other users to work together with you.
+
+ )}
+
+
+
setShowCreateTeamModal(true)}
+ className="btn btn-white">
+
+ New Team
+
+
+
+
+ {!!teams.length && (
+
+ )}
+
+ {!!invites.length && (
+
+
Open Invitations
+
+ {invites.map((team: Team) => (
+
+ ))}
+
)}
- {!!(invites.length || teams.length) && (
-
- setShowCreateTeamModal(true)}>
- Create new team
-
-
- )}
-
- {!!teams.length &&
}
-
- {!!invites.length && (
-
-
Open Invitations
-
- {invites.map((team) => (
-
- ))}
-
-
- )}
-
- {/*{teamsLoaded &&
-
-
Transform account
-
- {membership.length !== 0 && "You cannot convert this account into a team until you leave all teams that you’re a member of."}
- {membership.length === 0 && "A user account can be turned into a team, as a team ...."}
-
-
-
- Convert {session.user.username} into a team
-
-
}*/}
-
+ )}
+ {!!editTeamEnabled &&
}
{showCreateTeamModal && (
-
+
-
-
-
-
+
+
+
+
-
+
Create a new team
@@ -156,12 +165,13 @@ export default function Teams() {
Name
@@ -171,7 +181,7 @@ export default function Teams() {
setShowCreateTeamModal(false)}
type="button"
- className="btn btn-white mr-2">
+ className="mr-2 btn btn-white">
Cancel
diff --git a/prisma/migrations/20210824054220_add_bio_branding_logo_to_team/migration.sql b/prisma/migrations/20210824054220_add_bio_branding_logo_to_team/migration.sql
new file mode 100644
index 00000000..04450e4d
--- /dev/null
+++ b/prisma/migrations/20210824054220_add_bio_branding_logo_to_team/migration.sql
@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE "Team" ADD COLUMN "bio" TEXT,
+ADD COLUMN "hideBranding" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN "logo" TEXT;
diff --git a/prisma/migrations/20210830064354_add_unique_to_team_slug/migration.sql b/prisma/migrations/20210830064354_add_unique_to_team_slug/migration.sql
new file mode 100644
index 00000000..35b62a56
--- /dev/null
+++ b/prisma/migrations/20210830064354_add_unique_to_team_slug/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - A unique constraint covering the columns `[slug]` on the table `Team` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- CreateIndex
+CREATE UNIQUE INDEX "Team.slug_unique" ON "Team"("slug");
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index eb9ea280..339f8831 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -73,10 +73,13 @@ model User {
}
model Team {
- id Int @id @default(autoincrement())
- name String?
- slug String?
- members Membership[]
+ id Int @default(autoincrement()) @id
+ name String?
+ slug String? @unique
+ logo String?
+ bio String?
+ hideBranding Boolean @default(false)
+ members Membership[]
}
enum MembershipRole {