Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Laushinka/allow users to delete 5066 #5966

Merged
merged 1 commit into from
Oct 4, 2021
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
[dashboard] Team settings page
Fixes #5066
  • Loading branch information
laushinka committed Oct 3, 2021
commit 5737672119337053fe08238893e21d2e9b2264e6
3 changes: 3 additions & 0 deletions components/dashboard/README.md
Original file line number Diff line number Diff line change
@@ -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`
58 changes: 31 additions & 27 deletions components/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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() {
<Route exact path="/teams/join" component={JoinTeam} />
</Route>
{(teams || []).map(team =>
<Route path={`/t/${team.slug}`} key={team.slug}>
<Route exact path={`/t/${team.slug}`}>
<Redirect to={`/t/${team.slug}/workspaces`} />
</Route>
<Route exact path={`/t/${team.slug}/:maybeProject/:resourceOrPrebuild?`} render={(props) => {
const { maybeProject, resourceOrPrebuild } = props.match.params;
if (maybeProject === "projects") {
return <Projects />;
}
if (maybeProject === "workspaces") {
return <Workspaces />;
}
if (maybeProject === "members") {
return <Members />;
}
if (resourceOrPrebuild === "configure") {
return <ConfigureProject />;
}
if (resourceOrPrebuild === "workspaces") {
return <Workspaces />;
}
if (resourceOrPrebuild === "prebuilds") {
return <Prebuilds />;
}
return resourceOrPrebuild ? <Prebuild /> : <Project />;
}} />
</Route>)}
<Route path={`/t/${team.slug}`} key={team.slug}>
<Route exact path={`/t/${team.slug}`}>
<Redirect to={`/t/${team.slug}/workspaces`} />
</Route>
<Route exact path={`/t/${team.slug}/:maybeProject/:resourceOrPrebuild?`} render={(props) => {
const { maybeProject, resourceOrPrebuild } = props.match.params;
if (maybeProject === "projects") {
return <Projects />;
}
if (maybeProject === "workspaces") {
return <Workspaces />;
}
if (maybeProject === "members") {
return <Members />;
}
if (maybeProject === "settings") {
return <TeamSettings />;
}
if (resourceOrPrebuild === "configure") {
return <ConfigureProject />;
}
if (resourceOrPrebuild === "workspaces") {
return <Workspaces />;
}
if (resourceOrPrebuild === "prebuilds") {
return <Prebuilds />;
}
return resourceOrPrebuild ? <Prebuild /> : <Project />;
}} />
</Route>)}
<Route path="*" render={
(_match) => {

19 changes: 15 additions & 4 deletions components/dashboard/src/Menu.tsx
Original file line number Diff line number Diff line change
@@ -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: <div className="w-full text-gray-400 flex flex-col">
<span className="text-gray-800 dark:text-gray-300 text-base font-semibold">{t.name}</span>
18 changes: 12 additions & 6 deletions components/dashboard/src/components/ConfirmationModal.tsx
Original file line number Diff line number Diff line change
@@ -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[] = [
<p className="mt-1 mb-2 text-base text-gray-500">{props.areYouSureText || "Are you sure?"}</p>,
const child: React.ReactChild[] = [
<p className="mt-3 mb-3 text-base text-gray-500">{props.areYouSureText || "Are you sure?"}</p>,
]

if (props.warningText) {
child.unshift(<AlertBox>{props.warningText}</AlertBox>);
}

const isEntity = (x: any): x is Entity => typeof x === "object" && "name" in x;
if (props.children) {
if (isEntity(props.children)) {
c.push(
child.push(
<div className="w-full p-4 mb-2 bg-gray-100 dark:bg-gray-700 rounded-xl group">
<p className="text-base text-gray-800 dark:text-gray-100 font-semibold">{props.children.name}</p>
{props.children.description && <p className="text-gray-500">{props.children.description}</p>}
</div>
)
} 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}
</Modal>
);
}
79 changes: 79 additions & 0 deletions components/dashboard/src/teams/TeamSettings.tsx
Original file line number Diff line number Diff line change
@@ -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 <Redirect to="/" />
};
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 <>
<PageWithSubMenu subMenu={settingsMenu} title='General' subtitle='Manage general team settings.'>
<h3>Delete Team</h3>
<p className="text-base text-gray-500 pb-4">Deleting this team will also remove all associated data with this team, including projects and workspaces. Deleted teams cannot be restored!</p>
<button className="danger secondary" onClick={() => setModal(true)}>Delete Account</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: This returns an error in console and so feedback to the user when they are have member permissions, not owner permission, which also lacks visual feedback for the user. Hiding the project settings page for members (non-owners) could resolve this issue. WDYT? ❓

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: This removes the team entry in the team scope selector but 🅰️ does not actually remove the rest of the team members and 🅱️ the rest of the team previously added as members or owners can still access team page, add projects, open workspaces, and trigger prebuilds. Shall we redirect back to the personal account when a user tries to access resources for a deleted team?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still an issue. Minor and non-blocking. Added follow up #6016.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: When deleting a team a user is redirected back to /account. What do you think of redirecting back to the starting page of the user, /workspaces?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I was torn re where to redirect and was looking forward to your input. It can be a bit disorienting to delete a team and then suddenly seeing another settings page, so I agree with redirecting back to /workspaces.

</PageWithSubMenu>

<ConfirmationModal
title="Delete Team"
areYouSureText="You are about to permanently delete this team including all associated data with this team."
buttonText="Delete Team"
buttonDisabled={teamSlug !== team!.slug}
visible={modal}
warningText="Warning: This action cannot be reversed."
onClose={close}
onConfirm={deleteTeam}
>
<ol className="text-gray-500 text-m list-outside list-decimal">
<li className="ml-5">All <b>projects</b> added in this team will be deleted and cannot be restored afterwards.</li>
<li className="ml-5">All <b>workspaces</b> opened for projects within this team will be deleted for all team members and cannot be restored afterwards.</li>
<li className="ml-5">All <b>members</b> of this team will lose access to this team, associated projects and workspaces.</li>
</ol>
<p className="pt-4 pb-2 text-gray-600 dark:text-gray-400 text-base font-semibold">Type your team's URL slug to confirm</p>
<input className="w-full" type="text" onChange={e => setTeamSlug(e.target.value)}></input>
</ConfirmationModal>
</>
}
1 change: 1 addition & 0 deletions components/gitpod-db/src/team-db.ts
Original file line number Diff line number Diff line change
@@ -18,4 +18,5 @@ export interface TeamDB {
findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite>;
findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite | undefined>;
resetGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
deleteTeam(teamId: string): Promise<void>;
}
3 changes: 3 additions & 0 deletions components/gitpod-db/src/typeorm/entity/db-team.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<any> {
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<any> {
}

}
9 changes: 9 additions & 0 deletions components/gitpod-db/src/typeorm/team-db-impl.ts
Original file line number Diff line number Diff line change
@@ -145,6 +145,15 @@ export class TeamDBImpl implements TeamDB {
return team;
}

public async deleteTeam(teamId: string): Promise<void> {
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<void> {
const teamRepo = await this.getTeamRepo();
const team = await teamRepo.findOneById(teamId);
1 change: 1 addition & 0 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
@@ -123,6 +123,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
removeTeamMember(teamId: string, userId: string): Promise<void>;
getGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
resetGenericInvite(inviteId: string): Promise<TeamMembershipInvite>;
deleteTeam(teamId: string, userId: string): Promise<void>;

// Projects
getProviderRepositoriesForUser(params: GetProviderRepositoriesParams): Promise<ProviderRepository[]>;
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions components/server/src/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
@@ -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 },
24 changes: 24 additions & 0 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
@@ -1555,6 +1555,30 @@ export class GitpodServerImpl<Client extends GitpodClient, Server extends Gitpod
return this.projectsService.deleteProject(projectId);
}

public async deleteTeam(teamId: string, userId: string): Promise<void> {
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<Project[]> {
this.checkUser("getTeamProjects");
await this.guardTeamOperation(teamId, "get");