Path: blob/master/src/packages/frontend/collaborators/current-collabs.tsx
5803 views
/*1* This file is part of CoCalc: Copyright © 2025-2026 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// cSpell:ignore replyto collabs noncloud67import { Alert, Button, Card, Dropdown, Popconfirm } from "antd";8import React, { useState } from "react";9import { FormattedMessage, useIntl } from "react-intl";1011import {12CSS,13redux,14useStore,15useTypedRedux,16} from "@cocalc/frontend/app-framework";17import {18Icon,19Paragraph,20SettingBox,21Text,22Tip,23Title,24} from "@cocalc/frontend/components";25import { useStudentProjectFunctionality } from "@cocalc/frontend/course";26import { labels } from "@cocalc/frontend/i18n";27import { CancelText } from "@cocalc/frontend/i18n/components";28import { Project } from "@cocalc/frontend/project/settings/types";29import { COLORS } from "@cocalc/util/theme";30import { CollaboratorsSetting } from "./collaborators-setting";31import { FIX_BORDER } from "../project/page/common";32import { User } from "../users";3334const LIST_STYLE: CSS = {35maxHeight: "20em",36overflowY: "auto",37overflowX: "hidden",38marginBottom: "0",39display: "flex",40flexDirection: "column",41gap: "12px",42} as const;4344interface Props {45project: Project;46user_map?: any;47mode?: "project" | "flyout";48}4950export const CurrentCollaboratorsPanel: React.FC<Props> = (props: Props) => {51const { project, user_map, mode = "project" } = props;52const isFlyout = mode === "flyout";53const intl = useIntl();54const current_account_id = useTypedRedux("account", "account_id");55const projectStore = useStore("projects");56const student = useStudentProjectFunctionality(project.get("project_id"));57const [error, setError] = useState<string>("");5859const project_id = project.get("project_id");60const users = project.get("users");61const current_user_group = users?.getIn([current_account_id, "group"]);62const is_requester_owner = current_user_group === "owner";63const strict_collaborator_management =64useTypedRedux("customize", "strict_collaborator_management") ?? false;65const manage_users_owner_only =66strict_collaborator_management ||67(project.get("manage_users_owner_only") ?? false);6869// Count owners to check if this is the last owner70const owner_count = users71? users.valueSeq().count((u: any) => u?.get?.("group") === "owner")72: 0;7374function remove_collaborator(account_id: string) {75redux.getActions("projects").remove_collaborator(project_id, account_id);76if (account_id === current_account_id) {77(redux.getActions("page") as any).close_project_tab(project_id);78// TODO: better types79}80}8182async function change_user_type(83account_id: string,84new_group: "owner" | "collaborator",85) {86try {87setError("");88await redux89.getActions("projects")90.change_user_type(project_id, account_id, new_group);91} catch (err) {92setError(`Error: ${err}`);93}94}9596function user_remove_confirm_text(account_id: string) {97const style: CSS = { maxWidth: "300px" };98if (account_id === current_account_id) {99return (100<div style={style}>101<FormattedMessage102id="collaborators.current-collabs.remove_self"103defaultMessage={`Are you sure you want to remove <b>yourself</b> from this project?104You will no longer have access to this project and cannot add yourself back.`}105/>106</div>107);108} else {109return (110<div style={style}>111<FormattedMessage112id="collaborators.current-collabs.remove_other"113defaultMessage={`Are you sure you want to remove {user} from this project?114They will no longer have access to this project.`}115values={{116user: <User account_id={account_id} user_map={user_map} />,117}}118/>119</div>120);121}122}123124function renderRoleSetting(account_id: string, group?: string) {125const isOwner = group === "owner";126const isLastOwner = isOwner && owner_count === 1;127const can_promote = !isOwner && is_requester_owner;128const can_demote = isOwner && is_requester_owner && !isLastOwner;129130const buttonSize = isFlyout ? "small" : "middle";131const roleLabel = intl.formatMessage(132isOwner ? labels.owner : labels.collaborator,133);134135// If not allowed to change owner/collab status, simply report the role of the given user136if (student.disableCollaborators || !is_requester_owner) {137const label = (138<Text type="secondary" style={{ padding: "0 6px" }}>139{`(${roleLabel})`}140</Text>141);142return isFlyout ? (143<div style={{ display: "flex", justifyContent: "flex-end" }}>144{label}145</div>146) : (147label148);149}150151const menuItems = [152{153key: "promote",154label: (155<Tip156title={intl.formatMessage({157id: "project.collaborators.promote.tooltip",158defaultMessage:159"Promote this collaborator to owner, giving them full project control",160})}161>162<FormattedMessage163id="project.collaborators.promote.label"164defaultMessage="Promote to Owner"165/>166</Tip>167),168disabled: !can_promote,169onClick: () => change_user_type(account_id, "owner"),170},171{172key: "demote",173label: (174<Tip175title={intl.formatMessage(176{177id: "project.collaborators.demote.tooltip",178defaultMessage:179"{isLastOwner, select, true {Cannot demote the last owner} other {Demote this owner to collaborator}}",180},181{ isLastOwner },182)}183>184<FormattedMessage185id="project.collaborators.demote.label"186defaultMessage="Demote to Collaborator"187/>188</Tip>189),190disabled: !can_demote,191onClick: () => change_user_type(account_id, "collaborator"),192},193];194195const dropdown = (196<Dropdown menu={{ items: menuItems }} placement="bottomRight">197<Button198type="link"199size={buttonSize}200style={{ color: COLORS.ANTD_LINK_BLUE, padding: "0 6px" }}201>202{roleLabel} <Icon name="chevron-down" />203</Button>204</Dropdown>205);206207if (isFlyout) {208return (209<div style={{ display: "flex", justifyContent: "flex-end" }}>210{dropdown}211</div>212);213} else {214return dropdown;215}216}217218function renderRemoveButton(account_id: string, group?: string) {219if (student.disableCollaborators) return;220const text = user_remove_confirm_text(account_id);221const isOwner = group === "owner";222const isSelf = account_id === current_account_id;223const disabledBySetting =224manage_users_owner_only && !is_requester_owner && !isSelf;225const disabled = isOwner || disabledBySetting;226227const disabledReason = (() => {228if (isOwner) {229return intl.formatMessage({230id: "collaborators.current-collabs.remove.owner_disabled",231defaultMessage: "Owners must be demoted before they can be removed.",232});233}234if (disabledBySetting) {235return intl.formatMessage({236id: "collaborators.current-collabs.remove.setting_disabled",237defaultMessage:238"Only owners can remove collaborators when this setting is enabled.",239});240}241return undefined;242})();243244const buttonType = isFlyout ? "link" : "default";245const buttonSize = isFlyout ? "small" : "middle";246247return (248<Tip title={disabledReason}>249<Popconfirm250title={text}251onConfirm={() => remove_collaborator(account_id)}252okText={intl.formatMessage(253{254id: "collaborators.current-collabs.remove.ok_button",255defaultMessage: "Yes, remove {role}",256},257{ role: intl.formatMessage(labels.collaborator) },258)}259cancelText={<CancelText />}260disabled={disabled}261>262<Button263disabled={disabled}264type={buttonType}265size={buttonSize}266style={{267marginBottom: "0",268...(isFlyout269? { color: COLORS.ANTD_RED_WARN, padding: "0 4px" }270: {}),271}}272>273<Icon name="user-times" /> {intl.formatMessage(labels.remove)}274</Button>275</Popconfirm>276</Tip>277);278}279280function render_user(user: any, is_last?: boolean) {281const baseStyle: CSS = {282width: "100%",283flex: "1 1 auto",284...(!is_last ? { marginBottom: "20px" } : {}),285};286287if (isFlyout) {288return (289<div key={user.account_id} style={baseStyle}>290<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>291<User292account_id={user.account_id}293user_map={user_map}294last_active={user.last_active}295show_avatar={true}296/>297</div>298<div299style={{300display: "flex",301justifyContent: "flex-end",302gap: "4px",303marginTop: "4px",304}}305>306{renderRoleSetting(user.account_id, user.group)}307{renderRemoveButton(user.account_id, user.group)}308</div>309</div>310);311}312313return (314<div315key={user.account_id}316style={{317...baseStyle,318display: "flex",319alignItems: "center",320justifyContent: "space-between",321}}322>323<div style={{ flex: "1 1 auto" }}>324<User325account_id={user.account_id}326user_map={user_map}327last_active={user.last_active}328show_avatar={true}329/>330</div>331<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>332{renderRoleSetting(user.account_id, user.group)}333{renderRemoveButton(user.account_id, user.group)}334</div>335</div>336);337}338339function render_users() {340const u = project.get("users");341if (u === undefined) {342return;343}344const users = u345.map((v, k) => ({ account_id: k, group: v.get("group") }))346.toList()347.toJS();348return projectStore349.sort_by_activity(users, project.get("project_id"))350.map((u: any, i: number) => render_user(u, i === users.length - 1));351}352353function render_setting() {354return (355<div style={{ marginTop: "12px" }}>356<CollaboratorsSetting project={project} withSettingBox={false} />357</div>358);359}360361function render_collaborators_list() {362const header = (363<>364{error && (365<Alert366type="error"367message={error}368closable369onClose={() => setError("")}370style={{ marginBottom: "10px" }}371/>372)}373</>374);375376const list = <div style={LIST_STYLE}>{render_users()}</div>;377378if (isFlyout) {379return (380<div style={{ borderBottom: FIX_BORDER }}>381{header}382{list}383</div>384);385} else {386return (387<Card style={{ backgroundColor: COLORS.GRAY_LLL }}>388{header}389{list}390</Card>391);392}393}394395const introText = intl.formatMessage(396{397id: "collaborators.current-collabs.intro2",398defaultMessage: `Everybody listed below can collaboratively work with you on any Jupyter Notebook, Linux Terminal or file in this project.399{manageUsersOnly, select,400true { Only project owners can add or remove collaborators.}401other { Collaborators can also add or remove other collaborators.}}`,402},403{ manageUsersOnly: manage_users_owner_only ? "true" : "false" },404);405406const nonOwnerNote = !is_requester_owner407? intl.formatMessage({408id: "project.collaborators.non_owner_note",409defaultMessage: "Only project owners can manage user roles.",410})411: null;412413const titleText = intl.formatMessage({414id: "collaborators.current-collabs.title",415defaultMessage: "Current Collaborators",416description: "Title of a table listing users collaborating on that project",417});418419switch (mode) {420case "project":421return (422<SettingBox title="Current Collaborators" icon="user">423<div>424{introText}425{nonOwnerNote && (426<>427{" "}428<Text type="secondary">{nonOwnerNote}</Text>429</>430)}431</div>432<hr />433{render_collaborators_list()}434<hr />435{render_setting()}436</SettingBox>437);438case "flyout":439return (440<div style={{ paddingLeft: "5px" }}>441<Title level={3}>442<Icon name="user" /> {titleText}443</Title>444<Paragraph445type="secondary"446ellipsis={{ rows: 1, expandable: true, symbol: "more" }}447>448{introText}449{nonOwnerNote && <> {nonOwnerNote}</>}450</Paragraph>451{render_collaborators_list()}452{render_setting()}453</div>454);455}456};457458459