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