Skip to content

Commit

Permalink
Manage teams as admin
Browse files Browse the repository at this point in the history
  • Loading branch information
laushinka committed Feb 23, 2022
1 parent 343ae26 commit 6494c5c
Show file tree
Hide file tree
Showing 12 changed files with 306 additions and 4 deletions.
2 changes: 2 additions & 0 deletions components/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/
const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/WorkspacesSearch'));
const AdminSettings = React.lazy(() => import(/* webpackPrefetch: true */ './admin/Settings'));
const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/ProjectsSearch'));
const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/TeamsSearch'));
const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ './OauthClientApproval'));

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

<Route path="/admin/users" component={UserSearch} />
<Route path="/admin/teams" component={TeamsSearch} />
<Route path="/admin/workspaces" component={WorkspacesSearch} />
<Route path="/admin/settings" component={AdminSettings} />
<Route path="/admin/projects" component={ProjectsSearch} />
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function Menu() {
const match = useRouteMatch<{ segment1?: string, segment2?: string, segment3?: string }>("/(t/)?:segment1/:segment2?/:segment3?");
const projectSlug = (() => {
const resource = match?.params?.segment2;
if (resource && !["projects", "members", "users", "workspaces", "settings"].includes(resource)) {
if (resource && !["projects", "members", "users", "workspaces", "settings", "teams"].includes(resource)) {
return resource;
}
})();
Expand Down
105 changes: 105 additions & 0 deletions components/dashboard/src/admin/TeamDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* 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 moment from "moment";
import { useEffect, useState } from "react";
import { Team, TeamMemberInfo, TeamMemberRole } from "@gitpod/gitpod-protocol";
import { getGitpodService } from "../service/service";
import { Item, ItemField, ItemsList } from "../components/ItemsList";
import PillLabel from "../components/PillLabel";
import DropDown from "../components/DropDown";
import { Link } from "react-router-dom";

export default function TeamDetail(props: { team: Team }) {
const { team } = props;
const [teamMembers, setTeamMembers] = useState<TeamMemberInfo[] | undefined>(undefined);
const [searchText, setSearchText] = useState<string>('');

useEffect(() => {
(async () => {
const members = await getGitpodService().server.adminGetTeamMembers(team.id);
if (members.length > 0) {
setTeamMembers(members)
}
})();
}, [team]);

const filteredMembers = teamMembers?.filter(m => {
const memberSearchText = `${m.fullName || ''}${m.primaryEmail || ''}`.toLocaleLowerCase();
if (!memberSearchText.includes(searchText.toLocaleLowerCase())) {
return false;
}
return true;
});

const setTeamMemberRole = async (userId: string, role: TeamMemberRole) => {
await getGitpodService().server.adminSetTeamMemberRole(team!.id, userId, role);
setTeamMembers(await getGitpodService().server.adminGetTeamMembers(team!.id));
}
return <>
<div className="flex">
<div className="flex-1">
<div className="flex"><h3>{team.name}</h3>
{team.markedDeleted && <span className="mt-2"><PillLabel type="warn" className="font-semibold mt-2 py-0.5 px-2 self-center">Deleted</PillLabel></span>}
</div>
<p className="mb-6">/t/{team.slug}</p>
{!team.markedDeleted && <>
<span className="text-gray-400">{teamMembers && teamMembers.length} member(s)</span>
<span className="text-gray-400"> · </span>
</>}
<span className="text-gray-400">Created on {moment(team.creationTime).format('MMM D, YYYY')}</span>
</div>
</div>
<div className="flex mt-4">
<div className="flex">
<div className="py-4">
<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>
</div>
<input type="search" placeholder="Search Members" onChange={e => setSearchText(e.target.value)} />
</div>
</div>

<ItemsList className="mt-2">
{!team.markedDeleted && <Item header={true} className="grid grid-cols-3">
<ItemField className="my-auto">
<span className="pl-14">Name</span>
</ItemField>
<ItemField className="flex items-center space-x-1 my-auto">
<span>Joined</span>
<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>
</ItemField>
<ItemField className="flex items-center my-auto">
<span className="flex-grow">Role</span>
</ItemField>
</Item>}
{!team.markedDeleted && (!filteredMembers || filteredMembers.length === 0)
? <p className="pt-16 text-center">No members found</p>
: filteredMembers && filteredMembers.map(m => <Item className="grid grid-cols-3" key={m.userId}>
<ItemField className="flex items-center my-auto">
<div className="w-14">{m.avatarUrl && <img className="rounded-full w-8 h-8" src={m.avatarUrl || ''} alt={m.fullName} />}</div>
<Link to={"/admin/users/" + m.userId}><div>
<div className="text-base text-gray-900 dark:text-gray-50 font-medium">{m.fullName}</div>
<p>{m.primaryEmail}</p>
</div></Link>
</ItemField>
<ItemField className="my-auto">
<span className="text-gray-400">{moment(m.memberSince).fromNow()}</span>
</ItemField>
<ItemField className="flex items-center my-auto">
<span className="text-gray-400 capitalize">
<DropDown contextMenuWidth="w-32" activeEntry={m.role} entries={[{
title: 'owner',
onClick: () => setTeamMemberRole(m.userId, 'owner')
}, {
title: 'member',
onClick: () => setTeamMemberRole(m.userId, 'member')
}]} />
</span>
</ItemField>
</Item>)}
</ItemsList>
</>
}
118 changes: 118 additions & 0 deletions components/dashboard/src/admin/TeamsSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* 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 moment from "moment";
import { useState, useContext, useEffect } from "react";

import TeamDetail from "./TeamDetail";
import { adminMenu } from "./admin-menu";
import { useLocation } from "react-router";
import { Link, Redirect } from "react-router-dom";
import { UserContext } from "../user-context";
import { getGitpodService } from "../service/service";
import { PageWithSubMenu } from "../components/PageWithSubMenu";
import { AdminGetListResult, Team } from "@gitpod/gitpod-protocol";
import PillLabel from "../components/PillLabel";

export default function TeamsSearchPage() {
return (
<PageWithSubMenu subMenu={adminMenu} title="Teams" subtitle="Search and manage teams.">
<TeamsSearch />
</PageWithSubMenu>
)
}

export function TeamsSearch() {
const location = useLocation();
const { user } = useContext(UserContext);
const [searching, setSearching] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [currentTeam, setCurrentTeam] = useState<Team | undefined>(undefined);
const [searchResult, setSearchResult] = useState<AdminGetListResult<Team>>({ total: 0, rows: [] });

useEffect(() => {
const teamId = location.pathname.split('/')[3];
if (teamId && searchResult) {
let foundTeam = searchResult.rows.find(team => team.id === teamId);
if (foundTeam) {
setCurrentTeam(foundTeam);
} else {
getGitpodService().server.adminGetTeamById(teamId)
.then(team => setCurrentTeam(team))
.catch(e => console.error(e));
}
} else {
setCurrentTeam(undefined);
}
}, [location]);

if (!user || !user?.rolesOrPermissions?.includes('admin')) {
return <Redirect to="/"/>
}

if (currentTeam) {
return <TeamDetail team={currentTeam} />;
}

const search = async () => {
setSearching(true);
try {
const result = await getGitpodService().server.adminGetTeams({
searchTerm,
limit: 100,
orderBy: 'creationTime',
offset: 0,
orderDir: "desc"
})
setSearchResult(result);
} finally {
setSearching(false);
}
}
return <>
<div className="pt-8 flex">
<div className="flex justify-between w-full">
<div className="flex">
<div className="py-4">
<svg className={searching ? 'animate-spin' : ''} width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
<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" />
</svg>
</div>
<input type="search" placeholder="Search Teams" onKeyDown={(k) => k.key === 'Enter' && search()} onChange={(v) => { setSearchTerm(v.target.value) }} />
</div>
<button disabled={searching} onClick={search}>Search</button>
</div>
</div>
<div className="flex flex-col space-y-2">
<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">
<div className="w-7/12">Name</div>
<div className="w-5/12 flex items-center"><span>Created</span>
<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>
</div>


</div>
{searchResult.rows.map(team => <TeamResultItem team={team} />)}
</div>
</>

function TeamResultItem(props: { team: Team }) {
return (
<Link key={'pr-' + props.team.name} to={'/admin/teams/' + props.team.id} data-analytics='{"button_type":"sidebar_menu"}'>
<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">
<div className="flex flex-col w-7/12 truncate">
<div className="font-medium text-gray-800 dark:text-gray-100 truncate">{props.team.name}
{props.team.markedDeleted && <span><PillLabel type="warn" className="font-semibold mt-2 py-0.5 px-2 self-center">Deleted</PillLabel></span>}
</div>
</div>
<div className="flex w-5/12 self-center">
<div className="text-sm w-full text-gray-400 truncate">{moment(props.team.creationTime).format('MMM D, YYYY')}</div>
</div>
</div>
</Link>
)
}
}
4 changes: 4 additions & 0 deletions components/dashboard/src/admin/admin-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export const adminMenu = [
title: 'Projects',
link: ['/admin/projects']
},
{
title: 'Teams',
link: ['/admin/teams']
},
{
title: 'Settings',
link: ['/admin/settings']
Expand Down
11 changes: 11 additions & 0 deletions components/gitpod-db/src/team-db.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ import { DBIdentity } from './typeorm/entity/db-identity';

expect(teams.length).to.eq(0);
}

@test(timeout(10000))
public async findTeams() {
const user = await this.userDb.newUser();
await this.db.createTeam(user.id, 'First Team');
await this.db.createTeam(user.id, 'Second Team');

const searchTerm = 'first';
const result = await this.db.findTeams(0, 10, "creationTime", "DESC", searchTerm);
expect(result.rows.length).to.eq(1);
}
}

module.exports = new TeamDBSpec()
1 change: 1 addition & 0 deletions components/gitpod-db/src/team-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Team, TeamMemberInfo, TeamMemberRole, TeamMembershipInvite } from "@git

