Skip to content

Commit 99ec2a5

Browse files
committed
[teams] Use invites that can be reset
1 parent 4394825 commit 99ec2a5

File tree

10 files changed

+186
-28
lines changed

10 files changed

+186
-28
lines changed

components/dashboard/src/teams/JoinTeam.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,16 @@ export default function() {
1414
const history = useHistory();
1515

1616
const [ joinError, setJoinError ] = useState<Error>();
17-
const teamId = new URL(window.location.href).searchParams.get('teamId');
17+
const inviteId = new URL(window.location.href).searchParams.get('inviteId');
1818

1919
useEffect(() => {
2020
(async () => {
2121
try {
22-
if (!teamId) {
23-
throw new Error('This invite URL is incorrect: No team ID specified');
22+
if (!inviteId) {
23+
throw new Error('This invite URL is incorrect.');
2424
}
25-
await getGitpodService().server.joinTeam(teamId);
25+
const team = await getGitpodService().server.joinTeam(inviteId);
2626
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-
}
3127
setTeams(teams);
3228
history.push(`/${team.slug}/members`);
3329
} catch (error) {

components/dashboard/src/teams/Members.tsx

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

7-
import { TeamMemberInfo } from "@gitpod/gitpod-protocol";
7+
import { TeamMemberInfo, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
88
import moment from "moment";
99
import { useContext, useEffect, useState } from "react";
1010
import { useLocation } from "react-router";
@@ -22,22 +22,27 @@ export default function() {
2222
const location = useLocation();
2323
const team = getCurrentTeam(location, teams);
2424
const [ members, setMembers ] = useState<TeamMemberInfo[]>([]);
25+
const [ genericInvite, setGenericInvite ] = useState<TeamMembershipInvite>();
2526
const [ showInviteModal, setShowInviteModal ] = useState<boolean>(false);
2627

2728
useEffect(() => {
2829
if (!team) {
2930
return;
3031
}
3132
(async () => {
32-
const infos = await getGitpodService().server.getTeamMembers(team.id);
33+
const [infos, invite] = await Promise.all([
34+
getGitpodService().server.getTeamMembers(team.id),
35+
getGitpodService().server.getGenericInvite(team.id)]);
36+
3337
setMembers(infos);
38+
setGenericInvite(invite);
3439
})();
3540
}, [ team ]);
3641

37-
const getInviteURL = () => {
42+
const getInviteURL = (inviteId: string) => {
3843
const link = new URL(window.location.href);
3944
link.pathname = '/join-team';
40-
link.search = '?teamId=' + team?.id;
45+
link.search = '?inviteId=' + inviteId;
4146
return link.href;
4247
}
4348

@@ -56,6 +61,15 @@ export default function() {
5661
setTimeout(() => setCopied(false), 2000);
5762
};
5863

64+
const resetInviteLink = async () => {
65+
// reset genericInvite first to prevent races on double click
66+
if (genericInvite) {
67+
setGenericInvite(undefined);
68+
const newInvite = await getGitpodService().server.resetGenericInvite(team!.id);
69+
setGenericInvite(newInvite);
70+
}
71+
}
72+
5973
return <>
6074
<Header title="Members" subtitle="Manage team members." />
6175
<div className="lg:px-28 px-10">
@@ -118,20 +132,21 @@ export default function() {
118132
</Item>)}
119133
</ItemsList>
120134
</div>
121-
{showInviteModal && <Modal visible={true} onClose={() => setShowInviteModal(false)}>
135+
{genericInvite && showInviteModal && <Modal visible={true} onClose={() => setShowInviteModal(false)}>
122136
<h3 className="mb-4">Invite Members</h3>
123137
<div className="border-t border-b border-gray-200 dark:border-gray-800 -mx-6 px-6 py-4 flex flex-col">
124138
<label htmlFor="inviteUrl" className="font-medium">Invite URL</label>
125139
<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())}>
140+
<input name="inviteUrl" disabled={true} readOnly={true} type="text" value={getInviteURL(genericInvite.id)} className="rounded-md w-full truncate pr-8" />
141+
<div className="cursor-pointer" onClick={() => copyToClipboard(getInviteURL(genericInvite.id))}>
128142
<img src={copy} title="Copy Invite URL" className="absolute top-1/3 right-3" />
129143
</div>
130144
</div>
131145
<p className="mt-1 text-gray-500 text-sm">{copied ? 'Copied to clipboard!' : 'Use this URL to join this team as a Member.'}</p>
132146
</div>
133-
<div className="flex justify-end mt-6">
134-
<button className="secondary" onClick={() => setShowInviteModal(false)}>Done</button>
147+
<div className="flex justify-end mt-6 space-x-2">
148+
<button className="secondary" onClick={() => resetInviteLink()}>Reset Invite Link</button>
149+
<button className="secondary" onClick={() => setShowInviteModal(false)}>Close</button>
135150
</div>
136151
</Modal>}
137152
</>;

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* See License.enterprise.txt in the project root folder.
55
*/
66

7-
import { Team, TeamMemberInfo } from "@gitpod/gitpod-protocol";
7+
import { Team, TeamMemberInfo, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
88

99
export const TeamDB = Symbol('TeamDB');
1010
export interface TeamDB {
@@ -13,4 +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-
}
16+
findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite>;
17+
findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite | undefined>;
18+
resetGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
19+
}
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 { TeamMemberRole } from "@gitpod/gitpod-protocol";
8+
import { Entity, Column, PrimaryColumn, Index } from "typeorm";
9+
import { Transformer } from "../transformer";
10+
import { TypeORM } from "../typeorm";
11+
12+
@Entity()
13+
// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
14+
export class DBTeamMembershipInvite {
15+
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
16+
id: string;
17+
18+
@Column(TypeORM.UUID_COLUMN_TYPE)
19+
@Index("ind_teamId")
20+
teamId: string;
21+
22+
@Column("varchar")
23+
role: TeamMemberRole;
24+
25+
@Column("varchar")
26+
creationTime: string;
27+
28+
@Column("varchar")
29+
invalidationTime: string;
30+
31+
@Column({
32+
default: '',
33+
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED
34+
})
35+
invitedEmail?: string;
36+
37+
// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
38+
@Column()
39+
deleted: boolean;
40+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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 { MigrationInterface, QueryRunner } from "typeorm";
8+
9+
export class TeamsMembershipInvite1623652164639 implements MigrationInterface {
10+
11+
public async up(queryRunner: QueryRunner): Promise<any> {
12+
await queryRunner.query("CREATE TABLE IF NOT EXISTS `d_b_team_membership_invite` (`id` char(36) NOT NULL, `teamId` char(36) NOT NULL, `role` varchar(255) NOT NULL, `creationTime` varchar(255) NOT NULL, `invalidationTime` varchar(255) NOT NULL DEFAULT '', `invitedEmail` varchar(255) NOT NULL DEFAULT '', `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;");
13+
}
14+
15+
public async down(queryRunner: QueryRunner): Promise<any> {
16+
// this is a one-way idempotent 'migration', no rollback possible for a nonempty DB
17+
}
18+
19+
}

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

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

7-
import { Team, TeamMemberInfo, User } from "@gitpod/gitpod-protocol";
7+
import { Team, TeamMemberInfo, TeamMembershipInvite, User } from "@gitpod/gitpod-protocol";
88
import { inject, injectable } from "inversify";
99
import { TypeORM } from "./typeorm";
1010
import { Repository } from "typeorm";
@@ -13,6 +13,7 @@ import { TeamDB } from "../team-db";
1313
import { DBTeam } from "./entity/db-team";
1414
import { DBTeamMembership } from "./entity/db-team-membership";
1515
import { DBUser } from "./entity/db-user";
16+
import { DBTeamMembershipInvite } from "./entity/db-team-membership-invite";
1617

1718
@injectable()
1819
export class TeamDBImpl implements TeamDB {
@@ -30,6 +31,10 @@ export class TeamDBImpl implements TeamDB {
3031
return (await this.getEntityManager()).getRepository<DBTeamMembership>(DBTeamMembership);
3132
}
3233

34+
protected async getMembershipInviteRepo(): Promise<Repository<DBTeamMembershipInvite>> {
35+
return (await this.getEntityManager()).getRepository<DBTeamMembershipInvite>(DBTeamMembershipInvite);
36+
}
37+
3338
protected async getUserRepo(): Promise<Repository<DBUser>> {
3439
return (await this.getEntityManager()).getRepository<DBUser>(DBUser);
3540
}
@@ -44,14 +49,15 @@ export class TeamDBImpl implements TeamDB {
4449
const userRepo = await this.getUserRepo();
4550
const memberships = await membershipRepo.find({ teamId });
4651
const users = await userRepo.findByIds(memberships.map(m => m.userId));
47-
return users.map(u => ({
52+
const infos = users.map(u => ({
4853
userId: u.id,
4954
fullName: u.fullName || u.name,
5055
primaryEmail: User.getPrimaryEmail(u),
5156
avatarUrl: u.avatarUrl,
5257
role: memberships.find(m => m.userId === u.id)!.role,
5358
memberSince: u.creationDate,
5459
}));
60+
return infos.sort((a,b) => a.memberSince < b.memberSince ? 1 : (a.memberSince === b.memberSince ? 0 : -1));
5561
}
5662

5763
public async findTeamsByUser(userId: string): Promise<Team[]> {
@@ -114,4 +120,38 @@ export class TeamDBImpl implements TeamDB {
114120
creationTime: new Date().toISOString(),
115121
});
116122
}
123+
124+
public async findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite> {
125+
const inviteRepo = await this.getMembershipInviteRepo();
126+
const invite = await inviteRepo.findOneById(inviteId);
127+
if (!invite) {
128+
throw new Error('No invite found for the given ID.');
129+
}
130+
return invite;
131+
}
132+
133+
public async findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite| undefined> {
134+
const inviteRepo = await this.getMembershipInviteRepo();
135+
const all = await inviteRepo.find({ teamId });
136+
return all.filter(i => i.invalidationTime === '' && !i.invitedEmail)[0];
137+
}
138+
139+
public async resetGenericInvite(teamId: string): Promise<TeamMembershipInvite> {
140+
const inviteRepo = await this.getMembershipInviteRepo();
141+
const invite = await this.findGenericInviteByTeamId(teamId);
142+
if (invite && invite.invalidationTime === '') {
143+
invite.invalidationTime = new Date().toISOString();
144+
await inviteRepo.save(invite);
145+
}
146+
147+
const newInvite :TeamMembershipInvite = {
148+
id: uuidv4(),
149+
creationTime: new Date().toISOString(),
150+
invalidationTime: '',
151+
role: 'member',
152+
teamId
153+
}
154+
await inviteRepo.save(newInvite);
155+
return newInvite;
156+
}
117157
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
WhitelistedRepository, WorkspaceImageBuild, AuthProviderInfo, Branding, CreateWorkspaceMode,
1010
Token, UserEnvVarValue, ResolvePluginsParams, PreparePluginUploadParams, Terms,
1111
ResolvedPlugins, Configuration, InstallPluginsParams, UninstallPluginParams, UserInfo, GitpodTokenType,
12-
GitpodToken, AuthProviderEntry, GuessGitTokenScopesParams, GuessedGitTokenScopes, Team, TeamMemberInfo
12+
GitpodToken, AuthProviderEntry, GuessGitTokenScopesParams, GuessedGitTokenScopes, Team, TeamMemberInfo, TeamMembershipInvite
1313
} from './protocol';
1414
import { JsonRpcProxy, JsonRpcServer } from './messaging/proxy-factory';
1515
import { Disposable, CancellationTokenSource } from 'vscode-jsonrpc';
@@ -111,7 +111,9 @@ 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>;
114+
joinTeam(inviteId: string): Promise<Team>;
115+
getGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
116+
resetGenericInvite(inviteId: string): Promise<TeamMembershipInvite>;
115117

116118
// content service
117119
getContentBlobUploadUrl(name: string): Promise<string>

components/gitpod-protocol/src/protocol.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,3 +1207,15 @@ export interface TeamMemberInfo {
12071207
role: TeamMemberRole;
12081208
memberSince: string;
12091209
}
1210+
1211+
export interface TeamMembershipInvite {
1212+
id: string;
1213+
teamId: string;
1214+
role: TeamMemberRole;
1215+
creationTime: string;
1216+
invalidationTime: string;
1217+
invitedEmail?: string;
1218+
1219+
/** This is a flag that triggers the HARD DELETION of this entity */
1220+
deleted?: boolean;
1221+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ function readConfig(): RateLimiterConfig {
8282
"getTeamMembers": { group: "default", points: 1 },
8383
"createTeam": { group: "default", points: 1 },
8484
"joinTeam": { group: "default", points: 1 },
85+
"getGenericInvite": { group: "default", points: 1 },
86+
"resetGenericInvite": { group: "default", points: 1 },
8587
"getContentBlobUploadUrl": { group: "default", points: 1 },
8688
"getContentBlobDownloadUrl": { group: "default", points: 1 },
8789
"getGitpodTokens": { group: "default", points: 1 },

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

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { BlobServiceClient } from "@gitpod/content-service/lib/blobs_grpc_pb";
88
import { DownloadUrlRequest, DownloadUrlResponse, UploadUrlRequest, UploadUrlResponse } from '@gitpod/content-service/lib/blobs_pb';
99
import { AppInstallationDB, UserDB, UserMessageViewsDB, WorkspaceDB, DBWithTracing, TracedWorkspaceDB, DBGitpodToken, DBUser, UserStorageResourcesDB, ProjectDB, TeamDB } from '@gitpod/gitpod-db/lib';
10-
import { AuthProviderEntry, AuthProviderInfo, Branding, CommitContext, Configuration, CreateWorkspaceMode, DisposableCollection, GetWorkspaceTimeoutResult, GitpodClient, GitpodServer, GitpodToken, GitpodTokenType, InstallPluginsParams, PermissionName, PortVisibility, PrebuiltWorkspace, PrebuiltWorkspaceContext, PreparePluginUploadParams, ResolvedPlugins, ResolvePluginsParams, SetWorkspaceTimeoutResult, StartPrebuildContext, StartWorkspaceResult, Terms, Token, UninstallPluginParams, User, UserEnvVar, UserEnvVarValue, UserInfo, WhitelistedRepository, Workspace, WorkspaceContext, WorkspaceCreationResult, WorkspaceImageBuild, WorkspaceInfo, WorkspaceInstance, WorkspaceInstancePort, WorkspaceInstanceUser, WorkspaceTimeoutDuration, GuessGitTokenScopesParams, GuessedGitTokenScopes, Team, TeamMemberInfo } from '@gitpod/gitpod-protocol';
10+
import { AuthProviderEntry, AuthProviderInfo, Branding, CommitContext, Configuration, CreateWorkspaceMode, DisposableCollection, GetWorkspaceTimeoutResult, GitpodClient, GitpodServer, GitpodToken, GitpodTokenType, InstallPluginsParams, PermissionName, PortVisibility, PrebuiltWorkspace, PrebuiltWorkspaceContext, PreparePluginUploadParams, ResolvedPlugins, ResolvePluginsParams, SetWorkspaceTimeoutResult, StartPrebuildContext, StartWorkspaceResult, Terms, Token, UninstallPluginParams, User, UserEnvVar, UserEnvVarValue, UserInfo, WhitelistedRepository, Workspace, WorkspaceContext, WorkspaceCreationResult, WorkspaceImageBuild, WorkspaceInfo, WorkspaceInstance, WorkspaceInstancePort, WorkspaceInstanceUser, WorkspaceTimeoutDuration, GuessGitTokenScopesParams, GuessedGitTokenScopes, Team, TeamMemberInfo, TeamMembershipInvite } from '@gitpod/gitpod-protocol';
1111
import { AccountStatement } from "@gitpod/gitpod-protocol/lib/accounting-protocol";
1212
import { AdminBlockUserRequest, AdminGetListRequest, AdminGetListResult, AdminGetWorkspacesRequest, AdminModifyPermanentWorkspaceFeatureFlagRequest, AdminModifyRoleOrPermissionRequest, WorkspaceAndInstance } from '@gitpod/gitpod-protocol/lib/admin-protocol';
1313
import { GetLicenseInfoResult, LicenseFeature, LicenseValidationResult } from '@gitpod/gitpod-protocol/lib/license-protocol';
@@ -1406,11 +1406,40 @@ 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.
1409+
public async joinTeam(inviteId: string): Promise<Team> {
14121410
const user = this.checkUser("joinTeam");
1413-
await this.teamDB.addMemberToTeam(user.id, teamId);
1411+
const invite = await this.teamDB.findTeamMembershipInviteById(inviteId);
1412+
if (!invite || invite.invalidationTime !== '') {
1413+
throw new ResponseError(ErrorCodes.NOT_FOUND, "The invite link is no longer valid.");
1414+
}
1415+
await this.teamDB.addMemberToTeam(user.id, invite.teamId);
1416+
const team = await this.teamDB.findTeamById(invite.teamId);
1417+
return team!;
1418+
}
1419+
1420+
public async getGenericInvite(teamId: string): Promise<TeamMembershipInvite> {
1421+
this.checkUser("getGenericInvite");
1422+
await this.guardTeamOperation(teamId, "get");
1423+
const invite = await this.teamDB.findGenericInviteByTeamId(teamId);
1424+
if (invite) {
1425+
return invite;
1426+
}
1427+
return this.teamDB.resetGenericInvite(teamId);
1428+
}
1429+
1430+
public async resetGenericInvite(teamId: string): Promise<TeamMembershipInvite> {
1431+
this.checkUser("resetGenericInvite");
1432+
await this.guardTeamOperation(teamId, "update");
1433+
return this.teamDB.resetGenericInvite(teamId);
1434+
}
1435+
1436+
protected async guardTeamOperation(teamId: string, op: ResourceAccessOp): Promise<void> {
1437+
const team = await this.teamDB.findTeamById(teamId);
1438+
if (!team) {
1439+
throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found");
1440+
}
1441+
const members = await this.teamDB.findMembersByTeam(team.id);
1442+
await this.guardAccess({ kind: "team", subject: team, members }, op);
14141443
}
14151444

14161445
public async getContentBlobUploadUrl(name: string): Promise<string> {

0 commit comments

Comments
 (0)