diff --git a/components/dashboard/README.md b/components/dashboard/README.md index a7cfc991716500..e13bd8b9be7e1f 100644 --- a/components/dashboard/README.md +++ b/components/dashboard/README.md @@ -29,3 +29,6 @@ const GitIntegration = React.lazy(() => import('./settings/GitIntegration')); ``` Global state is passed through `React.Context`. + +After creating a new component, run the following to update the license header: +`leeway run components:update-license-header` \ No newline at end of file diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index e0d6fed8b5c04b..3c04d521e43d7f 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -34,6 +34,7 @@ const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './s const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam')); const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/JoinTeam')); const Members = React.lazy(() => import(/* webpackPrefetch: true */ './teams/Members')); +const TeamSettings = React.lazy(() => import(/* webpackPrefetch: true */ './teams/TeamSettings')); const NewProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/NewProject')); const ConfigureProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/ConfigureProject')); const Projects = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Projects')); @@ -280,33 +281,36 @@ function App() { {(teams || []).map(team => - - - - - { - const { maybeProject, resourceOrPrebuild } = props.match.params; - if (maybeProject === "projects") { - return ; - } - if (maybeProject === "workspaces") { - return ; - } - if (maybeProject === "members") { - return ; - } - if (resourceOrPrebuild === "configure") { - return ; - } - if (resourceOrPrebuild === "workspaces") { - return ; - } - if (resourceOrPrebuild === "prebuilds") { - return ; - } - return resourceOrPrebuild ? : ; - }} /> - )} + + + + + { + const { maybeProject, resourceOrPrebuild } = props.match.params; + if (maybeProject === "projects") { + return ; + } + if (maybeProject === "workspaces") { + return ; + } + if (maybeProject === "members") { + return ; + } + if (maybeProject === "settings") { + return ; + } + if (resourceOrPrebuild === "configure") { + return ; + } + if (resourceOrPrebuild === "workspaces") { + return ; + } + if (resourceOrPrebuild === "prebuilds") { + return ; + } + return resourceOrPrebuild ? : ; + }} /> + )} { diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index 53371aaf2dda44..8eeee5693afdb3 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -33,11 +33,12 @@ export default function Menu() { const { user } = useContext(UserContext); const { teams } = useContext(TeamsContext); const location = useLocation(); + const visibleTeams = teams?.filter(team => { return Boolean(!team.markedDeleted) }); const match = useRouteMatch<{ segment1?: string, segment2?: string, segment3?: string }>("/(t/)?:segment1/:segment2?/:segment3?"); const projectName = (() => { const resource = match?.params?.segment2; - if (resource && !["projects", "members", "users", "workspaces"].includes(resource)) { + if (resource && !["projects", "members", "users", "workspaces", "settings"].includes(resource)) { return resource; } })(); @@ -107,8 +108,10 @@ export default function Menu() { ]; } // Team menu - if (team) { - return [ + if (team && teamMembers && teamMembers[team.id]) { + const currentUserInTeam = teamMembers[team.id].find(m => m.userId === user?.id); + + const teamSettingsList = [ { title: 'Projects', link: `/t/${team.slug}/projects`, @@ -123,6 +126,14 @@ export default function Menu() { link: `/t/${team.slug}/members` } ]; + if (currentUserInTeam?.role === "owner") { + teamSettingsList.push({ + title: 'Settings', + link: `/t/${team.slug}/settings`, + }) + } + + return teamSettingsList; } // User menu return [ @@ -178,7 +189,7 @@ export default function Menu() { separator: true, link: '/', }, - ...(teams || []).map(t => ({ + ...(visibleTeams || []).map(t => ({ title: t.name, customContent:
{t.name} diff --git a/components/dashboard/src/components/ConfirmationModal.tsx b/components/dashboard/src/components/ConfirmationModal.tsx index 67ffd78f4fa4ec..e7f9327ec80366 100644 --- a/components/dashboard/src/components/ConfirmationModal.tsx +++ b/components/dashboard/src/components/ConfirmationModal.tsx @@ -4,6 +4,7 @@ * See License-AGPL.txt in the project root for license information. */ +import AlertBox from "./AlertBox"; import Modal from "./Modal"; export default function ConfirmationModal(props: { @@ -13,27 +14,32 @@ export default function ConfirmationModal(props: { buttonText?: string, buttonDisabled?: boolean, visible?: boolean, + warningText?: string, onClose: () => void, onConfirm: () => void, }) { - const c: React.ReactChild[] = [ -

{props.areYouSureText || "Are you sure?"}

, + const child: React.ReactChild[] = [ +

{props.areYouSureText || "Are you sure?"}

, ] + if (props.warningText) { + child.unshift({props.warningText}); + } + const isEntity = (x: any): x is Entity => typeof x === "object" && "name" in x; if (props.children) { if (isEntity(props.children)) { - c.push( + child.push(

{props.children.name}

{props.children.description &&

{props.children.description}

}
) } else if (Array.isArray(props.children)) { - c.push(...props.children); + child.push(...props.children); } else { - c.push(props.children); + child.push(props.children); } } @@ -52,7 +58,7 @@ export default function ConfirmationModal(props: { onClose={props.onClose} onEnter={() => { props.onConfirm(); return true; }} > - {c} + {child} ); } diff --git a/components/dashboard/src/teams/TeamSettings.tsx b/components/dashboard/src/teams/TeamSettings.tsx new file mode 100644 index 00000000000000..0ba2cc59720aa3 --- /dev/null +++ b/components/dashboard/src/teams/TeamSettings.tsx @@ -0,0 +1,79 @@ +/** + * 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 { useContext, useEffect, useState } from "react"; +import { Redirect, useLocation } from "react-router"; +import ConfirmationModal from "../components/ConfirmationModal"; +import { PageWithSubMenu } from "../components/PageWithSubMenu"; +import { getGitpodService, gitpodHostUrl } from "../service/service"; +import { UserContext } from "../user-context"; +import { getCurrentTeam, TeamsContext } from "./teams-context"; + +export default function TeamSettings() { + const [modal, setModal] = useState(false); + const [teamSlug, setTeamSlug] = useState(''); + const [isUserOwner, setIsUserOwner] = useState(true); + const { teams } = useContext(TeamsContext); + const { user } = useContext(UserContext); + const location = useLocation(); + const team = getCurrentTeam(location, teams); + + const close = () => setModal(false); + + useEffect(() => { + (async () => { + if (!team) return; + const members = await getGitpodService().server.getTeamMembers(team.id); + const currentUserInTeam = members.find(member => member.userId === user?.id); + setIsUserOwner(currentUserInTeam?.role === 'owner'); + })(); + }, []); + + if (!isUserOwner) { + return + }; + const deleteTeam = async () => { + if (!team || !user) { + return + } + await getGitpodService().server.deleteTeam(team.id, user.id); + document.location.href = gitpodHostUrl.asDashboard().toString(); + }; + + const settingsMenu = [ + { + title: 'General', + link: [`/t/${team?.slug}/settings`] + } + ] + + return <> + +

Delete Team

+

Deleting this team will also remove all associated data with this team, including projects and workspaces. Deleted teams cannot be restored!

+ +
+ + +
    +
  1. All projects added in this team will be deleted and cannot be restored afterwards.
  2. +
  3. All workspaces opened for projects within this team will be deleted for all team members and cannot be restored afterwards.
  4. +
  5. All members of this team will lose access to this team, associated projects and workspaces.
  6. +
+

Type your team's URL slug to confirm

+ setTeamSlug(e.target.value)}> +
+ +} \ No newline at end of file diff --git a/components/gitpod-db/src/team-db.ts b/components/gitpod-db/src/team-db.ts index 1a3c6ee97a0148..1c08e246613b69 100644 --- a/components/gitpod-db/src/team-db.ts +++ b/components/gitpod-db/src/team-db.ts @@ -18,4 +18,5 @@ export interface TeamDB { findTeamMembershipInviteById(inviteId: string): Promise; findGenericInviteByTeamId(teamId: string): Promise; resetGenericInvite(teamId: string): Promise; + deleteTeam(teamId: string): Promise; } diff --git a/components/gitpod-db/src/typeorm/entity/db-team.ts b/components/gitpod-db/src/typeorm/entity/db-team.ts index f632ea0319d85a..8219403658e44b 100644 --- a/components/gitpod-db/src/typeorm/entity/db-team.ts +++ b/components/gitpod-db/src/typeorm/entity/db-team.ts @@ -22,6 +22,9 @@ export class DBTeam { @Column("varchar") creationTime: string; + @Column() + markedDeleted?: boolean; + // This column triggers the db-sync deletion mechanism. It's not intended for public consumption. @Column() deleted: boolean; diff --git a/components/gitpod-db/src/typeorm/migration/1632908105486-AddSoftDeletedToTeam.ts b/components/gitpod-db/src/typeorm/migration/1632908105486-AddSoftDeletedToTeam.ts new file mode 100644 index 00000000000000..5724c9e345f727 --- /dev/null +++ b/components/gitpod-db/src/typeorm/migration/1632908105486-AddSoftDeletedToTeam.ts @@ -0,0 +1,21 @@ +/** + * 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"; +import { columnExists } from "./helper/helper"; + +export class AddMarkedDeletedToTeam1632908105486 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + if (!(await columnExists(queryRunner, "d_b_team", "markedDeleted"))) { + await queryRunner.query("ALTER TABLE d_b_team ADD COLUMN `markedDeleted` tinyint(4) NOT NULL DEFAULT '0'"); + } + } + + public async down(queryRunner: QueryRunner): Promise { + } + +} diff --git a/components/gitpod-db/src/typeorm/team-db-impl.ts b/components/gitpod-db/src/typeorm/team-db-impl.ts index 73b1085149d54b..c88dea2f4e00f3 100644 --- a/components/gitpod-db/src/typeorm/team-db-impl.ts +++ b/components/gitpod-db/src/typeorm/team-db-impl.ts @@ -145,6 +145,15 @@ export class TeamDBImpl implements TeamDB { return team; } + public async deleteTeam(teamId: string): Promise { + const teamRepo = await this.getTeamRepo(); + const team = await this.findTeamById(teamId); + if (team) { + team.markedDeleted = true; + await teamRepo.save(team); + } + } + public async addMemberToTeam(userId: string, teamId: string): Promise { const teamRepo = await this.getTeamRepo(); const team = await teamRepo.findOneById(teamId); diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index abc48613d4faac..fd631aaa4c2292 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -123,6 +123,7 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, removeTeamMember(teamId: string, userId: string): Promise; getGenericInvite(teamId: string): Promise; resetGenericInvite(inviteId: string): Promise; + deleteTeam(teamId: string, userId: string): Promise; // Projects getProviderRepositoriesForUser(params: GetProviderRepositoriesParams): Promise; diff --git a/components/gitpod-protocol/src/teams-projects-protocol.ts b/components/gitpod-protocol/src/teams-projects-protocol.ts index 345d4b5495393d..4cab15c53072d2 100644 --- a/components/gitpod-protocol/src/teams-projects-protocol.ts +++ b/components/gitpod-protocol/src/teams-projects-protocol.ts @@ -101,6 +101,7 @@ export interface Team { name: string; slug: string; creationTime: string; + markedDeleted?: boolean; /** This is a flag that triggers the HARD DELETION of this entity */ deleted?: boolean; } diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index 33866d5be6c7dc..eeb45a751e6610 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -88,6 +88,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { "removeTeamMember": { group: "default", points: 1 }, "getGenericInvite": { group: "default", points: 1 }, "resetGenericInvite": { group: "default", points: 1 }, + "deleteTeam": { group: "default", points: 1 }, "getProviderRepositoriesForUser": { group: "default", points: 1 }, "createProject": { group: "default", points: 1 }, "getTeamProjects": { 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 84c8bf5caecf2a..5d3213397275ce 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -1555,6 +1555,30 @@ export class GitpodServerImpl { + const user = this.checkAndBlockUser("deleteTeam"); + await this.guardTeamOperation(teamId, "delete"); + + await this.teamDB.deleteTeam(teamId); + const teamProjects = await this.projectsService.getTeamProjects(teamId); + teamProjects.forEach(project => { + this.deleteProject(project.id); + }) + + const teamMembers = await this.teamDB.findMembersByTeam(teamId); + teamMembers.forEach(member => { + this.removeTeamMember(teamId, userId); + }) + + return this.analytics.track({ + userId: user.id, + event: "team_deleted", + properties: { + team_id: teamId + } + }) + } + public async getTeamProjects(teamId: string): Promise { this.checkUser("getTeamProjects"); await this.guardTeamOperation(teamId, "get");