Path: blob/main/components/dashboard/src/teams/Members.tsx
2501 views
/**1* Copyright (c) 2021 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License.AGPL.txt in the project root for license information.4*/56import dayjs from "dayjs";7import { useMemo, useState } from "react";8import { trackEvent } from "../Analytics";9import DropDown from "../components/DropDown";10import Header from "../components/Header";11import { Item, ItemField, ItemFieldContextMenu, ItemsList } from "../components/ItemsList";12import Modal, { ModalBody, ModalFooter, ModalHeader } from "../components/Modal";13import Tooltip from "../components/Tooltip";14import { useCurrentOrg } from "../data/organizations/orgs-query";15import searchIcon from "../icons/search.svg";16import { organizationClient } from "../service/public-api";17import { useCurrentUser } from "../user-context";18import { SpinnerLoader } from "../components/Loader";19import { InputField } from "../components/forms/InputField";20import { InputWithCopy } from "../components/InputWithCopy";21import { OrganizationMember, OrganizationRole } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";22import { useListOrganizationMembers, useOrganizationMembersInvalidator } from "../data/organizations/members-query";23import { useInvitationId, useInviteInvalidator } from "../data/organizations/invite-query";24import { Delayed } from "@podkit/loading/Delayed";25import { Button } from "@podkit/buttons/Button";26import { isGitpodIo } from "../utils";2728function getHumanReadable(role: OrganizationRole): string {29return OrganizationRole[role].toLowerCase();30}3132const AvailableRoleOptions = [OrganizationRole.OWNER, OrganizationRole.MEMBER, OrganizationRole.COLLABORATOR];3334export default function MembersPage() {35const user = useCurrentUser();36const org = useCurrentOrg();37const membersQuery = useListOrganizationMembers();38const members: OrganizationMember[] = useMemo(() => membersQuery.data || [], [membersQuery.data]);39const invalidateInviteQuery = useInviteInvalidator();40const invalidateMembers = useOrganizationMembersInvalidator();4142const [showInviteModal, setShowInviteModal] = useState<boolean>(false);43const [searchText, setSearchText] = useState<string>("");44const [roleFilter, setRoleFilter] = useState<OrganizationRole | undefined>();45const [memberToRemove, setMemberToRemove] = useState<OrganizationMember | undefined>(undefined);46const inviteId = useInvitationId().data;4748const inviteUrl = useMemo(() => {49if (!org.data) {50return undefined;51}52// orgs without an invitation id invite members through their own login page53const link = new URL(window.location.href);54if (!inviteId) {55link.pathname = "/login/" + org.data.slug;56} else {57link.pathname = "/orgs/join";58link.search = "?inviteId=" + inviteId;59}60return link.href;61}, [org.data, inviteId]);6263const resetInviteLink = async () => {64await organizationClient.resetOrganizationInvitation({ organizationId: org.data?.id });65invalidateInviteQuery();66};6768const setTeamMemberRole = async (userId: string, role: OrganizationRole) => {69await organizationClient.updateOrganizationMember({70organizationId: org.data?.id,71userId,72role,73});74invalidateMembers();75};7677const isRemainingOwner = useMemo(() => {78const owners = members.filter((m) => m.role === OrganizationRole.OWNER);79return owners?.length === 1 && owners[0].userId === user?.id;80}, [members, user?.id]);8182const isOwner = useMemo(() => {83const owners = members.filter((m) => m.role === OrganizationRole.OWNER);84return !!owners?.some((o) => o.userId === user?.id);85}, [members, user?.id]);8687// Note: We would hardly get here, but just in case. We should show a loader instead of blank section.88if (org.isLoading) {89return (90<Delayed>91<SpinnerLoader />92</Delayed>93);94}9596const filteredMembers =97members.filter((m) => {98if (!!roleFilter && m.role !== roleFilter) {99return false;100}101const memberSearchText = `${m.fullName || ""}${m.email || ""}`.toLocaleLowerCase();102if (!memberSearchText.includes(searchText.toLocaleLowerCase())) {103return false;104}105return true;106}) || [];107108return (109<>110<Header title="Members" subtitle="Manage organization members and their permissions." />111<div className="app-container">112<div className="flex mb-3 mt-3">113<div className="flex relative h-10 my-auto">114<img115src={searchIcon}116title="Search"117className="filter-grayscale absolute top-3 left-3"118alt="search icon"119/>120<input121className="w-64 pl-9 border-0"122type="search"123placeholder="Filter Members"124onChange={(e) => setSearchText(e.target.value)}125/>126</div>127<div className="py-2 pl-3 capitalize pr-1 border border-gray-100 dark:border-gray-800 ml-2 rounded-md">128<DropDown129customClasses="w-36"130activeEntry={roleFilter ? getHumanReadable(roleFilter) + "s" : "All"}131entries={[132{133title: "All",134onClick: () => setRoleFilter(undefined),135},136...AvailableRoleOptions.map((role) => ({137title: getHumanReadable(role) + "s",138onClick: () => setRoleFilter(role),139})),140]}141/>142</div>143<div className="flex-1" />144{isOwner && (145<Button146onClick={() => {147trackEvent("invite_url_requested", {148invite_url: inviteUrl || "",149});150setShowInviteModal(true);151}}152className="ml-2"153>154Invite Members155</Button>156)}157</div>158<ItemsList className="mt-2">159<Item header={true} className="grid grid-cols-3">160<ItemField className="my-auto">161<span className="pl-14">Name</span>162</ItemField>163<ItemField className="flex items-center space-x-1 my-auto">164<span>Joined</span>165<svg xmlns="http://www.w3.org/2000/svg" fill="none" className="h-4 w-4" viewBox="0 0 16 16">166<path167fill="#A8A29E"168fillRule="evenodd"169d="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"170clipRule="evenodd"171/>172</svg>173</ItemField>174<ItemField className="flex items-center my-auto">175<span className="flex-grow">Role</span>176</ItemField>177</Item>178{filteredMembers.length === 0 ? (179<p className="pt-16 text-center">No members found</p>180) : (181filteredMembers.map((m) => (182<Item className="grid grid-cols-3" key={m.userId}>183<ItemField className="flex items-center my-auto">184<div className="flex-shrink-0">185{m.avatarUrl && (186<img187className="rounded-full w-8 h-8"188src={m.avatarUrl || ""}189alt={m.fullName}190/>191)}192</div>193<div className="ml-5 truncate">194<div195className="text-base text-gray-900 dark:text-gray-50 font-medium"196title={m.fullName}197>198{m.fullName}199</div>200<p title={m.email}>{m.email}</p>201</div>202</ItemField>203<ItemField className="my-auto">204<Tooltip content={dayjs(m.memberSince?.toDate()).format("MMM D, YYYY")}>205<span className="text-gray-400">206{dayjs(m.memberSince?.toDate()).fromNow()}207</span>208</Tooltip>209</ItemField>210<ItemField className="flex items-center my-auto">211<span className="text-gray-400 capitalize">212{isOwner ? (213<DropDown214customClasses="w-36"215activeEntry={getHumanReadable(m.role)}216entries={AvailableRoleOptions.map((role) => ({217title: getHumanReadable(role),218onClick: () => setTeamMemberRole(m.userId, role),219}))}220/>221) : (222getHumanReadable(m.role)223)}224</span>225<span className="flex-grow" />226<ItemFieldContextMenu227menuEntries={228m.userId === user?.id229? [230{231title: !isRemainingOwner232? "Leave Organization"233: "Remaining owner",234customFontStyle: !isRemainingOwner235? "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300"236: "text-gray-400 dark:text-gray-200",237onClick: () => !isRemainingOwner && setMemberToRemove(m),238},239]240: isOwner241? [242{243title: "Remove",244customFontStyle:245"text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300",246onClick: () => setMemberToRemove(m),247},248]249: []250}251/>252</ItemField>253</Item>254))255)}256</ItemsList>257</div>258{inviteUrl && showInviteModal && (259// TODO: Use title and buttons props260<Modal visible={true} onClose={() => setShowInviteModal(false)}>261<ModalHeader>Invite Members</ModalHeader>262<ModalBody>263<InputField264label="Invite URL"265hint={`Share this URL to allow others to join this organization.`}266>267<InputWithCopy value={inviteUrl} tip="Copy Invite URL" />268</InputField>269{isGitpodIo() && (270<div className="text-pk-content-tertiary mt-3">271<span className="text-sm font-bold">Need SSO? </span>272<a273className="text-sm gp-link"274href="https://www.gitpod.io/docs/enterprise"275target="_blank"276rel="noreferrer"277>278Try Gitpod Enterprise279</a>280</div>281)}282</ModalBody>283<ModalFooter>284{!!inviteId && (285<Button variant="secondary" onClick={() => resetInviteLink()}>286Reset Invite Link287</Button>288)}289<Button variant="secondary" onClick={() => setShowInviteModal(false)}>290Close291</Button>292</ModalFooter>293</Modal>294)}295{memberToRemove && (296// TODO: Use title and buttons props297<Modal visible={true} onClose={() => setMemberToRemove(undefined)}>298<ModalHeader>Remove Members</ModalHeader>299<ModalBody>300You are about to remove <b>{memberToRemove.fullName}</b> from this organization.301<br />302<br />303{memberToRemove.ownedByOrganization ? (304<>This will delete the user account and all associated data.</>305) : null}306</ModalBody>307<ModalFooter>308<Button variant="secondary" onClick={() => setMemberToRemove(undefined)}>309Cancel310</Button>311<Button312variant="default"313onClick={async () => {314await organizationClient.deleteOrganizationMember({315organizationId: org.data?.id,316userId: memberToRemove.userId,317});318invalidateMembers();319setMemberToRemove(undefined);320}}321>322Remove323</Button>324</ModalFooter>325</Modal>326)}327</>328);329}330331332