From 1c90cb2d3a3659009aadf472607e0039868ab3b3 Mon Sep 17 00:00:00 2001 From: Sven Efftinge Date: Mon, 16 Jan 2023 13:47:47 +0000 Subject: [PATCH 1/4] [server] Added updateTeam call see #5067 --- components/gitpod-db/src/team-db.ts | 1 + .../src/typeorm/team-db-impl.spec.db.ts | 45 +++++++++++++++++++ .../gitpod-db/src/typeorm/team-db-impl.ts | 14 ++++++ .../gitpod-protocol/src/gitpod-service.ts | 1 + components/server/src/auth/rate-limiter.ts | 1 + .../src/workspace/gitpod-server-impl.ts | 18 ++++++++ 6 files changed, 80 insertions(+) create mode 100644 components/gitpod-db/src/typeorm/team-db-impl.spec.db.ts diff --git a/components/gitpod-db/src/team-db.ts b/components/gitpod-db/src/team-db.ts index fe2f6fa7955482..efc1b583a97fa6 100644 --- a/components/gitpod-db/src/team-db.ts +++ b/components/gitpod-db/src/team-db.ts @@ -23,6 +23,7 @@ export interface TeamDB { findTeamsByUser(userId: string): Promise; findTeamsByUserAsSoleOwner(userId: string): Promise; createTeam(userId: string, name: string): Promise; + updateTeam(teamId: string, team: Partial>): Promise; addMemberToTeam(userId: string, teamId: string): Promise<"added" | "already_member">; setTeamMemberRole(userId: string, teamId: string, role: TeamMemberRole): Promise; setTeamMemberSubscription(userId: string, teamId: string, subscriptionId: string): Promise; diff --git a/components/gitpod-db/src/typeorm/team-db-impl.spec.db.ts b/components/gitpod-db/src/typeorm/team-db-impl.spec.db.ts new file mode 100644 index 00000000000000..fea36717ca257a --- /dev/null +++ b/components/gitpod-db/src/typeorm/team-db-impl.spec.db.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2022 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 { suite, test, timeout } from "mocha-typescript"; +import { testContainer } from "../test-container"; +import { UserDB } from "../user-db"; +import { TypeORM } from "./typeorm"; +import { DBUser } from "./entity/db-user"; +import * as chai from "chai"; +import { TeamDB } from "../team-db"; +import { DBTeam } from "./entity/db-team"; +const expect = chai.expect; + +@suite(timeout(10000)) +export class TeamDBSpec { + private readonly teamDB = testContainer.get(TeamDB); + private readonly userDB = testContainer.get(UserDB); + + async before() { + await this.wipeRepo(); + } + + async after() { + await this.wipeRepo(); + } + + async wipeRepo() { + const typeorm = testContainer.get(TypeORM); + const manager = await typeorm.getConnection(); + await manager.getRepository(DBTeam).delete({}); + await manager.getRepository(DBUser).delete({}); + } + + @test() + async testPersistAndUpdate(): Promise { + const user = await this.userDB.newUser(); + let team = await this.teamDB.createTeam(user.id, "Test Team"); + team.name = "Test Team 2"; + team = await this.teamDB.updateTeam(team.id, team); + expect(team.name).to.be.eq("Test Team 2"); + } +} diff --git a/components/gitpod-db/src/typeorm/team-db-impl.ts b/components/gitpod-db/src/typeorm/team-db-impl.ts index ec1f91498e8c36..f3768a4d2147fc 100644 --- a/components/gitpod-db/src/typeorm/team-db-impl.ts +++ b/components/gitpod-db/src/typeorm/team-db-impl.ts @@ -122,6 +122,20 @@ export class TeamDBImpl implements TeamDB { return soleOwnedTeams; } + public async updateTeam(teamId: string, team: Partial>): Promise { + const teamRepo = await this.getTeamRepo(); + const existingTeam = await teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false }); + if (!existingTeam) { + throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found"); + } + const name = team.name && team.name.trim(); + if (!name || name.length === 0 || name.length > 32) { + throw new ResponseError(ErrorCodes.INVALID_VALUE, "A team's name must be between 1 and 32 characters long"); + } + existingTeam.name = name; + return teamRepo.save(existingTeam); + } + public async createTeam(userId: string, name: string): Promise { if (!name) { throw new ResponseError(ErrorCodes.BAD_REQUEST, "Team name cannot be empty"); diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 6d4438890b2a79..381f44b8cd9925 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -165,6 +165,7 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, // Teams getTeam(teamId: string): Promise; + updateTeam(teamId: string, team: Partial>): Promise; getTeams(): Promise; getTeamMembers(teamId: string): Promise; createTeam(name: string): Promise; diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index dfed65a9c5e3b4..8466fca3d74055 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -97,6 +97,7 @@ const defaultFunctions: FunctionsConfig = { getProjectEnvironmentVariables: { group: "default", points: 1 }, deleteProjectEnvironmentVariable: { group: "default", points: 1 }, getTeam: { group: "default", points: 1 }, + updateTeam: { group: "default", points: 1 }, getTeams: { group: "default", points: 1 }, getTeamMembers: { group: "default", points: 1 }, createTeam: { group: "default", points: 1 }, diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index dcc0182232a62a..2b877a39e9c102 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -2061,6 +2061,24 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { return team; } + public async updateTeam(ctx: TraceContext, teamId: string, team: Partial>): Promise { + traceAPIParams(ctx, { teamId }); + + if (!teamId || !uuidValidate(teamId)) { + throw new ResponseError(ErrorCodes.BAD_REQUEST, "team ID must be a valid UUID"); + } + this.checkUser("updateTeam"); + const existingTeam = await this.teamDB.findTeamById(teamId); + if (!existingTeam) { + throw new ResponseError(ErrorCodes.NOT_FOUND, `Team ${teamId} does not exist`); + } + const members = await this.teamDB.findMembersByTeam(teamId); + await this.guardAccess({ kind: "team", subject: existingTeam, members }, "update"); + + const updatedTeam = await this.teamDB.updateTeam(teamId, team); + return updatedTeam; + } + public async getTeamMembers(ctx: TraceContext, teamId: string): Promise { traceAPIParams(ctx, { teamId }); From 6936b10426b3eedfd559cd49903acd8e0f833e88 Mon Sep 17 00:00:00 2001 From: Sven Efftinge Date: Mon, 16 Jan 2023 17:00:03 +0000 Subject: [PATCH 2/4] [dashboard] allow changing team name fixes #5067 --- .../dashboard/src/teams/TeamSettings.tsx | 107 +++++++++++++++--- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/components/dashboard/src/teams/TeamSettings.tsx b/components/dashboard/src/teams/TeamSettings.tsx index d8b8881a951e66..d4b5d1d39c2b9c 100644 --- a/components/dashboard/src/teams/TeamSettings.tsx +++ b/components/dashboard/src/teams/TeamSettings.tsx @@ -6,14 +6,15 @@ import { Team } from "@gitpod/gitpod-protocol"; import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; -import { useContext, useEffect, useState } from "react"; -import { Redirect, useLocation } from "react-router"; +import React, { useCallback, useContext, useEffect, useState } from "react"; +import { Redirect } from "react-router"; +import Alert from "../components/Alert"; import ConfirmationModal from "../components/ConfirmationModal"; import { PageWithSubMenu } from "../components/PageWithSubMenu"; import { publicApiTeamMembersToProtocol, teamsService } from "../service/public-api"; import { getGitpodService, gitpodHostUrl } from "../service/service"; -import { UserContext } from "../user-context"; -import { getCurrentTeam, TeamsContext } from "./teams-context"; +import { useCurrentUser } from "../user-context"; +import { TeamsContext, useCurrentTeam } from "./teams-context"; export function getTeamSettingsMenu(params: { team?: Team; billingMode?: BillingMode }) { const { team, billingMode } = params; @@ -35,14 +36,15 @@ export function getTeamSettingsMenu(params: { team?: Team; billingMode?: Billing } export default function TeamSettings() { + const user = useCurrentUser(); + const team = useCurrentTeam(); + const { teams, setTeams } = useContext(TeamsContext); const [modal, setModal] = useState(false); - const [teamSlug, setTeamSlug] = useState(""); + const [teamName, setTeamName] = useState(team?.name || ""); + const [errorMessage, setErrorMessage] = useState(undefined); const [isUserOwner, setIsUserOwner] = useState(true); - const { teams } = useContext(TeamsContext); - const { user } = useContext(UserContext); const [billingMode, setBillingMode] = useState(undefined); - const location = useLocation(); - const team = getCurrentTeam(location, teams); + const [updated, setUpdated] = useState(false); const close = () => setModal(false); @@ -60,12 +62,45 @@ export default function TeamSettings() { const billingMode = await getGitpodService().server.getBillingModeForTeam(team.id); setBillingMode(billingMode); })(); - }, []); + }, [team, user]); - if (!isUserOwner) { - return ; - } - const deleteTeam = async () => { + const updateTeamInformation = useCallback(async () => { + if (!team || errorMessage || !teams) { + return; + } + try { + const updatedTeam = await getGitpodService().server.updateTeam(team.id, { name: teamName }); + const updatedTeams = [...teams?.filter((t) => t.id !== team.id)]; + updatedTeams.push(updatedTeam); + setTeams(updatedTeams); + setUpdated(true); + setTimeout(() => setUpdated(false), 3000); + } catch (error) { + setErrorMessage(`Failed to update team information: ${error.message}`); + } + }, [team, errorMessage, teams, teamName, setTeams]); + + const onNameChange = useCallback( + async (event: React.ChangeEvent) => { + if (!team) { + return; + } + const newName = event.target.value || ""; + setTeamName(newName); + if (newName.trim().length === 0) { + setErrorMessage("Team name can not be blank."); + return; + } else if (newName.trim().length > 32) { + setErrorMessage("Team name must not be longer than 32 characters."); + return; + } else { + setErrorMessage(undefined); + } + }, + [team], + ); + + const deleteTeam = useCallback(async () => { if (!team || !user) { return; } @@ -73,7 +108,11 @@ export default function TeamSettings() { await teamsService.deleteTeam({ teamId: team.id }); document.location.href = gitpodHostUrl.asDashboard().toString(); - }; + }, [team, user]); + + if (!isUserOwner) { + return ; + } return ( <> @@ -82,7 +121,39 @@ export default function TeamSettings() { title="Settings" subtitle="Manage general team settings." > -

