CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

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