Skip to content

Commit 201d63f

Browse files
committed
[dashboard] Implement inviting team members
1 parent b2e4fad commit 201d63f

File tree

12 files changed

+185
-22
lines changed

12 files changed

+185
-22
lines changed

components/dashboard/src/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ './setti
2929
const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/StartWorkspace'));
3030
const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace'));
3131
const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam'));
32+
const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/JoinTeam'));
3233
const Members = React.lazy(() => import(/* webpackPrefetch: true */ './teams/Members'));
3334
const Projects = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Projects'));
3435
const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './prebuilds/InstallGitHubApp'));
@@ -154,7 +155,6 @@ function App() {
154155
<Route path="/notifications" exact component={Notifications} />
155156
<Route path="/plans" exact component={Plans} />
156157
<Route path="/teams" exact component={Teams} />
157-
<Route path="/new-team" exact component={NewTeam} />
158158
<Route path="/variables" exact component={EnvironmentVariables} />
159159
<Route path="/preferences" exact component={Preferences} />
160160
<Route path="/install-github-app" exact component={InstallGitHubApp} />
@@ -184,6 +184,8 @@ function App() {
184184
<p className="mt-4 text-lg text-gitpod-red">{decodeURIComponent(getURLHash())}</p>
185185
</div>
186186
</Route>
187+
<Route path="/new-team" exact component={NewTeam} />
188+
<Route path="/join-team" exact component={JoinTeam} />
187189
{(teams || []).map(team => <Route path={`/${team.slug}`}>
188190
<Route exact path={`/${team.slug}`}>
189191
<Redirect to={`/${team.slug}/projects`} />
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { useContext, useEffect, useState } from "react";
8+
import { useHistory } from "react-router-dom";
9+
import { getGitpodService } from "../service/service";
10+
import { TeamsContext } from "./teams-context";
11+
12+
export default function() {
13+
const { setTeams } = useContext(TeamsContext);
14+
const history = useHistory();
15+
16+
const [ joinError, setJoinError ] = useState<Error>();
17+
const teamId = new URL(window.location.href).searchParams.get('teamId');
18+
19+
useEffect(() => {
20+
(async () => {
21+
try {
22+
if (!teamId) {
23+
throw new Error('This invite URL is incorrect: No team ID specified');
24+
}
25+
await getGitpodService().server.joinTeam(teamId);
26+
const teams = await getGitpodService().server.getTeams();
27+
const team = teams.find(t => t.id === teamId);
28+
if (!team) {
29+
throw new Error('Failed to join team. Please contact support.');
30+
}
31+
setTeams(teams);
32+
history.push(`/${team.slug}/members`);
33+
} catch (error) {
34+
console.error(error);
35+
setJoinError(error);
36+
}
37+
})();
38+
}, []);
39+
return <div className="mt-16 text-center text-gitpod-red">{String(joinError)}</div>
40+
}

components/dashboard/src/teams/Members.tsx

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import moment from "moment";
99
import { useContext, useEffect, useState } from "react";
1010
import { useLocation } from "react-router";
1111
import Header from "../components/Header";
12+
import DropDown from "../components/DropDown";
13+
import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList";
14+
import Modal from "../components/Modal";
1215
import { getGitpodService } from "../service/service";
16+
import copy from '../images/copy.svg';
1317
import { TeamsContext, getCurrentTeam } from "./teams-context";
1418

1519

@@ -18,6 +22,7 @@ export default function() {
1822
const location = useLocation();
1923
const team = getCurrentTeam(location, teams);
2024
const [ members, setMembers ] = useState<TeamMemberInfo[]>([]);
25+
const [ showInviteModal, setShowInviteModal ] = useState<boolean>(false);
2126

2227
useEffect(() => {
2328
if (!team) {
@@ -29,29 +34,105 @@ export default function() {
2934
})();
3035
}, [ team ]);
3136

37+
const getInviteURL = () => {
38+
const link = new URL(window.location.href);
39+
link.pathname = '/join-team';
40+
link.search = '?teamId=' + team?.id;
41+
return link.href;
42+
}
43+
44+
const [ copied, setCopied ] = useState<boolean>(false);
45+
const copyToClipboard = (text: string) => {
46+
const el = document.createElement("textarea");
47+
el.value = text;
48+
document.body.appendChild(el);
49+
el.select();
50+
try {
51+
document.execCommand("copy");
52+
} finally {
53+
document.body.removeChild(el);
54+
}
55+
setCopied(true);
56+
setTimeout(() => setCopied(false), 2000);
57+
};
58+
3259
return <>
3360
<Header title="Members" subtitle="Manage team members." />
3461
<div className="lg:px-28 px-10">
35-
<div className="mt-2 grid grid-cols-3 px-6 py-2 font-semibold border-t border-b border-gray-200 dark:border-gray-800">
36-
<p className="pl-14">Name</p>
37-
<p>Joined</p>
38-
<p>Role</p>
39-
</div>
40-
{members.map(m => <div className="mt-2 grid grid-cols-3 p-6 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl">
41-
<div className="flex items-center">
42-
<div className="w-14">{m.avatarUrl && <img className="rounded-full w-8 h-8" src={m.avatarUrl || ''} alt={m.fullName} />}</div>
43-
<div>
44-
<div className="text-base text-gray-900 dark:text-gray-50 font-medium">{m.fullName}</div>
45-
<p>{m.primaryEmail}</p>
62+
<div className="flex mt-8">
63+
<div className="flex">
64+
<div className="py-4">
65+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" width="16" height="16"><path fill="#A8A29E" d="M6 2a4 4 0 100 8 4 4 0 000-8zM0 6a6 6 0 1110.89 3.477l4.817 4.816a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 010 6z"/></svg>
4666
</div>
67+
<input type="search" placeholder="Search Members" onChange={() => { /* TODO */ }} />
4768
</div>
48-
<div className="flex items-center">
49-
<div className="text-gray-400">{moment(m.memberSince).fromNow()}</div>
50-
</div>
51-
<div className="flex items-center">
52-
<div className=" text-gray-400">Owner</div>
69+
<div className="flex-1" />
70+
<div className="py-3 pl-3">
71+
<DropDown prefix="Role: " contextMenuWidth="w-32" activeEntry={'All'} entries={[{
72+
title: 'All',
73+
onClick: () => { /* TODO */ }
74+
}, {
75+
title: 'Owner',
76+
onClick: () => { /* TODO */ }
77+
}, {
78+
title: 'Member',
79+
onClick: () => { /* TODO */ }
80+
}]} />
5381
</div>
54-
</div>)}
82+
<button onClick={() => setShowInviteModal(true)} className="ml-2">Invite Members</button>
83+
</div>
84+
<ItemsList className="mt-2">
85+
<Item header={true} className="grid grid-cols-3">
86+
<ItemField>
87+
<span className="pl-14">Name</span>
88+
</ItemField>
89+
<ItemField>
90+
<span>Joined</span>
91+
</ItemField>
92+
<ItemField className="flex items-center">
93+
<span className="flex-grow">Role</span>
94+
<ItemFieldContextMenu />
95+
</ItemField>
96+
</Item>
97+
{members.map(m => <Item className="grid grid-cols-3">
98+
<ItemField className="flex items-center">
99+
<div className="w-14">{m.avatarUrl && <img className="rounded-full w-8 h-8" src={m.avatarUrl || ''} alt={m.fullName} />}</div>
100+
<div>
101+
<div className="text-base text-gray-900 dark:text-gray-50 font-medium">{m.fullName}</div>
102+
<p>{m.primaryEmail}</p>
103+
</div>
104+
</ItemField>
105+
<ItemField>
106+
<span className="text-gray-400">{moment(m.memberSince).fromNow()}</span>
107+
</ItemField>
108+
<ItemField className="flex items-center">
109+
<span className="text-gray-400 flex-grow capitalize">{m.role}</span>
110+
<ItemFieldContextMenu menuEntries={[
111+
{
112+
title: 'Remove',
113+
customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300',
114+
onClick: () => { /* TODO(janx) */ }
115+
},
116+
]} />
117+
</ItemField>
118+
</Item>)}
119+
</ItemsList>
55120
</div>
121+
{showInviteModal && <Modal visible={true} onClose={() => setShowInviteModal(false)}>
122+
<h3 className="mb-4">Invite Members</h3>
123+
<div className="border-t border-b border-gray-200 dark:border-gray-800 -mx-6 px-6 py-4 flex flex-col">
124+
<label htmlFor="inviteUrl" className="font-medium">Invite URL</label>
125+
<div className="w-full relative">
126+
<input name="inviteUrl" disabled={true} readOnly={true} type="text" value={getInviteURL()} className="rounded-md w-full truncate pr-8" />
127+
<div className="cursor-pointer" onClick={() => copyToClipboard(getInviteURL())}>
128+
<img src={copy} title="Copy Invite URL" className="absolute top-1/3 right-3" />
129+
</div>
130+
</div>
131+
<p className="mt-1 text-gray-500 text-sm">{copied ? 'Copied to clipboard!' : 'Use this URL to join this team as a Member.'}</p>
132+
</div>
133+
<div className="flex justify-end mt-6">
134+
<button className="secondary" onClick={() => setShowInviteModal(false)}>Done</button>
135+
</div>
136+
</Modal>}
56137
</>;
57138
}

components/dashboard/src/workspaces/Workspaces.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,7 @@ export default class Workspaces extends React.Component<WorkspacesProps, Workspa
7070
<div className="lg:px-28 px-10 pt-8 flex">
7171
<div className="flex">
7272
<div className="py-4">
73-
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
74-
<path fillRule="evenodd" clipRule="evenodd" d="M6 2a4 4 0 100 8 4 4 0 000-8zM0 6a6 6 0 1110.89 3.477l4.817 4.816a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 010 6z" fill="#A8A29E"/>
75-
</svg>
73+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" width="16" height="16"><path fill="#A8A29E" d="M6 2a4 4 0 100 8 4 4 0 000-8zM0 6a6 6 0 1110.89 3.477l4.817 4.816a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 010 6z"/></svg>
7674
</div>
7775
<input type="search" placeholder="Search Workspaces" onChange={(v) => { if (wsModel) wsModel.setSearch(v.target.value) }} />
7876
</div>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export interface TeamDB {
1212
findMembersByTeam(teamId: string): Promise<TeamMemberInfo[]>;
1313
findTeamsByUser(userId: string): Promise<Team[]>;
1414
createTeam(userId: string, name: string): Promise<Team>;
15+
addMemberToTeam(userId: string, teamId: string): Promise<void>;
1516
}

components/gitpod-db/src/typeorm/entity/db-team-membership.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* See License-AGPL.txt in the project root for license information.
55
*/
66

7+
import { TeamMemberRole } from "@gitpod/gitpod-protocol";
78
import { Entity, Column, PrimaryColumn, Index } from "typeorm";
89
import { TypeORM } from "../typeorm";
910

@@ -21,6 +22,9 @@ export class DBTeamMembership {
2122
@Index("ind_userId")
2223
userId: string;
2324

25+
@Column("varchar")
26+
role: TeamMemberRole;
27+
2428
@Column("varchar")
2529
creationTime: string;
2630

components/gitpod-db/src/typeorm/migration/1622468446118-TeamsAndProjects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export class TeamsAndProjects1622468446118 implements MigrationInterface {
1010

1111
public async up(queryRunner: QueryRunner): Promise<any> {
1212
await queryRunner.query("CREATE TABLE IF NOT EXISTS `d_b_team` (`id` char(36) NOT NULL, `name` varchar(255) NOT NULL, `slug` varchar(255) NOT NULL, `creationTime` varchar(255) NOT NULL, `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), KEY `ind_dbsync` (`_lastModified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
13-
await queryRunner.query("CREATE TABLE IF NOT EXISTS `d_b_team_membership` (`id` char(36) NOT NULL, `teamId` char(36) NOT NULL, `userId` char(36) NOT NULL, `creationTime` varchar(255) NOT NULL, `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), KEY `ind_teamId` (`teamId`), KEY `ind_userId` (`userId`), KEY `ind_dbsync` (`_lastModified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
13+
await queryRunner.query("CREATE TABLE IF NOT EXISTS `d_b_team_membership` (`id` char(36) NOT NULL, `teamId` char(36) NOT NULL, `userId` char(36) NOT NULL, `role` varchar(255) NOT NULL, `creationTime` varchar(255) NOT NULL, `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), KEY `ind_teamId` (`teamId`), KEY `ind_userId` (`userId`), KEY `ind_dbsync` (`_lastModified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
1414
await queryRunner.query("CREATE TABLE IF NOT EXISTS `d_b_project` (`id` char(36) NOT NULL, `cloneUrl` varchar(255) NOT NULL, `teamId` char(36) NOT NULL, `appInstallationId` varchar(255) NOT NULL, `creationTime` varchar(255) NOT NULL, `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), KEY `ind_teamId` (`teamId`), KEY `ind_dbsync` (`_lastModified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
1515
}
1616

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class TeamDBImpl implements TeamDB {
4949
fullName: u.fullName || u.name,
5050
primaryEmail: User.getPrimaryEmail(u),
5151
avatarUrl: u.avatarUrl,
52+
role: memberships.find(m => m.userId === u.id)!.role,
5253
memberSince: u.creationDate,
5354
}));
5455
}
@@ -85,8 +86,32 @@ export class TeamDBImpl implements TeamDB {
8586
id: uuidv4(),
8687
teamId: team.id,
8788
userId,
89+
role: 'owner',
8890
creationTime: team.creationTime,
8991
});
9092
return team;
9193
}
94+
95+
public async addMemberToTeam(userId: string, teamId: string): Promise<void> {
96+
if (teamId.length !== 36) {
97+
throw new Error('This team ID is incorrect');
98+
}
99+
const teamRepo = await this.getTeamRepo();
100+
const team = await teamRepo.findOneById(teamId);
101+
if (!team) {
102+
throw new Error('A team with this ID could not be found');
103+
}
104+
const membershipRepo = await this.getMembershipRepo();
105+
const membership = await membershipRepo.findOne({ teamId, userId });
106+
if (!!membership) {
107+
throw new Error('You are already a member of this team');
108+
}
109+
await membershipRepo.save({
110+
id: uuidv4(),
111+
teamId: team.id,
112+
userId,
113+
role: 'member',
114+
creationTime: new Date().toISOString(),
115+
});
116+
}
92117
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
111111
getTeams(): Promise<Team[]>;
112112
getTeamMembers(teamId: string): Promise<TeamMemberInfo[]>;
113113
createTeam(name: string): Promise<Team>;
114+
joinTeam(teamId: string): Promise<void>;
114115

115116
// content service
116117
getContentBlobUploadUrl(name: string): Promise<string>

components/gitpod-protocol/src/protocol.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,10 +1197,13 @@ export interface Team {
11971197
deleted?: boolean;
11981198
}
11991199

1200+
export type TeamMemberRole = "owner" | "member";
1201+
12001202
export interface TeamMemberInfo {
12011203
userId: string;
12021204
fullName?: string;
12031205
primaryEmail?: string;
12041206
avatarUrl?: string;
1207+
role: TeamMemberRole;
12051208
memberSince: string;
12061209
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ function readConfig(): RateLimiterConfig {
8181
"getTeams": { group: "default", points: 1 },
8282
"getTeamMembers": { group: "default", points: 1 },
8383
"createTeam": { group: "default", points: 1 },
84+
"joinTeam": { group: "default", points: 1 },
8485
"getContentBlobUploadUrl": { group: "default", points: 1 },
8586
"getContentBlobDownloadUrl": { group: "default", points: 1 },
8687
"getGitpodTokens": { group: "default", points: 1 },

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,13 @@ export class GitpodServerImpl<Client extends GitpodClient, Server extends Gitpod
14061406
return this.teamDB.createTeam(user.id, name);
14071407
}
14081408

1409+
public async joinTeam(teamId: string): Promise<void> {
1410+
// TODO(janx): Any user who knows a team's "secret" UUID can join it. If this becomes a problem, we should
1411+
// look into generating (temporary and/or member-specific) invite codes.
1412+
const user = this.checkUser("joinTeam");
1413+
await this.teamDB.addMemberToTeam(user.id, teamId);
1414+
}
1415+
14091416
public async getContentBlobUploadUrl(name: string): Promise<string> {
14101417
const user = this.checkAndBlockUser("getContentBlobUploadUrl");
14111418
await this.guardAccess({ kind: "contentBlob", name: name, userID: user.id }, "create");

0 commit comments

Comments
 (0)