Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/collaborators/add-collaborators.tsx
5966 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020-2026 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Add collaborators to a project
8
*/
9
10
// cSpell:ignore replyto noncloud collabs
11
12
import { Alert, Button, Input, Select } from "antd";
13
import { FormattedMessage, useIntl } from "react-intl";
14
15
import { labels } from "@cocalc/frontend/i18n";
16
import {
17
React,
18
redux,
19
useActions,
20
useIsMountedRef,
21
useMemo,
22
useRef,
23
useTypedRedux,
24
useState,
25
} from "../app-framework";
26
import { Well } from "@cocalc/frontend/antd-bootstrap";
27
import {
28
A,
29
Icon,
30
Loading,
31
ErrorDisplay,
32
Gap,
33
} from "@cocalc/frontend/components";
34
import { webapp_client } from "@cocalc/frontend/webapp-client";
35
import { SITE_NAME } from "@cocalc/util/theme";
36
import {
37
contains_url,
38
plural,
39
cmp,
40
trunc_middle,
41
is_valid_email_address,
42
is_valid_uuid_string,
43
search_match,
44
search_split,
45
} from "@cocalc/util/misc";
46
import { Project } from "@cocalc/frontend/projects/store";
47
import { Avatar } from "@cocalc/frontend/account/avatar/avatar";
48
import { ProjectInviteTokens } from "./project-invite-tokens";
49
import { alert_message } from "@cocalc/frontend/alerts";
50
import { useStudentProjectFunctionality } from "@cocalc/frontend/course";
51
import Sandbox from "./sandbox";
52
import track from "@cocalc/frontend/user-tracking";
53
import RequireLicense from "@cocalc/frontend/site-licenses/require-license";
54
55
interface RegisteredUser {
56
sort?: string;
57
account_id: string;
58
first_name?: string;
59
last_name?: string;
60
last_active?: number;
61
created?: number;
62
email_address?: string;
63
email_address_verified?: boolean;
64
label?: string;
65
tag?: string;
66
name?: string;
67
}
68
69
interface NonregisteredUser {
70
sort?: string;
71
email_address: string;
72
account_id?: undefined;
73
first_name?: undefined;
74
last_name?: undefined;
75
last_active?: undefined;
76
created?: undefined;
77
email_address_verified?: undefined;
78
label?: string;
79
tag?: string;
80
name?: string;
81
}
82
83
type User = RegisteredUser | NonregisteredUser;
84
85
interface Props {
86
project_id: string;
87
autoFocus?: boolean;
88
where: string; // used for tracking only right now, so we know from where people add collaborators.
89
mode?: "project" | "flyout";
90
}
91
92
type State = "input" | "searching" | "searched" | "invited" | "invited_errors";
93
94
export const AddCollaborators: React.FC<Props> = ({
95
autoFocus,
96
project_id,
97
where,
98
mode = "project",
99
}) => {
100
const intl = useIntl();
101
const unlicensedLimit = useTypedRedux(
102
"customize",
103
"unlicensed_project_collaborator_limit",
104
);
105
const isFlyout = mode === "flyout";
106
const student = useStudentProjectFunctionality(project_id);
107
const accountCustomize = useTypedRedux("account", "customize")?.toJS() as
108
| { disableCollaborators?: boolean }
109
| undefined;
110
const user_map = useTypedRedux("users", "user_map");
111
const project_map = useTypedRedux("projects", "project_map");
112
const project: Project | undefined = useMemo(
113
() => project_map?.get(project_id),
114
[project_id, project_map],
115
);
116
const current_account_id = useTypedRedux("account", "account_id");
117
const strict_collaborator_management =
118
useTypedRedux("customize", "strict_collaborator_management") ?? false;
119
const manage_users_owner_only =
120
strict_collaborator_management ||
121
(project?.get("manage_users_owner_only") ?? false);
122
const current_user_group = project?.getIn([
123
"users",
124
current_account_id,
125
"group",
126
]);
127
const isOwner = current_user_group === "owner";
128
const collaboratorManagementRestricted = manage_users_owner_only && !isOwner;
129
130
// search that user has typed in so far
131
const [search, set_search] = useState<string>("");
132
const search_ref = useRef<string>("");
133
134
// list of results for doing the search -- turned into a selector
135
const [results, set_results] = useState<User[]>([]);
136
const [num_matching_already, set_num_matching_already] = useState<number>(0);
137
138
// list of actually selected entries in the selector list
139
const [selected_entries, set_selected_entries] = useState<string[]>([]);
140
const select_ref = useRef<any>(null);
141
142
// currently carrying out a search
143
const [state, set_state] = useState<State>("input");
144
const [focused, set_focused] = useState<boolean>(false);
145
// display an error in case something went wrong doing a search
146
const [err, set_err] = useState<string>("");
147
// if set, adding user via email to this address
148
const [email_to, set_email_to] = useState<string>("");
149
// with this body.
150
const [email_body, set_email_body] = useState<string>("");
151
const [email_body_error, set_email_body_error] = useState<string>("");
152
const [email_body_editing, set_email_body_editing] = useState<boolean>(false);
153
const [invite_result, set_invite_result] = useState<string>("");
154
155
const hasLicense = (project?.get("site_license")?.size ?? 0) > 0;
156
const limitExceeded =
157
!!unlicensedLimit &&
158
!hasLicense &&
159
(project?.get("users").size ?? 1) + selected_entries.length >
160
unlicensedLimit;
161
162
const isMountedRef = useIsMountedRef();
163
164
const project_actions = useActions("projects");
165
166
const allow_urls = useMemo(
167
() => redux.getStore("projects").allow_urls_in_emails(project_id),
168
[project_id],
169
);
170
171
function reset(): void {
172
set_search("");
173
set_results([]);
174
set_num_matching_already(0);
175
set_selected_entries([]);
176
set_state("input");
177
set_err("");
178
set_email_to("");
179
set_email_body("");
180
set_email_body_error("");
181
set_email_body_editing(false);
182
}
183
184
async function do_search(search: string): Promise<void> {
185
if (state == "searching" || project == null) {
186
// already searching
187
return;
188
}
189
set_search(search);
190
if (search.length === 0) {
191
set_err("");
192
set_results([]);
193
return;
194
}
195
set_state("searching");
196
let err = "";
197
let search_results: User[] = [];
198
let num_already_matching = 0;
199
const already = new Set<string>([]);
200
try {
201
for (let query of search.split(",")) {
202
query = query.trim().toLowerCase();
203
const query_results = await webapp_client.users_client.user_search({
204
query,
205
limit: 30,
206
});
207
if (!isMountedRef.current) return; // no longer mounted
208
if (query_results.length == 0 && is_valid_email_address(query)) {
209
const email_address = query;
210
if (!already.has(email_address)) {
211
search_results.push({ email_address, sort: "0" + email_address });
212
already.add(email_address);
213
}
214
} else {
215
// There are some results, so not adding non-cloud user via email.
216
// Filter out any users that already a collab on this project.
217
for (const r of query_results) {
218
if (r.account_id == null) continue; // won't happen
219
if (project.getIn(["users", r.account_id]) == null) {
220
if (!already.has(r.account_id)) {
221
search_results.push(r);
222
already.add(r.account_id);
223
} else {
224
// if we got additional information about email
225
// address and already have this user, remember that
226
// extra info.
227
if (r.email_address != null) {
228
for (const x of search_results) {
229
if (x.account_id == r.account_id) {
230
x.email_address = r.email_address;
231
}
232
}
233
}
234
}
235
} else {
236
num_already_matching += 1;
237
}
238
}
239
}
240
}
241
} catch (e) {
242
err = e.toString();
243
}
244
set_num_matching_already(num_already_matching);
245
write_email_invite();
246
// sort search_results with collaborators first by last_active,
247
// then non-collabs by last_active.
248
search_results.sort((x, y) => {
249
let c = cmp(
250
x.account_id && user_map.has(x.account_id) ? 0 : 1,
251
y.account_id && user_map.has(y.account_id) ? 0 : 1,
252
);
253
if (c) return c;
254
c = -cmp(x.last_active?.valueOf() ?? 0, y.last_active?.valueOf() ?? 0);
255
if (c) return c;
256
return cmp(x.last_name?.toLowerCase(), y.last_name?.toLowerCase());
257
});
258
259
set_state("searched");
260
set_err(err);
261
set_results(search_results);
262
set_email_to("");
263
select_ref.current?.focus();
264
}
265
266
function render_options(users: User[]): React.JSX.Element[] {
267
const options: React.JSX.Element[] = [];
268
for (const r of users) {
269
if (r.label == null || r.tag == null || r.name == null) {
270
let name = r.account_id
271
? (r.first_name ?? "") + " " + (r.last_name ?? "")
272
: r.email_address;
273
if (!name?.trim()) {
274
name = "Anonymous User";
275
}
276
const tag = trunc_middle(name, 20);
277
278
// Extra display is a bit ugly, but we need to do it for now. Need to make
279
// react rendered version of this that is much nicer (with pictures!) someday.
280
const extra: string[] = [];
281
if (r.account_id != null && user_map.get(r.account_id)) {
282
extra.push(intl.formatMessage(labels.collaborator));
283
}
284
if (r.last_active) {
285
extra.push(`Active ${new Date(r.last_active).toLocaleDateString()}`);
286
}
287
if (r.created) {
288
extra.push(`Created ${new Date(r.created).toLocaleDateString()}`);
289
}
290
if (r.account_id == null) {
291
extra.push(`No account`);
292
} else {
293
if (r.email_address) {
294
if (r.email_address_verified?.[r.email_address]) {
295
extra.push(`${r.email_address} -- verified`);
296
} else {
297
extra.push(`${r.email_address} -- not verified`);
298
}
299
}
300
}
301
if (extra.length > 0) {
302
name += ` (${extra.join(", ")})`;
303
}
304
r.label = name.toLowerCase();
305
r.tag = tag;
306
r.name = name;
307
}
308
const x = r.account_id ?? r.email_address;
309
options.push(
310
<Select.Option key={x} value={x} label={r.label} tag={r.tag}>
311
<Avatar
312
size={36}
313
no_tooltip={true}
314
account_id={r.account_id}
315
first_name={r.account_id ? r.first_name : "@"}
316
last_name={r.last_name}
317
/>{" "}
318
<span title={r.name}>{r.name}</span>
319
</Select.Option>,
320
);
321
}
322
return options;
323
}
324
325
async function invite_collaborator(account_id: string): Promise<void> {
326
if (project == null) return;
327
const { subject, replyto, replyto_name } = sender_info();
328
329
track("invite-collaborator", {
330
where,
331
project_id,
332
account_id,
333
subject,
334
email_body,
335
});
336
await project_actions.invite_collaborator(
337
project_id,
338
account_id,
339
email_body,
340
subject,
341
false,
342
replyto,
343
replyto_name,
344
);
345
}
346
347
function add_selected(): void {
348
let errors = "";
349
for (const x of selected_entries) {
350
try {
351
if (is_valid_email_address(x)) {
352
invite_noncloud_collaborator(x);
353
} else if (is_valid_uuid_string(x)) {
354
invite_collaborator(x);
355
} else {
356
// skip
357
throw Error(
358
`BUG - invalid selection ${x} must be an email address or account_id.`,
359
);
360
}
361
} catch (err) {
362
errors += `\nError - ${err}`;
363
}
364
}
365
reset();
366
if (errors) {
367
set_invite_result(errors);
368
set_state("invited_errors");
369
} else {
370
set_invite_result(`Successfully added ${selected_entries.length} users!`);
371
set_state("invited");
372
}
373
}
374
375
function write_email_invite(): void {
376
if (project == null) return;
377
378
const name = redux.getStore("account").get_fullname();
379
const title = project.get("title");
380
const target = `project '${title}'`;
381
const SiteName = redux.getStore("customize").get("site_name") ?? SITE_NAME;
382
const body = `Hello!\n\nPlease collaborate with me using ${SiteName} on ${target}.\n\nBest wishes,\n\n${name}`;
383
set_email_to(search);
384
set_email_body(body);
385
}
386
387
function sender_info(): {
388
subject: string;
389
replyto?: string;
390
replyto_name: string;
391
} {
392
const replyto = redux.getStore("account").get_email_address();
393
const replyto_name = redux.getStore("account").get_fullname();
394
const SiteName = redux.getStore("customize").get("site_name") ?? SITE_NAME;
395
let subject;
396
if (replyto_name != null) {
397
subject = `${replyto_name} added you to project ${project?.get("title")}`;
398
} else {
399
subject = `${SiteName} Invitation to project ${project?.get("title")}`;
400
}
401
return { subject, replyto, replyto_name };
402
}
403
404
async function invite_noncloud_collaborator(email_address): Promise<void> {
405
if (project == null) return;
406
const { subject, replyto, replyto_name } = sender_info();
407
await project_actions.invite_collaborators_by_email(
408
project_id,
409
email_address,
410
email_body,
411
subject,
412
false,
413
replyto,
414
replyto_name,
415
);
416
if (!allow_urls) {
417
// Show a message that they might have to email that person
418
// and tell them to make a cocalc account, and when they do
419
// then they will get added as collaborator to this project....
420
alert_message({
421
type: "warning",
422
message: `For security reasons you should contact ${email_address} directly and ask them to join Cocalc to get access to this project.`,
423
});
424
}
425
}
426
427
function send_email_invite(): void {
428
if (project == null) return;
429
const { subject, replyto, replyto_name } = sender_info();
430
project_actions.invite_collaborators_by_email(
431
project_id,
432
email_to,
433
email_body,
434
subject,
435
false,
436
replyto,
437
replyto_name,
438
);
439
set_email_to("");
440
set_email_body("");
441
reset();
442
}
443
444
function check_email_body(value: string): void {
445
if (!allow_urls && contains_url(value)) {
446
set_email_body_error("Sending URLs is not allowed. (anti-spam measure)");
447
} else {
448
set_email_body_error("");
449
}
450
}
451
452
function render_email_body_error(): React.JSX.Element | undefined {
453
if (!email_body_error) {
454
return;
455
}
456
return <ErrorDisplay error={email_body_error} />;
457
}
458
459
function render_email_textarea(): React.JSX.Element {
460
return (
461
<Input.TextArea
462
defaultValue={email_body}
463
autoSize={true}
464
maxLength={1000}
465
showCount={true}
466
onBlur={() => {
467
set_email_body_editing(false);
468
}}
469
onFocus={() => set_email_body_editing(true)}
470
onChange={(e) => {
471
const value: string = (e.target as any).value;
472
set_email_body(value);
473
check_email_body(value);
474
}}
475
/>
476
);
477
}
478
479
function render_send_email(): React.JSX.Element | undefined {
480
if (!email_to) {
481
return;
482
}
483
484
return (
485
<div>
486
<hr />
487
<Well>
488
Enter one or more email addresses separated by commas:
489
<Input
490
placeholder="Email addresses separated by commas..."
491
value={email_to}
492
onChange={(e) => set_email_to((e.target as any).value)}
493
autoFocus
494
/>
495
<div
496
style={{
497
padding: "20px 0",
498
backgroundColor: "white",
499
marginBottom: "15px",
500
}}
501
>
502
{render_email_body_error()}
503
{render_email_textarea()}
504
</div>
505
<div style={{ display: "flex" }}>
506
<Button
507
onClick={() => {
508
set_email_to("");
509
set_email_body("");
510
set_email_body_editing(false);
511
}}
512
>
513
{intl.formatMessage(labels.cancel)}
514
</Button>
515
<Gap />
516
<Button
517
type="primary"
518
onClick={send_email_invite}
519
disabled={!!email_body_editing}
520
>
521
Send Invitation
522
</Button>
523
</div>
524
</Well>
525
</div>
526
);
527
}
528
529
function render_search(): React.JSX.Element | undefined {
530
return (
531
<div style={{ marginBottom: "15px" }}>
532
{state == "searched" ? (
533
render_select_list_button()
534
) : (
535
<>
536
Who would you like to collaborate with?{" "}
537
<b>
538
NOTE: If you are teaching,{" "}
539
<A href="https://doc.cocalc.com/teaching-create-course.html#add-students-to-the-course">
540
add your students to your course
541
</A>
542
, NOT HERE.
543
</b>
544
</>
545
)}
546
</div>
547
);
548
}
549
550
function render_select_list(): React.JSX.Element | undefined {
551
if (project == null) return;
552
553
const users: User[] = [];
554
const existing: User[] = [];
555
for (const r of results) {
556
if (project.getIn(["users", r.account_id]) != null) {
557
existing.push(r);
558
} else {
559
users.push(r);
560
}
561
}
562
563
function render_search_help(): React.JSX.Element | undefined {
564
if (focused && results.length === 0) {
565
return <Alert type="info" message={"Press enter to search..."} />;
566
}
567
}
568
569
return (
570
<div style={{ marginBottom: "10px" }}>
571
<Select
572
ref={select_ref}
573
mode="multiple"
574
allowClear
575
autoFocus={autoFocus}
576
open={autoFocus ? true : undefined}
577
filterOption={(s, opt) => {
578
if (s.indexOf(",") != -1) return true;
579
return search_match(
580
(opt as any).label,
581
search_split(s.toLowerCase()),
582
);
583
}}
584
style={{ width: "100%", marginBottom: "10px" }}
585
placeholder={
586
results.length > 0 && search.trim() ? (
587
`Select user from ${results.length} ${plural(
588
results.length,
589
"user",
590
)} matching '${search}'.`
591
) : (
592
<span>
593
<Icon name="search" /> Name or email address...
594
</span>
595
)
596
}
597
onChange={(value) => {
598
set_selected_entries(value as string[]);
599
}}
600
value={selected_entries}
601
optionLabelProp="tag"
602
onInputKeyDown={(e) => {
603
if (e.keyCode == 27) {
604
reset();
605
e.preventDefault();
606
return;
607
}
608
if (
609
e.keyCode == 13 &&
610
state != ("searching" as State) &&
611
!hasMatches()
612
) {
613
do_search(search_ref.current);
614
e.preventDefault();
615
return;
616
}
617
}}
618
onSearch={(value) => (search_ref.current = value)}
619
notFoundContent={null}
620
onFocus={() => set_focused(true)}
621
onBlur={() => set_focused(false)}
622
>
623
{render_options(users)}
624
</Select>
625
{render_search_help()}
626
{selected_entries.length > 0 && (
627
<div
628
style={{
629
border: "1px solid lightgrey",
630
padding: "10px",
631
borderRadius: "5px",
632
backgroundColor: "white",
633
margin: "10px 0",
634
}}
635
>
636
{render_email_body_error()}
637
{render_email_textarea()}
638
</div>
639
)}
640
{state == "searched" && render_select_list_button()}
641
</div>
642
);
643
}
644
645
function hasMatches(): boolean {
646
const s = search_split(search_ref.current.toLowerCase());
647
if (s.length == 0) return true;
648
for (const r of results) {
649
if (r.label == null) continue;
650
if (search_match(r.label, s)) {
651
return true;
652
}
653
}
654
return false;
655
}
656
657
function render_select_list_button(): React.JSX.Element | undefined {
658
const number_selected = selected_entries.length;
659
let label: string;
660
let disabled: boolean;
661
if (number_selected == 0 && results.length == 0) {
662
label = "No matching users";
663
if (num_matching_already > 0) {
664
label += ` (${num_matching_already} matching ${plural(
665
num_matching_already,
666
"user",
667
)} already added)`;
668
}
669
disabled = true;
670
} else {
671
if (number_selected == 0) {
672
label = "Add selected user";
673
disabled = true;
674
} else if (number_selected == 1) {
675
label = "Add selected user";
676
disabled = false;
677
} else {
678
label = `Add ${number_selected} selected users`;
679
disabled = false;
680
}
681
}
682
if (email_body_error || limitExceeded) {
683
disabled = true;
684
}
685
return (
686
<div style={{ display: "flex" }}>
687
<Button onClick={reset}>Cancel</Button>
688
<Gap />
689
<Button disabled={disabled} onClick={add_selected} type="primary">
690
<Icon name="user-plus" /> {label}
691
</Button>
692
</div>
693
);
694
}
695
696
function render_invite_result(): React.JSX.Element | undefined {
697
if (state != "invited") {
698
return;
699
}
700
return (
701
<Alert
702
style={{ margin: "5px 0" }}
703
showIcon
704
closable
705
onClose={reset}
706
type="success"
707
message={invite_result}
708
/>
709
);
710
}
711
712
if (student.disableCollaborators || accountCustomize?.disableCollaborators) {
713
return <div></div>;
714
}
715
716
if (collaboratorManagementRestricted) {
717
return (
718
<Alert
719
type="info"
720
showIcon={false}
721
message={
722
<FormattedMessage
723
id="project.collaborators.add.owner_only_setting"
724
defaultMessage="Only project owners can add collaborators when owner-only management is enabled."
725
/>
726
}
727
/>
728
);
729
}
730
731
return (
732
<div
733
style={isFlyout ? { paddingLeft: "5px", paddingRight: "5px" } : undefined}
734
>
735
{limitExceeded && (
736
<RequireLicense
737
project_id={project_id}
738
message={`A license is required to have more than ${unlicensedLimit} collaborators on this project.`}
739
/>
740
)}
741
{err && <ErrorDisplay error={err} onClose={() => set_err("")} />}
742
{state == "searching" && <Loading />}
743
{render_search()}
744
{render_select_list()}
745
{render_send_email()}
746
{render_invite_result()}
747
<ProjectInviteTokens project_id={project?.get("project_id")} />
748
<Sandbox project={project} />
749
</div>
750
);
751
};
752
753