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