Skip to content

Commit

Permalink
[teams] Use invites that can be reset
Browse files Browse the repository at this point in the history
  • Loading branch information
svenefftinge committed Jun 15, 2021
1 parent 4394825 commit 99ec2a5
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 28 deletions.
12 changes: 4 additions & 8 deletions components/dashboard/src/teams/JoinTeam.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,16 @@ export default function() {
const history = useHistory();

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

useEffect(() => {
(async () => {
try {
if (!teamId) {
throw new Error('This invite URL is incorrect: No team ID specified');
if (!inviteId) {
throw new Error('This invite URL is incorrect.');
}
await getGitpodService().server.joinTeam(teamId);
const team = await getGitpodService().server.joinTeam(inviteId);
const teams = await getGitpodService().server.getTeams();
const team = teams.find(t => t.id === teamId);
if (!team) {
throw new Error('Failed to join team. Please contact support.');
}
setTeams(teams);
history.push(`/${team.slug}/members`);
} catch (error) {
Expand Down
33 changes: 24 additions & 9 deletions components/dashboard/src/teams/Members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* See License-AGPL.txt in the project root for license information.
*/

import { TeamMemberInfo } from "@gitpod/gitpod-protocol";
import { TeamMemberInfo, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
import moment from "moment";
import { useContext, useEffect, useState } from "react";
import { useLocation } from "react-router";
Expand All @@ -22,22 +22,27 @@ export default function() {
const location = useLocation();
const team = getCurrentTeam(location, teams);
const [ members, setMembers ] = useState<TeamMemberInfo[]>([]);
const [ genericInvite, setGenericInvite ] = useState<TeamMembershipInvite>();
const [ showInviteModal, setShowInviteModal ] = useState<boolean>(false);

useEffect(() => {
if (!team) {
return;
}
(async () => {
const infos = await getGitpodService().server.getTeamMembers(team.id);
const [infos, invite] = await Promise.all([
getGitpodService().server.getTeamMembers(team.id),
getGitpodService().server.getGenericInvite(team.id)]);

setMembers(infos);
setGenericInvite(invite);
})();
}, [ team ]);

const getInviteURL = () => {
const getInviteURL = (inviteId: string) => {
const link = new URL(window.location.href);
link.pathname = '/join-team';
link.search = '?teamId=' + team?.id;
link.search = '?inviteId=' + inviteId;
return link.href;
}

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

const resetInviteLink = async () => {
// reset genericInvite first to prevent races on double click
if (genericInvite) {
setGenericInvite(undefined);
const newInvite = await getGitpodService().server.resetGenericInvite(team!.id);
setGenericInvite(newInvite);
}
}

return <>
<Header title="Members" subtitle="Manage team members." />
<div className="lg:px-28 px-10">
Expand Down Expand Up @@ -118,20 +132,21 @@ export default function() {
</Item>)}
</ItemsList>
</div>
{showInviteModal && <Modal visible={true} onClose={() => setShowInviteModal(false)}>
{genericInvite && showInviteModal && <Modal visible={true} onClose={() => setShowInviteModal(false)}>
<h3 className="mb-4">Invite Members</h3>
<div className="border-t border-b border-gray-200 dark:border-gray-800 -mx-6 px-6 py-4 flex flex-col">
<label htmlFor="inviteUrl" className="font-medium">Invite URL</label>
<div className="w-full relative">
<input name="inviteUrl" disabled={true} readOnly={true} type="text" value={getInviteURL()} className="rounded-md w-full truncate pr-8" />
<div className="cursor-pointer" onClick={() => copyToClipboard(getInviteURL())}>
<input name="inviteUrl" disabled={true} readOnly={true} type="text" value={getInviteURL(genericInvite.id)} className="rounded-md w-full truncate pr-8" />
<div className="cursor-pointer" onClick={() => copyToClipboard(getInviteURL(genericInvite.id))}>
<img src={copy} title="Copy Invite URL" className="absolute top-1/3 right-3" />
</div>
</div>
<p className="mt-1 text-gray-500 text-sm">{copied ? 'Copied to clipboard!' : 'Use this URL to join this team as a Member.'}</p>
</div>
<div className="flex justify-end mt-6">
<button className="secondary" onClick={() => setShowInviteModal(false)}>Done</button>
<div className="flex justify-end mt-6 space-x-2">
<button className="secondary" onClick={() => resetInviteLink()}>Reset Invite Link</button>
<button className="secondary" onClick={() => setShowInviteModal(false)}>Close</button>
</div>
</Modal>}
</>;
Expand Down
7 changes: 5 additions & 2 deletions components/gitpod-db/src/team-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* See License.enterprise.txt in the project root folder.
*/

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

export const TeamDB = Symbol('TeamDB');
export interface TeamDB {
Expand All @@ -13,4 +13,7 @@ export interface TeamDB {
findTeamsByUser(userId: string): Promise<Team[]>;
createTeam(userId: string, name: string): Promise<Team>;
addMemberToTeam(userId: string, teamId: string): Promise<void>;
}
findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite>;
findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite | undefined>;
resetGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { TeamMemberRole } from "@gitpod/gitpod-protocol";
import { Entity, Column, PrimaryColumn, Index } from "typeorm";
import { Transformer } from "../transformer";
import { TypeORM } from "../typeorm";

@Entity()
// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
export class DBTeamMembershipInvite {
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
id: string;

@Column(TypeORM.UUID_COLUMN_TYPE)
@Index("ind_teamId")
teamId: string;

@Column("varchar")
role: TeamMemberRole;

@Column("varchar")
creationTime: string;

@Column("varchar")
invalidationTime: string;

@Column({
default: '',
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED
})
invitedEmail?: string;

// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
@Column()
deleted: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { MigrationInterface, QueryRunner } from "typeorm";

export class TeamsMembershipInvite1623652164639 implements MigrationInterface {

public async up(queryRunner: QueryRunner): Promise<any> {
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;");
}

public async down(queryRunner: QueryRunner): Promise<any> {
// this is a one-way idempotent 'migration', no rollback possible for a nonempty DB
}

}
44 changes: 42 additions & 2 deletions components/gitpod-db/src/typeorm/team-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* See License-AGPL.txt in the project root for license information.
*/

import { Team, TeamMemberInfo, User } from "@gitpod/gitpod-protocol";
import { Team, TeamMemberInfo, TeamMembershipInvite, User } from "@gitpod/gitpod-protocol";
import { inject, injectable } from "inversify";
import { TypeORM } from "./typeorm";
import { Repository } from "typeorm";
Expand All @@ -13,6 +13,7 @@ import { TeamDB } from "../team-db";
import { DBTeam } from "./entity/db-team";
import { DBTeamMembership } from "./entity/db-team-membership";
import { DBUser } from "./entity/db-user";
import { DBTeamMembershipInvite } from "./entity/db-team-membership-invite";

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

protected async getMembershipInviteRepo(): Promise<Repository<DBTeamMembershipInvite>> {
return (await this.getEntityManager()).getRepository<DBTeamMembershipInvite>(DBTeamMembershipInvite);
}

protected async getUserRepo(): Promise<Repository<DBUser>> {
return (await this.getEntityManager()).getRepository<DBUser>(DBUser);
}
Expand All @@ -44,14 +49,15 @@ export class TeamDBImpl implements TeamDB {
const userRepo = await this.getUserRepo();
const memberships = await membershipRepo.find({ teamId });
const users = await userRepo.findByIds(memberships.map(m => m.userId));
return users.map(u => ({
const infos = users.map(u => ({
userId: u.id,
fullName: u.fullName || u.name,
primaryEmail: User.getPrimaryEmail(u),
avatarUrl: u.avatarUrl,
role: memberships.find(m => m.userId === u.id)!.role,
memberSince: u.creationDate,
}));
return infos.sort((a,b) => a.memberSince < b.memberSince ? 1 : (a.memberSince === b.memberSince ? 0 : -1));
}

public async findTeamsByUser(userId: string): Promise<Team[]> {
Expand Down Expand Up @@ -114,4 +120,38 @@ export class TeamDBImpl implements TeamDB {
creationTime: new Date().toISOString(),
});
}

public async findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite> {
const inviteRepo = await this.getMembershipInviteRepo();
const invite = await inviteRepo.findOneById(inviteId);
if (!invite) {
throw new Error('No invite found for the given ID.');
}
return invite;
}

public async findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite| undefined> {
const inviteRepo = await this.getMembershipInviteRepo();
const all = await inviteRepo.find({ teamId });
return all.filter(i => i.invalidationTime === '' && !i.invitedEmail)[0];
}

public async resetGenericInvite(teamId: string): Promise<TeamMembershipInvite> {
const inviteRepo = await this.getMembershipInviteRepo();
const invite = await this.findGenericInviteByTeamId(teamId);
if (invite && invite.invalidationTime === '') {
invite.invalidationTime = new Date().toISOString();
await inviteRepo.save(invite);
}

const newInvite :TeamMembershipInvite = {
id: uuidv4(),
creationTime: new Date().toISOString(),
invalidationTime: '',
role: 'member',
teamId
}
await inviteRepo.save(newInvite);
return newInvite;
}
}
6 changes: 4 additions & 2 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
WhitelistedRepository, WorkspaceImageBuild, AuthProviderInfo, Branding, CreateWorkspaceMode,
Token, UserEnvVarValue, ResolvePluginsParams, PreparePluginUploadParams, Terms,
ResolvedPlugins, Configuration, InstallPluginsParams, UninstallPluginParams, UserInfo, GitpodTokenType,
GitpodToken, AuthProviderEntry, GuessGitTokenScopesParams, GuessedGitTokenScopes, Team, TeamMemberInfo
GitpodToken, AuthProviderEntry, GuessGitTokenScopesParams, GuessedGitTokenScopes, Team, TeamMemberInfo, TeamMembershipInvite
} from './protocol';
import { JsonRpcProxy, JsonRpcServer } from './messaging/proxy-factory';
import { Disposable, CancellationTokenSource } from 'vscode-jsonrpc';
Expand Down Expand Up @@ -111,7 +111,9 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
getTeams(): Promise<Team[]>;
getTeamMembers(teamId: string): Promise<TeamMemberInfo[]>;
createTeam(name: string): Promise<Team>;
joinTeam(teamId: string): Promise<void>;
joinTeam(inviteId: string): Promise<Team>;
getGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
resetGenericInvite(inviteId: string): Promise<TeamMembershipInvite>;

// content service
getContentBlobUploadUrl(name: string): Promise<string>
Expand Down
12 changes: 12 additions & 0 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1207,3 +1207,15 @@ export interface TeamMemberInfo {
role: TeamMemberRole;
memberSince: string;
}

export interface TeamMembershipInvite {
id: string;
teamId: string;
role: TeamMemberRole;
creationTime: string;
invalidationTime: string;
invitedEmail?: string;

/** This is a flag that triggers the HARD DELETION of this entity */
deleted?: boolean;
}
2 changes: 2 additions & 0 deletions components/server/src/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ function readConfig(): RateLimiterConfig {
"getTeamMembers": { group: "default", points: 1 },
"createTeam": { group: "default", points: 1 },
"joinTeam": { group: "default", points: 1 },
"getGenericInvite": { group: "default", points: 1 },
"resetGenericInvite": { group: "default", points: 1 },
"getContentBlobUploadUrl": { group: "default", points: 1 },
"getContentBlobDownloadUrl": { group: "default", points: 1 },
"getGitpodTokens": { group: "default", points: 1 },
Expand Down
39 changes: 34 additions & 5 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { BlobServiceClient } from "@gitpod/content-service/lib/blobs_grpc_pb";
import { DownloadUrlRequest, DownloadUrlResponse, UploadUrlRequest, UploadUrlResponse } from '@gitpod/content-service/lib/blobs_pb';
import { AppInstallationDB, UserDB, UserMessageViewsDB, WorkspaceDB, DBWithTracing, TracedWorkspaceDB, DBGitpodToken, DBUser, UserStorageResourcesDB, ProjectDB, TeamDB } from '@gitpod/gitpod-db/lib';
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';
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';
import { AccountStatement } from "@gitpod/gitpod-protocol/lib/accounting-protocol";
import { AdminBlockUserRequest, AdminGetListRequest, AdminGetListResult, AdminGetWorkspacesRequest, AdminModifyPermanentWorkspaceFeatureFlagRequest, AdminModifyRoleOrPermissionRequest, WorkspaceAndInstance } from '@gitpod/gitpod-protocol/lib/admin-protocol';
import { GetLicenseInfoResult, LicenseFeature, LicenseValidationResult } from '@gitpod/gitpod-protocol/lib/license-protocol';
Expand Down Expand Up @@ -1406,11 +1406,40 @@ export class GitpodServerImpl<Client extends GitpodClient, Server extends Gitpod
return this.teamDB.createTeam(user.id, name);
}

public async joinTeam(teamId: string): Promise<void> {
// TODO(janx): Any user who knows a team's "secret" UUID can join it. If this becomes a problem, we should
// look into generating (temporary and/or member-specific) invite codes.
public async joinTeam(inviteId: string): Promise<Team> {
const user = this.checkUser("joinTeam");
await this.teamDB.addMemberToTeam(user.id, teamId);
const invite = await this.teamDB.findTeamMembershipInviteById(inviteId);
if (!invite || invite.invalidationTime !== '') {
throw new ResponseError(ErrorCodes.NOT_FOUND, "The invite link is no longer valid.");
}
await this.teamDB.addMemberToTeam(user.id, invite.teamId);
const team = await this.teamDB.findTeamById(invite.teamId);
return team!;
}

public async getGenericInvite(teamId: string): Promise<TeamMembershipInvite> {
this.checkUser("getGenericInvite");
await this.guardTeamOperation(teamId, "get");
const invite = await this.teamDB.findGenericInviteByTeamId(teamId);
if (invite) {
return invite;
}
return this.teamDB.resetGenericInvite(teamId);
}

public async resetGenericInvite(teamId: string): Promise<TeamMembershipInvite> {
this.checkUser("resetGenericInvite");
await this.guardTeamOperation(teamId, "update");
return this.teamDB.resetGenericInvite(teamId);
}

protected async guardTeamOperation(teamId: string, op: ResourceAccessOp): Promise<void> {
const team = await this.teamDB.findTeamById(teamId);
if (!team) {
throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found");
}
const members = await this.teamDB.findMembersByTeam(team.id);
await this.guardAccess({ kind: "team", subject: team, members }, op);
}

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

0 comments on commit 99ec2a5

Please sign in to comment.