Skip to content

Commit b144324

Browse files
author
Laurie T. Malau
committed
[dashboard] Team settings page
Fixes #5066
1 parent 12417ff commit b144324

File tree

13 files changed

+173
-35
lines changed

13 files changed

+173
-35
lines changed

components/dashboard/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ const GitIntegration = React.lazy(() => import('./settings/GitIntegration'));
2929
```
3030

3131
Global state is passed through `React.Context`.
32+
33+
After creating a new component, run the following to update the license header:
34+
`leeway run components:update-license-header`

components/dashboard/src/App.tsx

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './s
3434
const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam'));
3535
const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/JoinTeam'));
3636
const Members = React.lazy(() => import(/* webpackPrefetch: true */ './teams/Members'));
37+
const TeamSettings = React.lazy(() => import(/* webpackPrefetch: true */ './teams/TeamSettings'));
3738
const NewProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/NewProject'));
3839
const ConfigureProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/ConfigureProject'));
3940
const Projects = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Projects'));
@@ -280,33 +281,36 @@ function App() {
280281
<Route exact path="/teams/join" component={JoinTeam} />
281282
</Route>
282283
{(teams || []).map(team =>
283-
<Route path={`/t/${team.slug}`} key={team.slug}>
284-
<Route exact path={`/t/${team.slug}`}>
285-
<Redirect to={`/t/${team.slug}/workspaces`} />
286-
</Route>
287-
<Route exact path={`/t/${team.slug}/:maybeProject/:resourceOrPrebuild?`} render={(props) => {
288-
const { maybeProject, resourceOrPrebuild } = props.match.params;
289-
if (maybeProject === "projects") {
290-
return <Projects />;
291-
}
292-
if (maybeProject === "workspaces") {
293-
return <Workspaces />;
294-
}
295-
if (maybeProject === "members") {
296-
return <Members />;
297-
}
298-
if (resourceOrPrebuild === "configure") {
299-
return <ConfigureProject />;
300-
}
301-
if (resourceOrPrebuild === "workspaces") {
302-
return <Workspaces />;
303-
}
304-
if (resourceOrPrebuild === "prebuilds") {
305-
return <Prebuilds />;
306-
}
307-
return resourceOrPrebuild ? <Prebuild /> : <Project />;
308-
}} />
309-
</Route>)}
284+
<Route path={`/t/${team.slug}`} key={team.slug}>
285+
<Route exact path={`/t/${team.slug}`}>
286+
<Redirect to={`/t/${team.slug}/workspaces`} />
287+
</Route>
288+
<Route exact path={`/t/${team.slug}/:maybeProject/:resourceOrPrebuild?`} render={(props) => {
289+
const { maybeProject, resourceOrPrebuild } = props.match.params;
290+
if (maybeProject === "projects") {
291+
return <Projects />;
292+
}
293+
if (maybeProject === "workspaces") {
294+
return <Workspaces />;
295+
}
296+
if (maybeProject === "members") {
297+
return <Members />;
298+
}
299+
if (maybeProject === "settings") {
300+
return <TeamSettings />;
301+
}
302+
if (resourceOrPrebuild === "configure") {
303+
return <ConfigureProject />;
304+
}
305+
if (resourceOrPrebuild === "workspaces") {
306+
return <Workspaces />;
307+
}
308+
if (resourceOrPrebuild === "prebuilds") {
309+
return <Prebuilds />;
310+
}
311+
return resourceOrPrebuild ? <Prebuild /> : <Project />;
312+
}} />
313+
</Route>)}
310314
<Route path="*" render={
311315
(_match) => {
312316

components/dashboard/src/Menu.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ export default function Menu() {
3333
const { user } = useContext(UserContext);
3434
const { teams } = useContext(TeamsContext);
3535
const location = useLocation();
36+
const visibleTeams = teams?.filter(team => { return Boolean(!team.markedDeleted) });
3637

3738
const match = useRouteMatch<{ segment1?: string, segment2?: string, segment3?: string }>("/(t/)?:segment1/:segment2?/:segment3?");
3839
const projectName = (() => {
3940
const resource = match?.params?.segment2;
40-
if (resource && !["projects", "members", "users", "workspaces"].includes(resource)) {
41+
if (resource && !["projects", "members", "users", "workspaces", "settings"].includes(resource)) {
4142
return resource;
4243
}
4344
})();
@@ -121,6 +122,10 @@ export default function Menu() {
121122
{
122123
title: 'Members',
123124
link: `/t/${team.slug}/members`
125+
},
126+
{
127+
title: 'Settings',
128+
link: `/t/${team.slug}/settings`,
124129
}
125130
];
126131
}
@@ -178,7 +183,7 @@ export default function Menu() {
178183
separator: true,
179184
link: '/',
180185
},
181-
...(teams || []).map(t => ({
186+
...(visibleTeams || []).map(t => ({
182187
title: t.name,
183188
customContent: <div className="w-full text-gray-400 flex flex-col">
184189
<span className="text-gray-800 dark:text-gray-300 text-base font-semibold">{t.name}</span>

components/dashboard/src/components/ConfirmationModal.tsx

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

7+
import AlertBox from "./AlertBox";
78
import Modal from "./Modal";
89

910
export default function ConfirmationModal(props: {
@@ -13,27 +14,32 @@ export default function ConfirmationModal(props: {
1314
buttonText?: string,
1415
buttonDisabled?: boolean,
1516
visible?: boolean,
17+
warningText?: string,
1618
onClose: () => void,
1719
onConfirm: () => void,
1820
}) {
1921

20-
const c: React.ReactChild[] = [
21-
<p className="mt-1 mb-2 text-base text-gray-500">{props.areYouSureText || "Are you sure?"}</p>,
22+
const child: React.ReactChild[] = [
23+
<p className="mt-3 mb-3 text-base text-gray-500">{props.areYouSureText || "Are you sure?"}</p>,
2224
]
2325

26+
if (props.warningText) {
27+
child.unshift(<AlertBox>{props.warningText}</AlertBox>);
28+
}
29+
2430
const isEntity = (x: any): x is Entity => typeof x === "object" && "name" in x;
2531
if (props.children) {
2632
if (isEntity(props.children)) {
27-
c.push(
33+
child.push(
2834
<div className="w-full p-4 mb-2 bg-gray-100 dark:bg-gray-700 rounded-xl group">
2935
<p className="text-base text-gray-800 dark:text-gray-100 font-semibold">{props.children.name}</p>
3036
{props.children.description && <p className="text-gray-500">{props.children.description}</p>}
3137
</div>
3238
)
3339
} else if (Array.isArray(props.children)) {
34-
c.push(...props.children);
40+
child.push(...props.children);
3541
} else {
36-
c.push(props.children);
42+
child.push(props.children);
3743
}
3844
}
3945

@@ -52,7 +58,7 @@ export default function ConfirmationModal(props: {
5258
onClose={props.onClose}
5359
onEnter={() => { props.onConfirm(); return true; }}
5460
>
55-
{c}
61+
{child}
5662
</Modal>
5763
);
5864
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 { useContext, useState } from "react";
8+
import { useLocation } from "react-router";
9+
import ConfirmationModal from "../components/ConfirmationModal";
10+
import { PageWithSubMenu } from "../components/PageWithSubMenu";
11+
import { getGitpodService, gitpodHostUrl } from "../service/service";
12+
import { getCurrentTeam, TeamsContext } from "./teams-context";
13+
14+
export default function TeamSettings() {
15+
const [modal, setModal] = useState(false);
16+
const [teamSlug, setTeamSlug] = useState('');
17+
const { teams } = useContext(TeamsContext);
18+
const location = useLocation();
19+
const team = getCurrentTeam(location, teams);
20+
21+
const close = () => setModal(false);
22+
23+
const deleteTeam = async () => {
24+
if (!team) {
25+
return
26+
}
27+
await getGitpodService().server.deleteTeam(team.id);
28+
document.location.href = gitpodHostUrl.asSettings().toString();
29+
};
30+
31+
const settingsMenu = [
32+
{
33+
title: 'General',
34+
link: [`/t/${team?.slug}/settings`]
35+
}
36+
]
37+
38+
return <>
39+
<PageWithSubMenu subMenu={settingsMenu} title='General' subtitle='Manage general team settings.'>
40+
<h3>Delete Team</h3>
41+
<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>
42+
<button className="danger secondary" onClick={() => setModal(true)}>Delete Account</button>
43+
</PageWithSubMenu>
44+
45+
<ConfirmationModal
46+
title="Delete Team"
47+
areYouSureText="You are about to permanently delete this team including all associated data with this team."
48+
buttonText="Delete Team"
49+
buttonDisabled={teamSlug !== team!.slug}
50+
visible={modal}
51+
warningText="Warning: this action cannot be reversed."
52+
onClose={close}
53+
onConfirm={deleteTeam}
54+
>
55+
<ol className="text-gray-500 text-m list-outside list-decimal">
56+
<li className="ml-5">All <b>projects</b> added in this team will be deleted and cannot be restored afterwards.</li>
57+
<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>
58+
<li className="ml-5">All <b>members</b> of this team will lose access to this team, associated projects and workspaces.</li>
59+
</ol>
60+
<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>
61+
<input className="w-full" type="text" onChange={e => setTeamSlug(e.target.value)}></input>
62+
</ConfirmationModal>
63+
</>
64+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ export interface TeamDB {
1818
findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite>;
1919
findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite | undefined>;
2020
resetGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
21+
deleteTeam(teamId: string): Promise<void>;
2122
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export class DBTeam {
2222
@Column("varchar")
2323
creationTime: string;
2424

25+
@Column()
26+
markedDeleted?: boolean;
27+
2528
// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
2629
@Column()
2730
deleted: boolean;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
import { columnExists } from "./helper/helper";
9+
10+
export class AddMarkedDeletedToTeam1632908105486 implements MigrationInterface {
11+
12+
public async up(queryRunner: QueryRunner): Promise<any> {
13+
if (!(await columnExists(queryRunner, "d_b_team", "markedDeleted"))) {
14+
await queryRunner.query("ALTER TABLE d_b_team ADD COLUMN `markedDeleted` tinyint(4) NOT NULL DEFAULT '0'");
15+
}
16+
}
17+
18+
public async down(queryRunner: QueryRunner): Promise<any> {
19+
}
20+
21+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,15 @@ export class TeamDBImpl implements TeamDB {
145145
return team;
146146
}
147147

148+
public async deleteTeam(teamId: string): Promise<void> {
149+
const teamRepo = await this.getTeamRepo();
150+
const team = await this.findTeamById(teamId);
151+
if (team) {
152+
team.markedDeleted = true;
153+
await teamRepo.save(team);
154+
}
155+
}
156+
148157
public async addMemberToTeam(userId: string, teamId: string): Promise<void> {
149158
const teamRepo = await this.getTeamRepo();
150159
const team = await teamRepo.findOneById(teamId);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
123123
removeTeamMember(teamId: string, userId: string): Promise<void>;
124124
getGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
125125
resetGenericInvite(inviteId: string): Promise<TeamMembershipInvite>;
126+
deleteTeam(teamId: string): Promise<void>;
126127

127128
// Projects
128129
getProviderRepositoriesForUser(params: GetProviderRepositoriesParams): Promise<ProviderRepository[]>;

components/gitpod-protocol/src/teams-projects-protocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export interface Team {
101101
name: string;
102102
slug: string;
103103
creationTime: string;
104+
markedDeleted?: boolean;
104105
/** This is a flag that triggers the HARD DELETION of this entity */
105106
deleted?: boolean;
106107
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
8888
"removeTeamMember": { group: "default", points: 1 },
8989
"getGenericInvite": { group: "default", points: 1 },
9090
"resetGenericInvite": { group: "default", points: 1 },
91+
"deleteTeam": { group: "default", points: 1 },
9192
"getProviderRepositoriesForUser": { group: "default", points: 1 },
9293
"createProject": { group: "default", points: 1 },
9394
"getTeamProjects": { group: "default", points: 1 },

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,6 +1555,25 @@ export class GitpodServerImpl<Client extends GitpodClient, Server extends Gitpod
15551555
return this.projectsService.deleteProject(projectId);
15561556
}
15571557

1558+
public async deleteTeam(teamId: string): Promise<void> {
1559+
const user = this.checkAndBlockUser("deleteTeam");
1560+
await this.guardTeamOperation(teamId, "delete");
1561+
1562+
await this.teamDB.deleteTeam(teamId);
1563+
const teamProjects = await this.projectsService.getTeamProjects(teamId);
1564+
teamProjects.forEach(project => {
1565+
this.deleteProject(project.id);
1566+
})
1567+
1568+
return this.analytics.track({
1569+
userId: user.id,
1570+
event: "team_deleted",
1571+
properties: {
1572+
team_id: teamId
1573+
}
1574+
})
1575+
}
1576+
15581577
public async getTeamProjects(teamId: string): Promise<Project[]> {
15591578
this.checkUser("getTeamProjects");
15601579
await this.guardTeamOperation(teamId, "get");

0 commit comments

Comments
 (0)