Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/collaborators/add-collaborators.tsx
Views: 687
/*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";1112import { labels } from "@cocalc/frontend/i18n";13import {14React,15redux,16useActions,17useIsMountedRef,18useMemo,19useRef,20useTypedRedux,21useState,22} from "../app-framework";23import { Well } from "../antd-bootstrap";24import { A, Icon, Loading, ErrorDisplay, Gap } from "../components";25import { webapp_client } from "../webapp-client";26import { SITE_NAME } from "@cocalc/util/theme";27import {28contains_url,29plural,30cmp,31trunc_middle,32is_valid_email_address,33is_valid_uuid_string,34search_match,35search_split,36} from "@cocalc/util/misc";37import { Project } from "../projects/store";38import { Avatar } from "../account/avatar/avatar";39import { ProjectInviteTokens } from "./project-invite-tokens";40import { alert_message } from "../alerts";41import { useStudentProjectFunctionality } from "@cocalc/frontend/course";42import Sandbox from "./sandbox";43import track from "@cocalc/frontend/user-tracking";44import RequireLicense from "@cocalc/frontend/site-licenses/require-license";4546interface RegisteredUser {47sort?: string;48account_id: string;49first_name?: string;50last_name?: string;51last_active?: number;52created?: number;53email_address?: string;54email_address_verified?: boolean;55label?: string;56tag?: string;57name?: string;58}5960interface NonregisteredUser {61sort?: string;62email_address: string;63account_id?: undefined;64first_name?: undefined;65last_name?: undefined;66last_active?: undefined;67created?: undefined;68email_address_verified?: undefined;69label?: string;70tag?: string;71name?: string;72}7374type User = RegisteredUser | NonregisteredUser;7576interface Props {77project_id: string;78autoFocus?: boolean;79where: string; // used for tracking only right now, so we know from where people add collaborators.80mode?: "project" | "flyout";81}8283type State = "input" | "searching" | "searched" | "invited" | "invited_errors";8485export const AddCollaborators: React.FC<Props> = ({86autoFocus,87project_id,88where,89mode = "project",90}) => {91const intl = useIntl();92const unlicensedLimit = useTypedRedux(93"customize",94"unlicensed_project_collaborator_limit",95);96const isFlyout = mode === "flyout";97const student = useStudentProjectFunctionality(project_id);98const user_map = useTypedRedux("users", "user_map");99const project_map = useTypedRedux("projects", "project_map");100const project: Project | undefined = useMemo(101() => project_map?.get(project_id),102[project_id, project_map],103);104105// search that user has typed in so far106const [search, set_search] = useState<string>("");107const search_ref = useRef<string>("");108109// list of results for doing the search -- turned into a selector110const [results, set_results] = useState<User[]>([]);111const [num_matching_already, set_num_matching_already] = useState<number>(0);112113// list of actually selected entries in the selector list114const [selected_entries, set_selected_entries] = useState<string[]>([]);115const select_ref = useRef<any>(null);116117// currently carrying out a search118const [state, set_state] = useState<State>("input");119const [focused, set_focused] = useState<boolean>(false);120// display an error in case something went wrong doing a search121const [err, set_err] = useState<string>("");122// if set, adding user via email to this address123const [email_to, set_email_to] = useState<string>("");124// with this body.125const [email_body, set_email_body] = useState<string>("");126const [email_body_error, set_email_body_error] = useState<string>("");127const [email_body_editing, set_email_body_editing] = useState<boolean>(false);128const [invite_result, set_invite_result] = useState<string>("");129130const hasLicense = (project?.get("site_license")?.size ?? 0) > 0;131const limitExceeded =132!!unlicensedLimit &&133!hasLicense &&134(project?.get("users").size ?? 1) + selected_entries.length >135unlicensedLimit;136137const isMountedRef = useIsMountedRef();138139const project_actions = useActions("projects");140141const allow_urls = useMemo(142() => redux.getStore("projects").allow_urls_in_emails(project_id),143[project_id],144);145146function reset(): void {147set_search("");148set_results([]);149set_num_matching_already(0);150set_selected_entries([]);151set_state("input");152set_err("");153set_email_to("");154set_email_body("");155set_email_body_error("");156set_email_body_editing(false);157}158159async function do_search(search: string): Promise<void> {160if (state == "searching" || project == null) {161// already searching162return;163}164set_search(search);165if (search.length === 0) {166set_err("");167set_results([]);168return;169}170set_state("searching");171let err = "";172let search_results: User[] = [];173let num_already_matching = 0;174const already = new Set<string>([]);175try {176for (let query of search.split(",")) {177query = query.trim().toLowerCase();178const query_results = await webapp_client.users_client.user_search({179query,180limit: 30,181});182if (!isMountedRef.current) return; // no longer mounted183if (query_results.length == 0 && is_valid_email_address(query)) {184const email_address = query;185if (!already.has(email_address)) {186search_results.push({ email_address, sort: "0" + email_address });187already.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.192for (const r of query_results) {193if (r.account_id == null) continue; // won't happen194if (project.getIn(["users", r.account_id]) == null) {195if (!already.has(r.account_id)) {196search_results.push(r);197already.add(r.account_id);198} else {199// if we got additional information about email200// address and already have this user, remember that201// extra info.202if (r.email_address != null) {203for (const x of search_results) {204if (x.account_id == r.account_id) {205x.email_address = r.email_address;206}207}208}209}210} else {211num_already_matching += 1;212}213}214}215}216} catch (e) {217err = e.toString();218}219set_num_matching_already(num_already_matching);220write_email_invite();221// sort search_results with collaborators first by last_active,222// then non-collabs by last_active.223search_results.sort((x, y) => {224let c = cmp(225x.account_id && user_map.has(x.account_id) ? 0 : 1,226y.account_id && user_map.has(y.account_id) ? 0 : 1,227);228if (c) return c;229c = -cmp(x.last_active?.valueOf() ?? 0, y.last_active?.valueOf() ?? 0);230if (c) return c;231return cmp(x.last_name?.toLowerCase(), y.last_name?.toLowerCase());232});233234set_state("searched");235set_err(err);236set_results(search_results);237set_email_to("");238select_ref.current?.focus();239}240241function render_options(users: User[]): JSX.Element[] {242const options: JSX.Element[] = [];243for (const r of users) {244if (r.label == null || r.tag == null || r.name == null) {245let name = r.account_id246? (r.first_name ?? "") + " " + (r.last_name ?? "")247: r.email_address;248if (!name?.trim()) {249name = "Anonymous User";250}251const tag = trunc_middle(name, 20);252253// Extra display is a bit ugly, but we need to do it for now. Need to make254// react rendered version of this that is much nicer (with pictures!) someday.255const extra: string[] = [];256if (r.account_id != null && user_map.get(r.account_id)) {257extra.push("Collaborator");258}259if (r.last_active) {260extra.push(`Active ${new Date(r.last_active).toLocaleDateString()}`);261}262if (r.created) {263extra.push(`Created ${new Date(r.created).toLocaleDateString()}`);264}265if (r.account_id == null) {266extra.push(`No account`);267} else {268if (r.email_address) {269if (r.email_address_verified?.[r.email_address]) {270extra.push(`${r.email_address} -- verified`);271} else {272extra.push(`${r.email_address} -- not verified`);273}274}275}276if (extra.length > 0) {277name += ` (${extra.join(", ")})`;278}279r.label = name.toLowerCase();280r.tag = tag;281r.name = name;282}283const x = r.account_id ?? r.email_address;284options.push(285<Select.Option key={x} value={x} label={r.label} tag={r.tag}>286<Avatar287size={36}288no_tooltip={true}289account_id={r.account_id}290first_name={r.account_id ? r.first_name : "@"}291last_name={r.last_name}292/>{" "}293<span title={r.name}>{r.name}</span>294</Select.Option>,295);296}297return options;298}299300async function invite_collaborator(account_id: string): Promise<void> {301if (project == null) return;302const { subject, replyto, replyto_name } = sender_info();303304track("invite-collaborator", {305where,306project_id,307account_id,308subject,309email_body,310});311await project_actions.invite_collaborator(312project_id,313account_id,314email_body,315subject,316false,317replyto,318replyto_name,319);320}321322function add_selected(): void {323let errors = "";324for (const x of selected_entries) {325try {326if (is_valid_email_address(x)) {327invite_noncloud_collaborator(x);328} else if (is_valid_uuid_string(x)) {329invite_collaborator(x);330} else {331// skip332throw Error(333`BUG - invalid selection ${x} must be an email address or account_id.`,334);335}336} catch (err) {337errors += `\nError - ${err}`;338}339}340reset();341if (errors) {342set_invite_result(errors);343set_state("invited_errors");344} else {345set_invite_result(`Successfully added ${selected_entries.length} users!`);346set_state("invited");347}348}349350function write_email_invite(): void {351if (project == null) return;352353const name = redux.getStore("account").get_fullname();354const title = project.get("title");355const target = `project '${title}'`;356const SiteName = redux.getStore("customize").get("site_name") ?? SITE_NAME;357const body = `Hello!\n\nPlease collaborate with me using ${SiteName} on ${target}.\n\nBest wishes,\n\n${name}`;358set_email_to(search);359set_email_body(body);360}361362function sender_info(): {363subject: string;364replyto?: string;365replyto_name: string;366} {367const replyto = redux.getStore("account").get_email_address();368const replyto_name = redux.getStore("account").get_fullname();369const SiteName = redux.getStore("customize").get("site_name") ?? SITE_NAME;370let subject;371if (replyto_name != null) {372subject = `${replyto_name} added you to project ${project?.get("title")}`;373} else {374subject = `${SiteName} Invitation to project ${project?.get("title")}`;375}376return { subject, replyto, replyto_name };377}378379async function invite_noncloud_collaborator(email_address): Promise<void> {380if (project == null) return;381const { subject, replyto, replyto_name } = sender_info();382await project_actions.invite_collaborators_by_email(383project_id,384email_address,385email_body,386subject,387false,388replyto,389replyto_name,390);391if (!allow_urls) {392// Show a message that they might have to email that person393// and tell them to make a cocalc account, and when they do394// then they will get added as collaborator to this project....395alert_message({396type: "warning",397message: `For security reasons you should contact ${email_address} directly and ask them to join Cocalc to get access to this project.`,398});399}400}401402function send_email_invite(): void {403if (project == null) return;404const { subject, replyto, replyto_name } = sender_info();405project_actions.invite_collaborators_by_email(406project_id,407email_to,408email_body,409subject,410false,411replyto,412replyto_name,413);414set_email_to("");415set_email_body("");416reset();417}418419function check_email_body(value: string): void {420if (!allow_urls && contains_url(value)) {421set_email_body_error("Sending URLs is not allowed. (anti-spam measure)");422} else {423set_email_body_error("");424}425}426427function render_email_body_error(): JSX.Element | undefined {428if (!email_body_error) {429return;430}431return <ErrorDisplay error={email_body_error} />;432}433434function render_email_textarea(): JSX.Element {435return (436<Input.TextArea437defaultValue={email_body}438autoSize={true}439maxLength={1000}440showCount={true}441onBlur={() => {442set_email_body_editing(false);443}}444onFocus={() => set_email_body_editing(true)}445onChange={(e) => {446const value: string = (e.target as any).value;447set_email_body(value);448check_email_body(value);449}}450/>451);452}453454function render_send_email(): JSX.Element | undefined {455if (!email_to) {456return;457}458459return (460<div>461<hr />462<Well>463Enter one or more email addresses separated by commas:464<Input465placeholder="Email addresses separated by commas..."466value={email_to}467onChange={(e) => set_email_to((e.target as any).value)}468autoFocus469/>470<div471style={{472padding: "20px 0",473backgroundColor: "white",474marginBottom: "15px",475}}476>477{render_email_body_error()}478{render_email_textarea()}479</div>480<div style={{ display: "flex" }}>481<Button482onClick={() => {483set_email_to("");484set_email_body("");485set_email_body_editing(false);486}}487>488{intl.formatMessage(labels.cancel)}489</Button>490<Gap />491<Button492type="primary"493onClick={send_email_invite}494disabled={!!email_body_editing}495>496Send Invitation497</Button>498</div>499</Well>500</div>501);502}503504function render_search(): JSX.Element | undefined {505return (506<div style={{ marginBottom: "15px" }}>507{state == "searched" ? (508render_select_list_button()509) : (510<>511Who would you like to collaborate with?{" "}512<b>513NOTE: If you are teaching,{" "}514<A href="https://doc.cocalc.com/teaching-create-course.html#add-students-to-the-course">515add your students to your course516</A>517, NOT HERE.518</b>519</>520)}521</div>522);523}524525function render_select_list(): JSX.Element | undefined {526if (project == null) return;527528const users: User[] = [];529const existing: User[] = [];530for (const r of results) {531if (project.get("users").get(r.account_id) != null) {532existing.push(r);533} else {534users.push(r);535}536}537538function render_search_help(): JSX.Element | undefined {539if (focused && results.length === 0) {540return <Alert type="info" message={"Press enter to search..."} />;541}542}543544return (545<div style={{ marginBottom: "10px" }}>546<Select547ref={select_ref}548mode="multiple"549allowClear550autoFocus={autoFocus}551open={autoFocus ? true : undefined}552filterOption={(s, opt) => {553if (s.indexOf(",") != -1) return true;554return search_match(555(opt as any).label,556search_split(s.toLowerCase()),557);558}}559style={{ width: "100%", marginBottom: "10px" }}560placeholder={561results.length > 0 && search.trim() ? (562`Select user from ${results.length} ${plural(563results.length,564"user",565)} matching '${search}'.`566) : (567<span>568<Icon name="search" /> Name or email address...569</span>570)571}572onChange={(value) => {573set_selected_entries(value as string[]);574}}575value={selected_entries}576optionLabelProp="tag"577onInputKeyDown={(e) => {578if (e.keyCode == 27) {579reset();580e.preventDefault();581return;582}583if (584e.keyCode == 13 &&585state != ("searching" as State) &&586!hasMatches()587) {588do_search(search_ref.current);589e.preventDefault();590return;591}592}}593onSearch={(value) => (search_ref.current = value)}594notFoundContent={null}595onFocus={() => set_focused(true)}596onBlur={() => set_focused(false)}597>598{render_options(users)}599</Select>600{render_search_help()}601{selected_entries.length > 0 && (602<div603style={{604border: "1px solid lightgrey",605padding: "10px",606borderRadius: "5px",607backgroundColor: "white",608margin: "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}619620function hasMatches(): boolean {621const s = search_split(search_ref.current.toLowerCase());622if (s.length == 0) return true;623for (const r of results) {624if (r.label == null) continue;625if (search_match(r.label, s)) {626return true;627}628}629return false;630}631632function render_select_list_button(): JSX.Element | undefined {633const number_selected = selected_entries.length;634let label: string;635let disabled: boolean;636if (number_selected == 0 && results.length == 0) {637label = "No matching users";638if (num_matching_already > 0) {639label += ` (${num_matching_already} matching ${plural(640num_matching_already,641"user",642)} already added)`;643}644disabled = true;645} else {646if (number_selected == 0) {647label = "Add selected user";648disabled = true;649} else if (number_selected == 1) {650label = "Add selected user";651disabled = false;652} else {653label = `Add ${number_selected} selected users`;654disabled = false;655}656}657if (email_body_error || limitExceeded) {658disabled = true;659}660return (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}670671function render_invite_result(): JSX.Element | undefined {672if (state != "invited") {673return;674}675return (676<Alert677style={{ margin: "5px 0" }}678showIcon679closable680onClose={reset}681type="success"682message={invite_result}683/>684);685}686687if (student.disableCollaborators) {688return <div></div>;689}690691return (692<div693style={isFlyout ? { paddingLeft: "5px", paddingRight: "5px" } : undefined}694>695{limitExceeded && (696<RequireLicense697project_id={project_id}698message={`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};712713714