export const TeamDB = Symbol('TeamDB');
export interface TeamDB {
findTeams(offset: number, limit: number, orderBy: keyof Team, orderDir: "ASC" | "DESC", searchTerm: string): Promise<{ total: number, rows: Team[] }>;
findTeamById(teamId: string): Promise<Team | undefined>;
findMembersByTeam(teamId: string): Promise<TeamMemberInfo[]>;
findTeamsByUser(userId: string): Promise<Team[]>;
Expand Down
19 changes: 19 additions & 0 deletions components/gitpod-db/src/typeorm/team-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ export class TeamDBImpl implements TeamDB {
return (await this.getEntityManager()).getRepository<DBUser>(DBUser);
}

public async findTeams(
offset: number,
limit: number,
orderBy: keyof Team,
orderDir: "DESC" | "ASC",
searchTerm?: string
): Promise<{ total: number, rows: Team[] }> {

const teamRepo = await this.getTeamRepo();
const queryBuilder = teamRepo.createQueryBuilder('team')
.where("team.name LIKE :searchTerm", { searchTerm: `%${searchTerm}%` })
.skip(offset)
.take(limit)
.orderBy(orderBy, orderDir)

const [rows, total] = await queryBuilder.getManyAndCount();
return { total, rows };
}

public async findTeamById(teamId: string): Promise<Team | undefined> {
const teamRepo = await this.getTeamRepo();
return teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false});
Expand Down
6 changes: 4 additions & 2 deletions components/gitpod-protocol/src/admin-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@

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

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

adminGetWorkspaces(req: AdminGetWorkspacesRequest): Promise<AdminGetListResult<WorkspaceAndInstance>>;
adminGetWorkspace(id: string): Promise<WorkspaceAndInstance>;
Expand Down
Loading

0 comments on commit 6494c5c

Please sign in to comment.