Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/collaborators/current-collabs.tsx
5803 views
1
/*
2
* This file is part of CoCalc: Copyright © 2025-2026 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
// cSpell:ignore replyto collabs noncloud
7
8
import { Alert, Button, Card, Dropdown, Popconfirm } from "antd";
9
import React, { useState } from "react";
10
import { FormattedMessage, useIntl } from "react-intl";
11
12
import {
13
CSS,
14
redux,
15
useStore,
16
useTypedRedux,
17
} from "@cocalc/frontend/app-framework";
18
import {
19
Icon,
20
Paragraph,
21
SettingBox,
22
Text,
23
Tip,
24
Title,
25
} from "@cocalc/frontend/components";
26
import { useStudentProjectFunctionality } from "@cocalc/frontend/course";
27
import { labels } from "@cocalc/frontend/i18n";
28
import { CancelText } from "@cocalc/frontend/i18n/components";
29
import { Project } from "@cocalc/frontend/project/settings/types";
30
import { COLORS } from "@cocalc/util/theme";
31
import { CollaboratorsSetting } from "./collaborators-setting";
32
import { FIX_BORDER } from "../project/page/common";
33
import { User } from "../users";
34
35
const LIST_STYLE: CSS = {
36
maxHeight: "20em",
37
overflowY: "auto",
38
overflowX: "hidden",
39
marginBottom: "0",
40
display: "flex",
41
flexDirection: "column",
42
gap: "12px",
43
} as const;
44
45
interface Props {
46
project: Project;
47
user_map?: any;
48
mode?: "project" | "flyout";
49
}
50
51
export const CurrentCollaboratorsPanel: React.FC<Props> = (props: Props) => {
52
const { project, user_map, mode = "project" } = props;
53
const isFlyout = mode === "flyout";
54
const intl = useIntl();
55
const current_account_id = useTypedRedux("account", "account_id");
56
const projectStore = useStore("projects");
57
const student = useStudentProjectFunctionality(project.get("project_id"));
58
const [error, setError] = useState<string>("");
59
60
const project_id = project.get("project_id");
61
const users = project.get("users");
62
const current_user_group = users?.getIn([current_account_id, "group"]);
63
const is_requester_owner = current_user_group === "owner";
64
const strict_collaborator_management =
65
useTypedRedux("customize", "strict_collaborator_management") ?? false;
66
const manage_users_owner_only =
67
strict_collaborator_management ||
68
(project.get("manage_users_owner_only") ?? false);
69
70
// Count owners to check if this is the last owner
71
const owner_count = users
72
? users.valueSeq().count((u: any) => u?.get?.("group") === "owner")
73
: 0;
74
75
function remove_collaborator(account_id: string) {
76
redux.getActions("projects").remove_collaborator(project_id, account_id);
77
if (account_id === current_account_id) {
78
(redux.getActions("page") as any).close_project_tab(project_id);
79
// TODO: better types
80
}
81
}
82
83
async function change_user_type(
84
account_id: string,
85
new_group: "owner" | "collaborator",
86
) {
87
try {
88
setError("");
89
await redux
90
.getActions("projects")
91
.change_user_type(project_id, account_id, new_group);
92
} catch (err) {
93
setError(`Error: ${err}`);
94
}
95
}
96
97
function user_remove_confirm_text(account_id: string) {
98
const style: CSS = { maxWidth: "300px" };
99
if (account_id === current_account_id) {
100
return (
101
<div style={style}>
102
<FormattedMessage
103
id="collaborators.current-collabs.remove_self"
104
defaultMessage={`Are you sure you want to remove <b>yourself</b> from this project?
105
You will no longer have access to this project and cannot add yourself back.`}
106
/>
107
</div>
108
);
109
} else {
110
return (
111
<div style={style}>
112
<FormattedMessage
113
id="collaborators.current-collabs.remove_other"
114
defaultMessage={`Are you sure you want to remove {user} from this project?
115
They will no longer have access to this project.`}
116
values={{
117
user: <User account_id={account_id} user_map={user_map} />,
118
}}
119
/>
120
</div>
121
);
122
}
123
}
124
125
function renderRoleSetting(account_id: string, group?: string) {
126
const isOwner = group === "owner";
127
const isLastOwner = isOwner && owner_count === 1;
128
const can_promote = !isOwner && is_requester_owner;
129
const can_demote = isOwner && is_requester_owner && !isLastOwner;
130
131
const buttonSize = isFlyout ? "small" : "middle";
132
const roleLabel = intl.formatMessage(
133
isOwner ? labels.owner : labels.collaborator,
134
);
135
136
// If not allowed to change owner/collab status, simply report the role of the given user
137
if (student.disableCollaborators || !is_requester_owner) {
138
const label = (
139
<Text type="secondary" style={{ padding: "0 6px" }}>
140
{`(${roleLabel})`}
141
</Text>
142
);
143
return isFlyout ? (
144
<div style={{ display: "flex", justifyContent: "flex-end" }}>
145
{label}
146
</div>
147
) : (
148
label
149
);
150
}
151
152
const menuItems = [
153
{
154
key: "promote",
155
label: (
156
<Tip
157
title={intl.formatMessage({
158
id: "project.collaborators.promote.tooltip",
159
defaultMessage:
160
"Promote this collaborator to owner, giving them full project control",
161
})}
162
>
163
<FormattedMessage
164
id="project.collaborators.promote.label"
165
defaultMessage="Promote to Owner"
166
/>
167
</Tip>
168
),
169
disabled: !can_promote,
170
onClick: () => change_user_type(account_id, "owner"),
171
},
172
{
173
key: "demote",
174
label: (
175
<Tip
176
title={intl.formatMessage(
177
{
178
id: "project.collaborators.demote.tooltip",
179
defaultMessage:
180
"{isLastOwner, select, true {Cannot demote the last owner} other {Demote this owner to collaborator}}",
181
},
182
{ isLastOwner },
183
)}
184
>
185
<FormattedMessage
186
id="project.collaborators.demote.label"
187
defaultMessage="Demote to Collaborator"
188
/>
189
</Tip>
190
),
191
disabled: !can_demote,
192
onClick: () => change_user_type(account_id, "collaborator"),
193
},
194
];
195
196
const dropdown = (
197
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
198
<Button
199
type="link"
200
size={buttonSize}
201
style={{ color: COLORS.ANTD_LINK_BLUE, padding: "0 6px" }}
202
>
203
{roleLabel} <Icon name="chevron-down" />
204
</Button>
205
</Dropdown>
206
);
207
208
if (isFlyout) {
209
return (
210
<div style={{ display: "flex", justifyContent: "flex-end" }}>
211
{dropdown}
212
</div>
213
);
214
} else {
215
return dropdown;
216
}
217
}
218
219
function renderRemoveButton(account_id: string, group?: string) {
220
if (student.disableCollaborators) return;
221
const text = user_remove_confirm_text(account_id);
222
const isOwner = group === "owner";
223
const isSelf = account_id === current_account_id;
224
const disabledBySetting =
225
manage_users_owner_only && !is_requester_owner && !isSelf;
226
const disabled = isOwner || disabledBySetting;
227
228
const disabledReason = (() => {
229
if (isOwner) {
230
return intl.formatMessage({
231
id: "collaborators.current-collabs.remove.owner_disabled",
232
defaultMessage: "Owners must be demoted before they can be removed.",
233
});
234
}
235
if (disabledBySetting) {
236
return intl.formatMessage({
237
id: "collaborators.current-collabs.remove.setting_disabled",
238
defaultMessage:
239
"Only owners can remove collaborators when this setting is enabled.",
240
});
241
}
242
return undefined;
243
})();
244
245
const buttonType = isFlyout ? "link" : "default";
246
const buttonSize = isFlyout ? "small" : "middle";
247
248
return (
249
<Tip title={disabledReason}>
250
<Popconfirm
251
title={text}
252
onConfirm={() => remove_collaborator(account_id)}
253
okText={intl.formatMessage(
254
{
255
id: "collaborators.current-collabs.remove.ok_button",
256
defaultMessage: "Yes, remove {role}",
257
},
258
{ role: intl.formatMessage(labels.collaborator) },
259
)}
260
cancelText={<CancelText />}
261
disabled={disabled}
262
>
263
<Button
264
disabled={disabled}
265
type={buttonType}
266
size={buttonSize}
267
style={{
268
marginBottom: "0",
269
...(isFlyout
270
? { color: COLORS.ANTD_RED_WARN, padding: "0 4px" }
271
: {}),
272
}}
273
>
274
<Icon name="user-times" /> {intl.formatMessage(labels.remove)}
275
</Button>
276
</Popconfirm>
277
</Tip>
278
);
279
}
280
281
function render_user(user: any, is_last?: boolean) {
282
const baseStyle: CSS = {
283
width: "100%",
284
flex: "1 1 auto",
285
...(!is_last ? { marginBottom: "20px" } : {}),
286
};
287
288
if (isFlyout) {
289
return (
290
<div key={user.account_id} style={baseStyle}>
291
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
292
<User
293
account_id={user.account_id}
294
user_map={user_map}
295
last_active={user.last_active}
296
show_avatar={true}
297
/>
298
</div>
299
<div
300
style={{
301
display: "flex",
302
justifyContent: "flex-end",
303
gap: "4px",
304
marginTop: "4px",
305
}}
306
>
307
{renderRoleSetting(user.account_id, user.group)}
308
{renderRemoveButton(user.account_id, user.group)}
309
</div>
310
</div>
311
);
312
}
313
314
return (
315
<div
316
key={user.account_id}
317
style={{
318
...baseStyle,
319
display: "flex",
320
alignItems: "center",
321
justifyContent: "space-between",
322
}}
323
>
324
<div style={{ flex: "1 1 auto" }}>
325
<User
326
account_id={user.account_id}
327
user_map={user_map}
328
last_active={user.last_active}
329
show_avatar={true}
330
/>
331
</div>
332
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
333
{renderRoleSetting(user.account_id, user.group)}
334
{renderRemoveButton(user.account_id, user.group)}
335
</div>
336
</div>
337
);
338
}
339
340
function render_users() {
341
const u = project.get("users");
342
if (u === undefined) {
343
return;
344
}
345
const users = u
346
.map((v, k) => ({ account_id: k, group: v.get("group") }))
347
.toList()
348
.toJS();
349
return projectStore
350
.sort_by_activity(users, project.get("project_id"))
351
.map((u: any, i: number) => render_user(u, i === users.length - 1));
352
}
353
354
function render_setting() {
355
return (
356
<div style={{ marginTop: "12px" }}>
357
<CollaboratorsSetting project={project} withSettingBox={false} />
358
</div>
359
);
360
}
361
362
function render_collaborators_list() {
363
const header = (
364
<>
365
{error && (
366
<Alert
367
type="error"
368
message={error}
369
closable
370
onClose={() => setError("")}
371
style={{ marginBottom: "10px" }}
372
/>
373
)}
374
</>
375
);
376
377
const list = <div style={LIST_STYLE}>{render_users()}</div>;
378
379
if (isFlyout) {
380
return (
381
<div style={{ borderBottom: FIX_BORDER }}>
382
{header}
383
{list}
384
</div>
385
);
386
} else {
387
return (
388
<Card style={{ backgroundColor: COLORS.GRAY_LLL }}>
389
{header}
390
{list}
391
</Card>
392
);
393
}
394
}
395
396
const introText = intl.formatMessage(
397
{
398
id: "collaborators.current-collabs.intro2",
399
defaultMessage: `Everybody listed below can collaboratively work with you on any Jupyter Notebook, Linux Terminal or file in this project.
400
{manageUsersOnly, select,
401
true { Only project owners can add or remove collaborators.}
402
other { Collaborators can also add or remove other collaborators.}}`,
403
},
404
{ manageUsersOnly: manage_users_owner_only ? "true" : "false" },
405
);
406
407
const nonOwnerNote = !is_requester_owner
408
? intl.formatMessage({
409
id: "project.collaborators.non_owner_note",
410
defaultMessage: "Only project owners can manage user roles.",
411
})
412
: null;
413
414
const titleText = intl.formatMessage({
415
id: "collaborators.current-collabs.title",
416
defaultMessage: "Current Collaborators",
417
description: "Title of a table listing users collaborating on that project",
418
});
419
420
switch (mode) {
421
case "project":
422
return (
423
<SettingBox title="Current Collaborators" icon="user">
424
<div>
425
{introText}
426
{nonOwnerNote && (
427
<>
428
{" "}
429
<Text type="secondary">{nonOwnerNote}</Text>
430
</>
431
)}
432
</div>
433
<hr />
434
{render_collaborators_list()}
435
<hr />
436
{render_setting()}
437
</SettingBox>
438
);
439
case "flyout":
440
return (
441
<div style={{ paddingLeft: "5px" }}>
442
<Title level={3}>
443
<Icon name="user" /> {titleText}
444
</Title>
445
<Paragraph
446
type="secondary"
447
ellipsis={{ rows: 1, expandable: true, symbol: "more" }}
448
>
449
{introText}
450
{nonOwnerNote && <> {nonOwnerNote}</>}
451
</Paragraph>
452
{render_collaborators_list()}
453
{render_setting()}
454
</div>
455
);
456
}
457
};
458
459