Skip to content

Commit e03f524

Browse files
committed
[dashboard] Improve team members page
1 parent caa322c commit e03f524

File tree

10 files changed

+100
-34
lines changed

10 files changed

+100
-34
lines changed

components/dashboard/src/App.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,6 @@ function App() {
154154
<Route path="/integrations" exact component={Integrations} />
155155
<Route path="/notifications" exact component={Notifications} />
156156
<Route path="/plans" exact component={Plans} />
157-
<Route path="/teams" exact component={Teams} />
158157
<Route path="/variables" exact component={EnvironmentVariables} />
159158
<Route path="/preferences" exact component={Preferences} />
160159
<Route path="/install-github-app" exact component={InstallGitHubApp} />
@@ -184,8 +183,11 @@ function App() {
184183
<p className="mt-4 text-lg text-gitpod-red">{decodeURIComponent(getURLHash())}</p>
185184
</div>
186185
</Route>
187-
<Route path="/new-team" exact component={NewTeam} />
188-
<Route path="/join-team" exact component={JoinTeam} />
186+
<Route path="/teams">
187+
<Route exact path="/teams" component={Teams} />
188+
<Route exact path="/teams/new" component={NewTeam} />
189+
<Route exact path="/teams/join" component={JoinTeam} />
190+
</Route>
189191
{(teams || []).map(team => <Route path={`/${team.slug}`}>
190192
<Route exact path={`/${team.slug}`}>
191193
<Redirect to={`/${team.slug}/projects`} />

components/dashboard/src/Menu.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export default function Menu() {
4343
const showTeamsUI = user?.rolesOrPermissions?.includes('teams-and-projects') || window.location.hostname.endsWith('gitpod-dev.com') || window.location.hostname.endsWith('gitpod-io-dev.com');
4444
const team = getCurrentTeam(location, teams);
4545

46+
// Hide most of the top menu when in a full-page form.
47+
const isMinimalUI = ['/new', '/teams/new'].includes(location.pathname);
48+
4649
const [ teamMembers, setTeamMembers ] = useState<Record<string, TeamMemberInfo[]>>({});
4750
useEffect(() => {
4851
if (!showTeamsUI || !teams) {
@@ -106,7 +109,7 @@ export default function Menu() {
106109
<Link to="/">
107110
<img src={gitpodIcon} className="h-6" />
108111
</Link>
109-
<div className="ml-2 text-base">
112+
{!isMinimalUI && <div className="ml-2 text-base">
110113
{showTeamsUI
111114
? <ContextMenu classes="w-64 left-0" menuEntries={[
112115
{
@@ -136,7 +139,7 @@ export default function Menu() {
136139
<span className="flex-1 font-semibold">New Team</span>
137140
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" className="w-3.5"><path fill="currentColor" fill-rule="evenodd" d="M7 0a1 1 0 011 1v5h5a1 1 0 110 2H8v5a1 1 0 11-2 0V8H1a1 1 0 010-2h5V1a1 1 0 011-1z" clip-rule="evenodd"/></svg>
138141
</div>,
139-
onClick: () => history.push("/new-team"),
142+
onClick: () => history.push("/teams/new"),
140143
}
141144
]}>
142145
<div className="flex p-1.5 pl-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
@@ -153,13 +156,13 @@ export default function Menu() {
153156
</ul>
154157
</nav>
155158
}
156-
</div>
159+
</div>}
157160
</div>
158161
<div className="flex flex-1 items-center w-auto" id="menu">
159162
<nav className="flex-1">
160163
<ul className="flex flex-1 items-center justify-between text-base text-gray-700 space-x-2">
161164
<li className="flex-1"></li>
162-
{rightMenu.map(entry => <li key={entry.title}>
165+
{!isMinimalUI && rightMenu.map(entry => <li key={entry.title}>
163166
<PillMenuItem name={entry.title} selected={isSelected(entry, location)} link={entry.link}/>
164167
</li>)}
165168
</ul>
@@ -186,10 +189,10 @@ export default function Menu() {
186189
</div>
187190
</div>
188191
</div>
189-
{showTeamsUI && <div className="flex">
192+
{!isMinimalUI && showTeamsUI && <div className="flex">
190193
{leftMenu.map(entry => <TabMenuItem name={entry.title} selected={isSelected(entry, location)} link={entry.link}/>)}
191194
</div>}
192195
</header>
193-
{showTeamsUI && <Separator />}
196+
{!isMinimalUI && showTeamsUI && <Separator />}
194197
</>;
195198
}

components/dashboard/src/teams/Members.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,21 @@
77
import { TeamMemberInfo, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
88
import moment from "moment";
99
import { useContext, useEffect, useState } from "react";
10-
import { useLocation } from "react-router";
10+
import { useHistory, useLocation } from "react-router";
1111
import Header from "../components/Header";
1212
import DropDown from "../components/DropDown";
1313
import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList";
1414
import Modal from "../components/Modal";
15-
import { getGitpodService } from "../service/service";
1615
import copy from '../images/copy.svg';
16+
import { getGitpodService } from "../service/service";
17+
import { UserContext } from "../user-context";
1718
import { TeamsContext, getCurrentTeam } from "./teams-context";
1819

1920

2021
export default function() {
21-
const { teams } = useContext(TeamsContext);
22+
const { user } = useContext(UserContext);
23+
const { teams, setTeams } = useContext(TeamsContext);
24+
const history = useHistory();
2225
const location = useLocation();
2326
const team = getCurrentTeam(location, teams);
2427
const [ members, setMembers ] = useState<TeamMemberInfo[]>([]);
@@ -41,7 +44,7 @@ export default function() {
4144

4245
const getInviteURL = (inviteId: string) => {
4346
const link = new URL(window.location.href);
44-
link.pathname = '/join-team';
47+
link.pathname = '/teams/join';
4548
link.search = '?inviteId=' + inviteId;
4649
return link.href;
4750
}
@@ -70,6 +73,20 @@ export default function() {
7073
}
7174
}
7275

76+
const removeTeamMember = async (userId: string) => {
77+
await getGitpodService().server.removeTeamMember(team!.id, userId);
78+
const newTeams = await getGitpodService().server.getTeams();
79+
if (newTeams.some(t => t.id === team!.id)) {
80+
// We're still a member of this team.
81+
const newMembers = await getGitpodService().server.getTeamMembers(team!.id);
82+
setMembers(newMembers);
83+
} else {
84+
// We're no longer a member of this team (note: we navigate away first in order to avoid a 404).
85+
history.push('/');
86+
setTeams(newTeams);
87+
}
88+
}
89+
7390
return <>
7491
<Header title="Members" subtitle="Manage team members." />
7592
<div className="lg:px-28 px-10">
@@ -100,8 +117,9 @@ export default function() {
100117
<ItemField>
101118
<span className="pl-14">Name</span>
102119
</ItemField>
103-
<ItemField>
120+
<ItemField className="flex items-center space-x-1">
104121
<span>Joined</span>
122+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" className="h-4 w-4" viewBox="0 0 16 16"><path fill="#A8A29E" fill-rule="evenodd" d="M13.366 8.234a.8.8 0 010 1.132l-4.8 4.8a.8.8 0 01-1.132 0l-4.8-4.8a.8.8 0 111.132-1.132L7.2 11.67V2.4a.8.8 0 111.6 0v9.269l3.434-3.435a.8.8 0 011.132 0z" clip-rule="evenodd"/></svg>
105123
</ItemField>
106124
<ItemField className="flex items-center">
107125
<span className="flex-grow">Role</span>
@@ -123,10 +141,10 @@ export default function() {
123141
<span className="text-gray-400 flex-grow capitalize">{m.role}</span>
124142
<ItemFieldContextMenu menuEntries={[
125143
{
126-
title: 'Remove',
144+
title: (m.userId === user?.id) ? 'Leave Team' : 'Remove',
127145
customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300',
128-
onClick: () => { /* TODO(janx) */ }
129-
},
146+
onClick: () => removeTeamMember(m.userId)
147+
}
130148
]} />
131149
</ItemField>
132150
</Item>)}

components/dashboard/src/teams/NewTeam.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default function () {
2727
setCreationError(error);
2828
}
2929
}
30-
return <div className="flex flex-col w-96 mt-16 mx-auto items-center">
30+
return <div className="flex flex-col w-96 mt-24 mx-auto items-center">
3131
<h1>New Team</h1>
3232
<p className="text-gray-500 text-center text-base">Teams allow you to <strong>group multiple projects</strong>, <strong>collaborate with others</strong>, <strong>manage subscriptions</strong> with one centralized billing, and more. <a className="learn-more" href="https://www.gitpod.io/docs/teams/">Learn more</a></p>
3333
<form className="mt-16 w-full" onSubmit={createTeam}>

components/gitpod-db/src/team-db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface TeamDB {
1313
findTeamsByUser(userId: string): Promise<Team[]>;
1414
createTeam(userId: string, name: string): Promise<Team>;
1515
addMemberToTeam(userId: string, teamId: string): Promise<void>;
16+
removeMemberFromTeam(userId: string, teamId: string): Promise<void>;
1617
findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite>;
1718
findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite | undefined>;
1819
resetGenericInvite(teamId: string): Promise<TeamMembershipInvite>;

components/gitpod-db/src/typeorm/team-db-impl.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,30 +41,34 @@ export class TeamDBImpl implements TeamDB {
4141

4242
public async findTeamById(teamId: string): Promise<Team | undefined> {
4343
const teamRepo = await this.getTeamRepo();
44-
return teamRepo.findOne({ id: teamId });
44+
return teamRepo.findOne({ id: teamId, deleted: false });
4545
}
4646

4747
public async findMembersByTeam(teamId: string): Promise<TeamMemberInfo[]> {
4848
const membershipRepo = await this.getMembershipRepo();
4949
const userRepo = await this.getUserRepo();
50-
const memberships = await membershipRepo.find({ teamId });
50+
const memberships = await membershipRepo.find({ teamId, deleted: false });
5151
const users = await userRepo.findByIds(memberships.map(m => m.userId));
52-
const infos = users.map(u => ({
53-
userId: u.id,
54-
fullName: u.fullName || u.name,
55-
primaryEmail: User.getPrimaryEmail(u),
56-
avatarUrl: u.avatarUrl,
57-
role: memberships.find(m => m.userId === u.id)!.role,
58-
memberSince: u.creationDate,
59-
}));
52+
const infos = users.map(u => {
53+
const m = memberships.find(m => m.userId === u.id)!;
54+
return {
55+
userId: u.id,
56+
fullName: u.fullName || u.name,
57+
primaryEmail: User.getPrimaryEmail(u),
58+
avatarUrl: u.avatarUrl,
59+
role: m.role,
60+
memberSince: m.creationTime,
61+
};
62+
});
6063
return infos.sort((a,b) => a.memberSince < b.memberSince ? 1 : (a.memberSince === b.memberSince ? 0 : -1));
6164
}
6265

6366
public async findTeamsByUser(userId: string): Promise<Team[]> {
6467
const teamRepo = await this.getTeamRepo();
6568
const membershipRepo = await this.getMembershipRepo();
66-
const memberships = await membershipRepo.find({ userId });
67-
return teamRepo.findByIds(memberships.map(m => m.teamId));
69+
const memberships = await membershipRepo.find({ userId, deleted: false });
70+
const teams = await teamRepo.findByIds(memberships.map(m => m.teamId));
71+
return teams.filter(t => !t.deleted);
6872
}
6973

7074
public async createTeam(userId: string, name: string): Promise<Team> {
@@ -76,7 +80,7 @@ export class TeamDBImpl implements TeamDB {
7680
}
7781
const slug = name.toLocaleLowerCase().replace(/ /g, '-');
7882
const teamRepo = await this.getTeamRepo();
79-
const existingTeam = await teamRepo.findOne({ slug });
83+
const existingTeam = await teamRepo.findOne({ slug, deleted: false });
8084
if (!!existingTeam) {
8185
throw new Error('A team with this name already exists');
8286
}
@@ -104,11 +108,11 @@ export class TeamDBImpl implements TeamDB {
104108
}
105109
const teamRepo = await this.getTeamRepo();
106110
const team = await teamRepo.findOneById(teamId);
107-
if (!team) {
111+
if (!team || !!team.deleted) {
108112
throw new Error('A team with this ID could not be found');
109113
}
110114
const membershipRepo = await this.getMembershipRepo();
111-
const membership = await membershipRepo.findOne({ teamId, userId });
115+
const membership = await membershipRepo.findOne({ teamId, userId, deleted: false });
112116
if (!!membership) {
113117
throw new Error('You are already a member of this team');
114118
}
@@ -121,6 +125,24 @@ export class TeamDBImpl implements TeamDB {
121125
});
122126
}
123127

128+
public async removeMemberFromTeam(userId: string, teamId: string): Promise<void> {
129+
if (teamId.length !== 36) {
130+
throw new Error('This team ID is incorrect');
131+
}
132+
const teamRepo = await this.getTeamRepo();
133+
const team = await teamRepo.findOneById(teamId);
134+
if (!team || !!team.deleted) {
135+
throw new Error('A team with this ID could not be found');
136+
}
137+
const membershipRepo = await this.getMembershipRepo();
138+
const membership = await membershipRepo.findOne({ teamId, userId, deleted: false });
139+
if (!membership) {
140+
throw new Error('You are not currently a member of this team');
141+
}
142+
membership.deleted = true;
143+
await membershipRepo.save(membership);
144+
}
145+
124146
public async findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite> {
125147
const inviteRepo = await this.getMembershipInviteRepo();
126148
const invite = await inviteRepo.findOneById(inviteId);

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
112112
getTeamMembers(teamId: string): Promise<TeamMemberInfo[]>;
113113
createTeam(name: string): Promise<Team>;
114114
joinTeam(inviteId: string): Promise<Team>;
115+
removeTeamMember(teamId: string, userId: string): Promise<void>;
115116
getGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
116117
resetGenericInvite(inviteId: string): Promise<TeamMembershipInvite>;
117118

components/server/src/auth/rate-limiter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ function readConfig(): RateLimiterConfig {
8282
"getTeamMembers": { group: "default", points: 1 },
8383
"createTeam": { group: "default", points: 1 },
8484
"joinTeam": { group: "default", points: 1 },
85+
"removeTeamMember": { group: "default", points: 1 },
8586
"getGenericInvite": { group: "default", points: 1 },
8687
"resetGenericInvite": { group: "default", points: 1 },
8788
"getContentBlobUploadUrl": { group: "default", points: 1 },

components/server/src/auth/resource-access.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,18 @@ export class OwnerResourceGuard implements ResourceAccessGuard {
156156
case "envVar":
157157
return resource.subject.userId === this.userId;
158158
case "team":
159-
return resource.members.some(m => m.userId === this.userId);
159+
switch (operation) {
160+
case "create":
161+
// Anyone can create a new team.
162+
return true;
163+
case "get":
164+
// Only members can get infos about a team.
165+
return resource.members.some(m => m.userId === this.userId);
166+
case "update":
167+
case "delete":
168+
// Only owners can update or delete a team.
169+
return resource.members.some(m => m.userId === this.userId && m.role === "owner");
170+
}
160171
}
161172
}
162173

components/server/src/workspace/gitpod-server-impl.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,6 +1417,13 @@ export class GitpodServerImpl<Client extends GitpodClient, Server extends Gitpod
14171417
return team!;
14181418
}
14191419

1420+
public async removeTeamMember(teamId: string, userId: string): Promise<void> {
1421+
const user = this.checkUser("removeTeamMember");
1422+
// Users are free to leave any team themselves, but only owners can remove others from their teams.
1423+
await this.guardTeamOperation(teamId, user.id === userId ? "get" : "update");
1424+
await this.teamDB.removeMemberFromTeam(userId, teamId);
1425+
}
1426+
14201427
public async getGenericInvite(teamId: string): Promise<TeamMembershipInvite> {
14211428
this.checkUser("getGenericInvite");
14221429
await this.guardTeamOperation(teamId, "get");

0 commit comments

Comments
 (0)