Skip to content

Commit add18ba

Browse files
author
Laurie T. Malau
committed
Allow teams serch
1 parent 2dce413 commit add18ba

File tree

12 files changed

+298
-4
lines changed

12 files changed

+298
-4
lines changed

components/dashboard/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/
5959
const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/WorkspacesSearch'));
6060
const AdminSettings = React.lazy(() => import(/* webpackPrefetch: true */ './admin/Settings'));
6161
const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/ProjectsSearch'));
62+
const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/TeamsSearch'));
6263
const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ './OauthClientApproval'));
6364

6465
function Loading() {
@@ -298,6 +299,7 @@ function App() {
298299
<Route path="/from-referrer" exact component={FromReferrer} />
299300

300301
<Route path="/admin/users" component={UserSearch} />
302+
<Route path="/admin/teams" component={TeamsSearch} />
301303
<Route path="/admin/workspaces" component={WorkspacesSearch} />
302304
<Route path="/admin/settings" component={AdminSettings} />
303305
<Route path="/admin/projects" component={ProjectsSearch} />

components/dashboard/src/Menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default function Menu() {
4141
const match = useRouteMatch<{ segment1?: string, segment2?: string, segment3?: string }>("/(t/)?:segment1/:segment2?/:segment3?");
4242
const projectSlug = (() => {
4343
const resource = match?.params?.segment2;
44-
if (resource && !["projects", "members", "users", "workspaces", "settings"].includes(resource)) {
44+
if (resource && !["projects", "members", "users", "workspaces", "settings", "teams"].includes(resource)) {
4545
return resource;
4646
}
4747
})();
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Copyright (c) 2022 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 moment from "moment";
8+
import { useEffect, useState } from "react";
9+
import { Team, TeamMemberInfo, TeamMemberRole } from "@gitpod/gitpod-protocol";
10+
import { getGitpodService } from "../service/service";
11+
import { Item, ItemField, ItemsList } from "../components/ItemsList";
12+
import PillLabel from "../components/PillLabel";
13+
import DropDown from "../components/DropDown";
14+
import { Link } from "react-router-dom";
15+
16+
export default function TeamDetail(props: { team: Team }) {
17+
const { team } = props;
18+
const [teamMembers, setTeamMembers] = useState<TeamMemberInfo[] | undefined>(undefined);
19+
const [searchText, setSearchText] = useState<string>('');
20+
21+
useEffect(() => {
22+
(async () => {
23+
const members = await getGitpodService().server.adminGetTeamMembers(team.id);
24+
if (members.length > 0) {
25+
setTeamMembers(members)
26+
}
27+
})();
28+
}, [team]);
29+
30+
const filteredMembers = teamMembers?.filter(m => {
31+
const memberSearchText = `${m.fullName || ''}${m.primaryEmail || ''}`.toLocaleLowerCase();
32+
if (!memberSearchText.includes(searchText.toLocaleLowerCase())) {
33+
return false;
34+
}
35+
return true;
36+
});
37+
38+
const setTeamMemberRole = async (userId: string, role: TeamMemberRole) => {
39+
await getGitpodService().server.adminSetTeamMemberRole(team!.id, userId, role);
40+
setTeamMembers(await getGitpodService().server.adminGetTeamMembers(team!.id));
41+
}
42+
return <>
43+
<div className="flex">
44+
<div className="flex-1">
45+
<div className="flex"><h3>{team.name}</h3>
46+
{team.markedDeleted && <span className="mt-2"><PillLabel type="warn" className="font-semibold mt-2 py-0.5 px-2 self-center">Deleted</PillLabel></span>}
47+
</div>
48+
{!team.markedDeleted && <p>{teamMembers && teamMembers.length} member(s)</p>}
49+
<p>Created on {moment(team.creationTime).format('MMM D, YYYY')}</p>
50+
</div>
51+
</div>
52+
<div className="flex mt-8">
53+
<div className="flex">
54+
<div className="py-4">
55+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" width="16" height="16"><path fill="#A8A29E" d="M6 2a4 4 0 100 8 4 4 0 000-8zM0 6a6 6 0 1110.89 3.477l4.817 4.816a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 010 6z" /></svg>
56+
</div>
57+
<input type="search" placeholder="Search Members" onChange={e => setSearchText(e.target.value)} />
58+
</div>
59+
</div>
60+
61+
<ItemsList className="mt-2">
62+
{!team.markedDeleted && <Item header={true} className="grid grid-cols-3">
63+
<ItemField className="my-auto">
64+
<span className="pl-14">Name</span>
65+
</ItemField>
66+
<ItemField className="flex items-center space-x-1 my-auto">
67+
<span>Joined</span>
68+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" className="h-4 w-4" viewBox="0 0 16 16"><path fill="#A8A29E" fill-rule="evenodd" d="M13.366 8.234a.8.8 0 010 1.132l-4.8 4.8a.8.8 0 01-1.132 0l-4.8-4.8a.8.8 0 111.132-1.132L7.2 11.67V2.4a.8.8 0 111.6 0v9.269l3.434-3.435a.8.8 0 011.132 0z" clip-rule="evenodd" /></svg>
69+
</ItemField>
70+
<ItemField className="flex items-center my-auto">
71+
<span className="flex-grow">Role</span>
72+
</ItemField>
73+
</Item>}
74+
{!team.markedDeleted && (!filteredMembers || filteredMembers.length === 0)
75+
? <p className="pt-16 text-center">No members found</p>
76+
: filteredMembers && filteredMembers.map(m => <Item className="grid grid-cols-3" key={m.userId}>
77+
<ItemField className="flex items-center my-auto">
78+
<div className="w-14">{m.avatarUrl && <img className="rounded-full w-8 h-8" src={m.avatarUrl || ''} alt={m.fullName} />}</div>
79+
<Link to={"/admin/users/" + m.userId}><div>
80+
<div className="text-base text-gray-900 dark:text-gray-50 font-medium">{m.fullName}</div>
81+
<p>{m.primaryEmail}</p>
82+
</div></Link>
83+
</ItemField>
84+
<ItemField className="my-auto">
85+
<span className="text-gray-400">{moment(m.memberSince).fromNow()}</span>
86+
</ItemField>
87+
<ItemField className="flex items-center my-auto">
88+
<span className="text-gray-400 capitalize">
89+
<DropDown contextMenuWidth="w-32" activeEntry={m.role} entries={[{
90+
title: 'owner',
91+
onClick: () => setTeamMemberRole(m.userId, 'owner')
92+
}, {
93+
title: 'member',
94+
onClick: () => setTeamMemberRole(m.userId, 'member')
95+
}]} />
96+
</span>
97+
</ItemField>
98+
</Item>)}
99+
</ItemsList>
100+
</>
101+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Copyright (c) 2022 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 moment from "moment";
8+
import { useState, useContext, useEffect } from "react";
9+
10+
import TeamDetail from "./TeamDetail";
11+
import { adminMenu } from "./admin-menu";
12+
import { useLocation } from "react-router";
13+
import { Link, Redirect } from "react-router-dom";
14+
import { UserContext } from "../user-context";
15+
import { getGitpodService } from "../service/service";
16+
import { PageWithSubMenu } from "../components/PageWithSubMenu";
17+
import { AdminGetListResult, Team } from "@gitpod/gitpod-protocol";
18+
import PillLabel from "../components/PillLabel";
19+
20+
export default function TeamsSearchPage() {
21+
return (
22+
<PageWithSubMenu subMenu={adminMenu} title="Teams" subtitle="Search and manage teams.">
23+
<TeamsSearch />
24+
</PageWithSubMenu>
25+
)
26+
}
27+
28+
export function TeamsSearch() {
29+
const location = useLocation();
30+
const { user } = useContext(UserContext);
31+
const [searching, setSearching] = useState(false);
32+
const [searchTerm, setSearchTerm] = useState('');
33+
const [currentTeam, setCurrentTeam] = useState<Team | undefined>(undefined);
34+
const [searchResult, setSearchResult] = useState<AdminGetListResult<Team>>({ total: 0, rows: [] });
35+
36+
useEffect(() => {
37+
const teamId = location.pathname.split('/')[3];
38+
if (teamId && searchResult) {
39+
let foundTeam = searchResult.rows.find(team => team.id === teamId);
40+
if (foundTeam) {
41+
setCurrentTeam(foundTeam);
42+
} else {
43+
getGitpodService().server.adminGetTeamById(teamId)
44+
.then(team => setCurrentTeam(team))
45+
.catch(e => console.error(e));
46+
}
47+
} else {
48+
setCurrentTeam(undefined);
49+
}
50+
}, [location]);
51+
52+
if (!user || !user?.rolesOrPermissions?.includes('admin')) {
53+
return <Redirect to="/"/>
54+
}
55+
56+
if (currentTeam) {
57+
return <TeamDetail team={currentTeam} />;
58+
}
59+
60+
const search = async () => {
61+
setSearching(true);
62+
try {
63+
const result = await getGitpodService().server.adminGetTeams({
64+
searchTerm,
65+
limit: 50,
66+
orderBy: 'creationTime',
67+
offset: 0,
68+
orderDir: "desc"
69+
})
70+
setSearchResult(result);
71+
} finally {
72+
setSearching(false);
73+
}
74+
}
75+
return <>
76+
<div className="pt-8 flex">
77+
<div className="flex justify-between w-full">
78+
<div className="flex">
79+
<div className="py-4">
80+
<svg className={searching ? 'animate-spin' : ''} width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
81+
<path fillRule="evenodd" clipRule="evenodd" d="M6 2a4 4 0 100 8 4 4 0 000-8zM0 6a6 6 0 1110.89 3.477l4.817 4.816a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 010 6z" fill="#A8A29E" />
82+
</svg>
83+
</div>
84+
<input type="search" placeholder="Search Teams" onKeyDown={(k) => k.key === 'Enter' && search()} onChange={(v) => { setSearchTerm(v.target.value) }} />
85+
</div>
86+
<button disabled={searching} onClick={search}>Search</button>
87+
</div>
88+
</div>
89+
<div className="flex flex-col space-y-2">
90+
<div className="px-6 py-3 flex justify-between text-sm text-gray-400 border-t border-b border-gray-200 dark:border-gray-800 mb-2">
91+
<div className="w-7/12">Name</div>
92+
<div className="w-5/12">Created</div>
93+
</div>
94+
{searchResult.rows.map(team => <TeamResultItem team={team} />)}
95+
</div>
96+
</>
97+
98+
function TeamResultItem(props: { team: Team }) {
99+
return (
100+
<Link key={'pr-' + props.team.name} to={'/admin/teams/' + props.team.id} data-analytics='{"button_type":"sidebar_menu"}'>
101+
<div className="rounded-xl whitespace-nowrap flex py-6 px-6 w-full justify-between hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light group">
102+
<div className="flex flex-col w-7/12 truncate">
103+
<div className="font-medium text-gray-800 dark:text-gray-100 truncate">{props.team.name}
104+
{props.team.markedDeleted && <span><PillLabel type="warn" className="font-semibold mt-2 py-0.5 px-2 self-center">Deleted</PillLabel></span>}
105+
</div>
106+
</div>
107+
<div className="flex w-5/12 self-center">
108+
<div className="text-sm w-full text-gray-400 truncate">{moment(props.team.creationTime).format('MMM D, YYYY')}</div>
109+
</div>
110+
</div>
111+
</Link>
112+
)
113+
}
114+
}

components/dashboard/src/admin/admin-menu.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const adminMenu = [
99
title: 'Users',
1010
link: ['/admin/users', '/admin']
1111
},
12+
{
13+
title: 'Teams',
14+
link: ['/admin/teams']
15+
},
1216
{
1317
title: 'Workspaces',
1418
link: ['/admin/workspaces']

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ import { DBIdentity } from './typeorm/entity/db-identity';
112112

113113
expect(teams.length).to.eq(0);
114114
}
115+
116+
@test(timeout(10000))
117+
public async findTeams() {
118+
const user = await this.userDb.newUser();
119+
await this.db.createTeam(user.id, 'First Team');
120+
await this.db.createTeam(user.id, 'Second Team');
121+
122+
const searchTerm = 'first';
123+
const result = await this.db.findTeams(0, 10, "creationTime", "DESC", searchTerm);
124+
expect(result.rows.length).to.eq(1);
125+
}
115126
}
116127

117128
module.exports = new TeamDBSpec()

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Team, TeamMemberInfo, TeamMemberRole, TeamMembershipInvite } from "@git
88

99
export const TeamDB = Symbol('TeamDB');
1010
export interface TeamDB {
11+
findTeams(offset: number, limit: number, orderBy: keyof Team, orderDir: "ASC" | "DESC", searchTerm: string): Promise<{ total: number, rows: Team[] }>;
1112
findTeamById(teamId: string): Promise<Team | undefined>;
1213
findMembersByTeam(teamId: string): Promise<TeamMemberInfo[]>;
1314
findTeamsByUser(userId: string): Promise<Team[]>;

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ export class TeamDBImpl implements TeamDB {
4141
return (await this.getEntityManager()).getRepository<DBUser>(DBUser);
4242
}
4343

44+
public async findTeams(
45+
offset: number,
46+
limit: number,
47+
orderBy: keyof Team,
48+
orderDir: "DESC" | "ASC",
49+
searchTerm?: string
50+
): Promise<{ total: number, rows: Team[] }> {
51+
52+
const teamRepo = await this.getTeamRepo();
53+
const queryBuilder = teamRepo.createQueryBuilder('team')
54+
.where("team.name LIKE :searchTerm", { searchTerm: `%${searchTerm}%` })
55+
.skip(offset)
56+
.take(limit)
57+
.orderBy(orderBy, orderDir)
58+
59+
const [rows, total] = await queryBuilder.getManyAndCount();
60+
return { total, rows };
61+
}
62+
4463
public async findTeamById(teamId: string): Promise<Team | undefined> {
4564
const teamRepo = await this.getTeamRepo();
4665
return teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false});

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66

77
import { User, Workspace, NamedWorkspaceFeatureFlag } from "./protocol";
88
import { FindPrebuildsParams } from "./gitpod-service";
9-
import { PrebuildWithStatus } from "./teams-projects-protocol"
10-
import { Project, Team } from "./teams-projects-protocol";
9+
import { Project, Team, PrebuildWithStatus, TeamMemberInfo, TeamMemberRole } from "./teams-projects-protocol"
1110
import { WorkspaceInstance, WorkspaceInstancePhase } from "./workspace-instance";
1211
import { RoleOrPermission } from "./permission";
1312
import { AccountStatement } from "./accounting-protocol";
@@ -21,7 +20,10 @@ export interface AdminServer {
2120
adminModifyRoleOrPermission(req: AdminModifyRoleOrPermissionRequest): Promise<User>;
2221
adminModifyPermanentWorkspaceFeatureFlag(req: AdminModifyPermanentWorkspaceFeatureFlagRequest): Promise<User>;
2322

23+
adminGetTeamMembers(teamId: string): Promise<TeamMemberInfo[]>;
24+
adminGetTeams(req: AdminGetListRequest<Team>): Promise<AdminGetListResult<Team>>;
2425
adminGetTeamById(id: string): Promise<Team | undefined>;
26+
adminSetTeamMemberRole(teamId: string, userId: string, role: TeamMemberRole): Promise<void>;
2527

2628
adminGetWorkspaces(req: AdminGetWorkspacesRequest): Promise<AdminGetListResult<WorkspaceAndInstance>>;
2729
adminGetWorkspace(id: string): Promise<WorkspaceAndInstance>;

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { injectable, inject } from "inversify";
88
import { GitpodServerImpl, traceAPIParams, traceWI, censor } from "../../../src/workspace/gitpod-server-impl";
99
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
10-
import { GitpodServer, GitpodClient, AdminGetListRequest, User, Team, AdminGetListResult, Permission, AdminBlockUserRequest, AdminModifyRoleOrPermissionRequest, RoleOrPermission, AdminModifyPermanentWorkspaceFeatureFlagRequest, UserFeatureSettings, AdminGetWorkspacesRequest, WorkspaceAndInstance, GetWorkspaceTimeoutResult, WorkspaceTimeoutDuration, WorkspaceTimeoutValues, SetWorkspaceTimeoutResult, WorkspaceContext, CreateWorkspaceMode, WorkspaceCreationResult, PrebuiltWorkspaceContext, CommitContext, PrebuiltWorkspace, WorkspaceInstance, EduEmailDomain, ProviderRepository, Queue, PrebuildWithStatus, CreateProjectParams, Project, StartPrebuildResult, ClientHeaderFields, Workspace, FindPrebuildsParams } from "@gitpod/gitpod-protocol";
10+
import { GitpodServer, GitpodClient, AdminGetListRequest, User, Team, TeamMemberInfo, AdminGetListResult, Permission, AdminBlockUserRequest, AdminModifyRoleOrPermissionRequest, RoleOrPermission, AdminModifyPermanentWorkspaceFeatureFlagRequest, UserFeatureSettings, AdminGetWorkspacesRequest, WorkspaceAndInstance, GetWorkspaceTimeoutResult, WorkspaceTimeoutDuration, WorkspaceTimeoutValues, SetWorkspaceTimeoutResult, WorkspaceContext, CreateWorkspaceMode, WorkspaceCreationResult, PrebuiltWorkspaceContext, CommitContext, PrebuiltWorkspace, WorkspaceInstance, EduEmailDomain, ProviderRepository, Queue, PrebuildWithStatus, CreateProjectParams, Project, StartPrebuildResult, ClientHeaderFields, Workspace, FindPrebuildsParams, TeamMemberRole } from "@gitpod/gitpod-protocol";
1111
import { ResponseError } from "vscode-jsonrpc";
1212
import { TakeSnapshotRequest, AdmissionLevel, ControlAdmissionRequest, StopWorkspacePolicy, DescribeWorkspaceRequest, SetTimeoutRequest } from "@gitpod/ws-manager/lib";
1313
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
@@ -548,12 +548,37 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
548548
return this.censorUser(target);
549549
}
550550

551+
async adminGetTeamMembers(ctx: TraceContext, teamId: string): Promise<TeamMemberInfo[]> {
552+
this.requireEELicense(Feature.FeatureAdminDashboard);
553+
await this.guardAdminAccess("adminGetTeamMembers", { teamId }, Permission.ADMIN_WORKSPACES);
554+
555+
const team = await this.teamDB.findTeamById(teamId);
556+
if (!team) {
557+
throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found");
558+
}
559+
const members = await this.teamDB.findMembersByTeam(team.id);
560+
return members;
561+
}
562+
563+
async adminGetTeams(ctx: TraceContext, req: AdminGetListRequest<Team>): Promise<AdminGetListResult<Team>> {
564+
this.requireEELicense(Feature.FeatureAdminDashboard);
565+
await this.guardAdminAccess("adminGetTeams", { req }, Permission.ADMIN_WORKSPACES);
566+
567+
return await this.teamDB.findTeams(req.offset, req.limit, req.orderBy, req.orderDir === "asc" ? "ASC" : "DESC", req.searchTerm as string);
568+
}
569+
551570
async adminGetTeamById(ctx: TraceContext, id: string): Promise<Team | undefined> {
552571
this.requireEELicense(Feature.FeatureAdminDashboard);
553572
await this.guardAdminAccess("adminGetTeamById", { id }, Permission.ADMIN_WORKSPACES);
554573
return await this.teamDB.findTeamById(id);
555574
}
556575

576+
async adminSetTeamMemberRole(ctx: TraceContext, teamId: string, userId: string, role: TeamMemberRole): Promise<void> {
577+
this.requireEELicense(Feature.FeatureAdminDashboard);
578+
await this.guardAdminAccess("adminSetTeamMemberRole", { teamId, userId, role }, Permission.ADMIN_WORKSPACES);
579+
return this.teamDB.setTeamMemberRole(userId, teamId, role);
580+
}
581+
557582
async adminGetWorkspaces(ctx: TraceContext, req: AdminGetWorkspacesRequest): Promise<AdminGetListResult<WorkspaceAndInstance>> {
558583
traceAPIParams(ctx, { req });
559584

0 commit comments

Comments
 (0)