diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index d8d2799a56814b..1cd3d457088c05 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -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() { @@ -298,6 +299,7 @@ function App() { + diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index d67cf9239d7f1c..67c585a4f20d10 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -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; } })(); diff --git a/components/dashboard/src/admin/Label.tsx b/components/dashboard/src/admin/Label.tsx new file mode 100644 index 00000000000000..73b031cfd96c82 --- /dev/null +++ b/components/dashboard/src/admin/Label.tsx @@ -0,0 +1,11 @@ +/** + * 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. + */ + +function Label(p: { text: string, color: string }) { + return {p.text}; +} + +export default Label; diff --git a/components/dashboard/src/admin/ProjectDetail.tsx b/components/dashboard/src/admin/ProjectDetail.tsx index 6ac5d495ffacf0..b2655bbbbe7b50 100644 --- a/components/dashboard/src/admin/ProjectDetail.tsx +++ b/components/dashboard/src/admin/ProjectDetail.tsx @@ -32,7 +32,7 @@ export default function ProjectDetail(props: { project: Project, owner: string | : <> - {props.owner} + {props.owner} (Team) } diff --git a/components/dashboard/src/admin/TeamDetail.tsx b/components/dashboard/src/admin/TeamDetail.tsx new file mode 100644 index 00000000000000..1ccad4949acfda --- /dev/null +++ b/components/dashboard/src/admin/TeamDetail.tsx @@ -0,0 +1,107 @@ +/** + * 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 DropDown from "../components/DropDown"; +import { Link } from "react-router-dom"; +import Label from "./Label"; +import Property from "./Property"; + +export default function TeamDetail(props: { team: Team }) { + const { team } = props; + const [teamMembers, setTeamMembers] = useState(undefined); + const [searchText, setSearchText] = useState(''); + + 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 <> +
+
+

{team.name}

+ {team.markedDeleted && } +
+ /t/{team.slug} + ยท + Created on {moment(team.creationTime).format('MMM D, YYYY')} +
+
+
+ {!team.markedDeleted && teamMembers && + {teamMembers.length}} +
+
+
+
+ +
+ setSearchText(e.target.value)} /> +
+
+ + + + + Name + + + Joined + + + + Role + + + {team.markedDeleted || (!filteredMembers || filteredMembers.length === 0) + ?

No members found

+ : filteredMembers && filteredMembers.map(m => + +
{m.avatarUrl && {m.fullName}}
+
+
{m.fullName}
+

{m.primaryEmail}

+
+
+ + {moment(m.memberSince).fromNow()} + + + + setTeamMemberRole(m.userId, 'owner') + }, { + title: 'member', + onClick: () => setTeamMemberRole(m.userId, 'member') + }]} /> + + +
)} +
+ +} diff --git a/components/dashboard/src/admin/TeamsSearch.tsx b/components/dashboard/src/admin/TeamsSearch.tsx new file mode 100644 index 00000000000000..464f40e9461e78 --- /dev/null +++ b/components/dashboard/src/admin/TeamsSearch.tsx @@ -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 Label from "./Label"; + +export default function TeamsSearchPage() { + return ( + + + + ) +} + +export function TeamsSearch() { + const location = useLocation(); + const { user } = useContext(UserContext); + const [searching, setSearching] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [currentTeam, setCurrentTeam] = useState(undefined); + const [searchResult, setSearchResult] = useState>({ 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 + } + + if (currentTeam) { + return ; + } + + 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 <> +
+
+
+
+ + + +
+ k.key === 'Enter' && search()} onChange={(v) => { setSearchTerm(v.target.value) }} /> +
+ +
+
+
+
+
Name
+
Created + +
+ + +
+ {searchResult.rows.map(team => )} +
+ + +function TeamResultItem(props: { team: Team }) { + return ( + +
+
+
{props.team.name} + {props.team.markedDeleted &&
+
+
+
{moment(props.team.creationTime).format('MMM D, YYYY')}
+
+
+ + ) +} +} \ No newline at end of file diff --git a/components/dashboard/src/admin/admin-menu.ts b/components/dashboard/src/admin/admin-menu.ts index f4a50093f1b9fd..6b0581d61942c5 100644 --- a/components/dashboard/src/admin/admin-menu.ts +++ b/components/dashboard/src/admin/admin-menu.ts @@ -17,6 +17,10 @@ export const adminMenu = [ title: 'Projects', link: ['/admin/projects'] }, + { + title: 'Teams', + link: ['/admin/teams'] + }, { title: 'Settings', link: ['/admin/settings'] diff --git a/components/gitpod-db/src/team-db.spec.db.ts b/components/gitpod-db/src/team-db.spec.db.ts index a46fbefc3a0106..24e69932dea243 100644 --- a/components/gitpod-db/src/team-db.spec.db.ts +++ b/components/gitpod-db/src/team-db.spec.db.ts @@ -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() diff --git a/components/gitpod-db/src/team-db.ts b/components/gitpod-db/src/team-db.ts index 06d658b1860672..158e30093e23ff 100644 --- a/components/gitpod-db/src/team-db.ts +++ b/components/gitpod-db/src/team-db.ts @@ -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; findMembersByTeam(teamId: string): Promise; findTeamsByUser(userId: string): Promise; diff --git a/components/gitpod-db/src/typeorm/team-db-impl.ts b/components/gitpod-db/src/typeorm/team-db-impl.ts index 6047ea4f0cb401..7532087a140d48 100644 --- a/components/gitpod-db/src/typeorm/team-db-impl.ts +++ b/components/gitpod-db/src/typeorm/team-db-impl.ts @@ -41,6 +41,25 @@ export class TeamDBImpl implements TeamDB { return (await this.getEntityManager()).getRepository(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 { const teamRepo = await this.getTeamRepo(); return teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false}); diff --git a/components/gitpod-protocol/src/admin-protocol.ts b/components/gitpod-protocol/src/admin-protocol.ts index ced62100020a83..5c23f40691ea29 100644 --- a/components/gitpod-protocol/src/admin-protocol.ts +++ b/components/gitpod-protocol/src/admin-protocol.ts @@ -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"; @@ -21,7 +20,10 @@ export interface AdminServer { adminModifyRoleOrPermission(req: AdminModifyRoleOrPermissionRequest): Promise; adminModifyPermanentWorkspaceFeatureFlag(req: AdminModifyPermanentWorkspaceFeatureFlagRequest): Promise; + adminGetTeamMembers(teamId: string): Promise; + adminGetTeams(req: AdminGetListRequest): Promise>; adminGetTeamById(id: string): Promise; + adminSetTeamMemberRole(teamId: string, userId: string, role: TeamMemberRole): Promise; adminGetWorkspaces(req: AdminGetWorkspacesRequest): Promise>; adminGetWorkspace(id: string): Promise; diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 1b8b858832c77a..4de1bf1c3195c9 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -7,7 +7,7 @@ import { injectable, inject } from "inversify"; import { GitpodServerImpl, traceAPIParams, traceWI, censor } from "../../../src/workspace/gitpod-server-impl"; import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; -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"; +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"; import { ResponseError } from "vscode-jsonrpc"; import { TakeSnapshotRequest, AdmissionLevel, ControlAdmissionRequest, StopWorkspacePolicy, DescribeWorkspaceRequest, SetTimeoutRequest } from "@gitpod/ws-manager/lib"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; @@ -548,12 +548,37 @@ export class GitpodServerEEImpl extends GitpodServerImpl { return this.censorUser(target); } + async adminGetTeamMembers(ctx: TraceContext, teamId: string): Promise { + this.requireEELicense(Feature.FeatureAdminDashboard); + await this.guardAdminAccess("adminGetTeamMembers", { teamId }, Permission.ADMIN_WORKSPACES); + + const team = await this.teamDB.findTeamById(teamId); + if (!team) { + throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found"); + } + const members = await this.teamDB.findMembersByTeam(team.id); + return members; + } + + async adminGetTeams(ctx: TraceContext, req: AdminGetListRequest): Promise> { + this.requireEELicense(Feature.FeatureAdminDashboard); + await this.guardAdminAccess("adminGetTeams", { req }, Permission.ADMIN_WORKSPACES); + + return await this.teamDB.findTeams(req.offset, req.limit, req.orderBy, req.orderDir === "asc" ? "ASC" : "DESC", req.searchTerm as string); + } + async adminGetTeamById(ctx: TraceContext, id: string): Promise { this.requireEELicense(Feature.FeatureAdminDashboard); await this.guardAdminAccess("adminGetTeamById", { id }, Permission.ADMIN_WORKSPACES); return await this.teamDB.findTeamById(id); } + async adminSetTeamMemberRole(ctx: TraceContext, teamId: string, userId: string, role: TeamMemberRole): Promise { + this.requireEELicense(Feature.FeatureAdminDashboard); + await this.guardAdminAccess("adminSetTeamMemberRole", { teamId, userId, role }, Permission.ADMIN_WORKSPACES); + return this.teamDB.setTeamMemberRole(userId, teamId, role); + } + async adminGetWorkspaces(ctx: TraceContext, req: AdminGetWorkspacesRequest): Promise> { traceAPIParams(ctx, { req }); diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index c1dd380d0f3073..e7084188b7d9f4 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -137,7 +137,10 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { "adminDeleteUser": { group: "default", points: 1 }, "adminModifyRoleOrPermission": { group: "default", points: 1 }, "adminModifyPermanentWorkspaceFeatureFlag": { group: "default", points: 1 }, + "adminGetTeams": { group: "default", points: 1 }, + "adminGetTeamMembers": { group: "default", points: 1 }, "adminGetTeamById": { group: "default", points: 1 }, + "adminSetTeamMemberRole": { group: "default", points: 1 }, "adminGetWorkspaces": { group: "default", points: 1 }, "adminGetWorkspace": { group: "default", points: 1 }, "adminForceStopWorkspace": { 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 78f386ecd8de90..78e71624a001b6 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -2203,6 +2203,18 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { throw new ResponseError(ErrorCodes.EE_FEATURE, `Admin support is implemented in Gitpod's Enterprise Edition`); } + async adminGetTeams(ctx: TraceContext, req: AdminGetListRequest): Promise> { + throw new ResponseError(ErrorCodes.EE_FEATURE, `Admin support is implemented in Gitpod's Enterprise Edition`); + } + + async adminGetTeamMembers(ctx: TraceContext, teamId: string): Promise { + throw new ResponseError(ErrorCodes.EE_FEATURE, `Admin support is implemented in Gitpod's Enterprise Edition`); + } + + async adminSetTeamMemberRole(ctx: TraceContext, teamId: string, userId: string, role: TeamMemberRole): Promise { + throw new ResponseError(ErrorCodes.EE_FEATURE, `Admin support is implemented in Gitpod's Enterprise Edition`); + } + async adminGetTeamById(ctx: TraceContext, id: string): Promise { throw new ResponseError(ErrorCodes.EE_FEATURE, `Admin support is implemented in Gitpod's Enterprise Edition`); }