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