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