Delete Team

+

Team Name

+

+ This is your team's visible name within Gitpod. For example, the name of your company. +

+ {errorMessage && ( + + {errorMessage} + + )} + {updated && ( + + Team name has been updated. + + )} +
+
+
+

Name

+ +
+
+
+
+ +
+ +

Delete Team

Deleting this team will also remove all associated data with this team, including projects and workspaces. Deleted teams cannot be restored! @@ -95,7 +166,7 @@ export default function TeamSettings() { Type {team?.slug} to confirm

- setTeamSlug(e.target.value)}> + setTeamName(e.target.value)}> ); From 1cef2ddfb6797a50358136a326fe973a491ab155 Mon Sep 17 00:00:00 2001 From: Sven Efftinge Date: Mon, 16 Jan 2023 17:02:20 +0000 Subject: [PATCH 3/4] [server] mandatory team name on update --- components/gitpod-db/src/team-db.ts | 2 +- components/gitpod-db/src/typeorm/team-db-impl.ts | 2 +- components/gitpod-protocol/src/gitpod-service.ts | 2 +- components/server/src/workspace/gitpod-server-impl.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/gitpod-db/src/team-db.ts b/components/gitpod-db/src/team-db.ts index efc1b583a97fa6..a1642236a428fe 100644 --- a/components/gitpod-db/src/team-db.ts +++ b/components/gitpod-db/src/team-db.ts @@ -23,7 +23,7 @@ export interface TeamDB { findTeamsByUser(userId: string): Promise; findTeamsByUserAsSoleOwner(userId: string): Promise; createTeam(userId: string, name: string): Promise; - updateTeam(teamId: string, team: Partial>): Promise; + updateTeam(teamId: string, team: Pick): Promise; addMemberToTeam(userId: string, teamId: string): Promise<"added" | "already_member">; setTeamMemberRole(userId: string, teamId: string, role: TeamMemberRole): Promise; setTeamMemberSubscription(userId: string, teamId: string, subscriptionId: string): Promise; diff --git a/components/gitpod-db/src/typeorm/team-db-impl.ts b/components/gitpod-db/src/typeorm/team-db-impl.ts index f3768a4d2147fc..23460fcf21ccad 100644 --- a/components/gitpod-db/src/typeorm/team-db-impl.ts +++ b/components/gitpod-db/src/typeorm/team-db-impl.ts @@ -122,7 +122,7 @@ export class TeamDBImpl implements TeamDB { return soleOwnedTeams; } - public async updateTeam(teamId: string, team: Partial>): Promise { + public async updateTeam(teamId: string, team: Pick): Promise { const teamRepo = await this.getTeamRepo(); const existingTeam = await teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false }); if (!existingTeam) { diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 381f44b8cd9925..5102f898baa7f5 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -165,7 +165,7 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, // Teams getTeam(teamId: string): Promise; - updateTeam(teamId: string, team: Partial>): Promise; + updateTeam(teamId: string, team: Pick): Promise; getTeams(): Promise; getTeamMembers(teamId: string): Promise; createTeam(name: string): Promise; diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 2b877a39e9c102..b28dd79ba3f239 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -2061,7 +2061,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { return team; } - public async updateTeam(ctx: TraceContext, teamId: string, team: Partial>): Promise { + public async updateTeam(ctx: TraceContext, teamId: string, team: Pick): Promise { traceAPIParams(ctx, { teamId }); if (!teamId || !uuidValidate(teamId)) { From ca3d3f69141bfc385d65bdfea7e2697a78962b77 Mon Sep 17 00:00:00 2001 From: Sven Efftinge Date: Mon, 16 Jan 2023 17:21:43 +0000 Subject: [PATCH 4/4] [dashboard] fix team deletion --- components/dashboard/src/teams/TeamSettings.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/components/dashboard/src/teams/TeamSettings.tsx b/components/dashboard/src/teams/TeamSettings.tsx index d4b5d1d39c2b9c..2b03e02bcc4503 100644 --- a/components/dashboard/src/teams/TeamSettings.tsx +++ b/components/dashboard/src/teams/TeamSettings.tsx @@ -40,6 +40,7 @@ export default function TeamSettings() { const team = useCurrentTeam(); const { teams, setTeams } = useContext(TeamsContext); const [modal, setModal] = useState(false); + const [teamNameToDelete, setTeamNameToDelete] = useState(""); const [teamName, setTeamName] = useState(team?.name || ""); const [errorMessage, setErrorMessage] = useState(undefined); const [isUserOwner, setIsUserOwner] = useState(true); @@ -166,7 +167,7 @@ export default function TeamSettings() {

- Type {team?.slug} to confirm + Type {team?.name} to confirm

- setTeamName(e.target.value)}> + setTeamNameToDelete(e.target.value)} + >
);