Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/.claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"Bash(gh pr view:*)",
"Bash(gh:*)",
"Bash(git add:*)",
"Bash(git grep:*)",
"Bash(git branch:*)",
"Bash(git checkout:*)",
"Bash(git commit:*)",
Expand Down
14 changes: 14 additions & 0 deletions src/packages/conat/hub/api/projects.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { authFirstRequireAccount } from "./util";
import { type CreateProjectOptions } from "@cocalc/util/db-schema/projects";
import { type UserCopyOptions } from "@cocalc/util/db-schema/projects";
import { type UserGroup } from "@cocalc/util/project-ownership";

export const projects = {
createProject: authFirstRequireAccount,
Expand All @@ -9,6 +10,7 @@ export const projects = {
addCollaborator: authFirstRequireAccount,
inviteCollaborator: authFirstRequireAccount,
inviteCollaboratorWithoutAccount: authFirstRequireAccount,
changeUserType: authFirstRequireAccount,
setQuotas: authFirstRequireAccount,
start: authFirstRequireAccount,
stop: authFirstRequireAccount,
Expand Down Expand Up @@ -87,6 +89,18 @@ export interface Projects {
};
}) => Promise<void>;

changeUserType: ({
account_id,
opts,
}: {
account_id?: string;
opts: {
project_id: string;
target_account_id: string;
new_group: UserGroup;
};
}) => Promise<void>;

setQuotas: (opts: {
account_id?: string;
project_id: string;
Expand Down
14 changes: 14 additions & 0 deletions src/packages/database/postgres-user-queries.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ required = defaults.required
{queryIsCmp, userGetQueryFilter} = require("./user-query/user-get-query")

{updateRetentionData} = require('./postgres/retention')
{sanitizeManageUsersOwnerOnly} = require('./postgres/project/manage-users-owner-only')

{ checkProjectName } = require("@cocalc/util/db-schema/name-rules");
{callback2} = require('@cocalc/util/async-utils')
Expand Down Expand Up @@ -839,6 +840,13 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext
users[id] = x
return users

_user_set_query_project_manage_users_owner_only: (obj, account_id) =>
# This hook is called from the schema functional substitution to validate
# the manage_users_owner_only flag. This must be synchronous - async validation
# (permission checks) is done in the check_hook instead.
# Just do basic type validation and sanitization here
return sanitizeManageUsersOwnerOnly(obj.manage_users_owner_only)

project_action: (opts) =>
opts = defaults opts,
project_id : required
Expand Down Expand Up @@ -933,6 +941,12 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext
cb("Only the owner of the project can currently change the project name.")
return

if new_val?.manage_users_owner_only? and new_val.manage_users_owner_only != old_val?.manage_users_owner_only
# Permission is enforced in the set-field interceptor; nothing to do here.
# Leaving this block for clarity and to avoid silent bypass if future callers
# modify manage_users_owner_only via another path.
dbg("manage_users_owner_only change requested")

if new_val?.action_request? and JSON.stringify(new_val.action_request.time) != JSON.stringify(old_val?.action_request?.time)
# Requesting an action, e.g., save, restart, etc.
dbg("action_request -- #{misc.to_json(new_val.action_request)}")
Expand Down
85 changes: 85 additions & 0 deletions src/packages/database/postgres/manage-users-owner-only.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
* License: MS-RSL – see LICENSE.md for details
*/

import getPool, { initEphemeralDatabase } from "@cocalc/database/pool";
import { db } from "@cocalc/database";
import { uuid } from "@cocalc/util/misc";

let pool: ReturnType<typeof getPool> | undefined;
let dbAvailable = true;

beforeAll(async () => {
try {
await initEphemeralDatabase();
pool = getPool();
} catch (err) {
// Skip locally if postgres is unavailable.
dbAvailable = false;
console.warn("Skipping manage_users_owner_only tests: " + err);
}
}, 15000);

afterAll(async () => {
if (pool) {
await pool.end();
}
});

async function insertProject(opts: {
projectId: string;
ownerId: string;
collaboratorId: string;
}) {
const { projectId, ownerId, collaboratorId } = opts;
if (!pool) {
throw Error("Pool not initialized");
}
await pool.query("INSERT INTO projects(project_id, users) VALUES ($1, $2)", [
projectId,
{
[ownerId]: { group: "owner" },
[collaboratorId]: { group: "collaborator" },
},
]);
}

describe("manage_users_owner_only set hook", () => {
const projectId = uuid();
const ownerId = uuid();
const collaboratorId = uuid();

beforeAll(async () => {
if (!dbAvailable) return;
await insertProject({ projectId, ownerId, collaboratorId });
});

test("owner can set manage_users_owner_only", async () => {
if (!dbAvailable) return;
const value = await db()._user_set_query_project_manage_users_owner_only(
{ project_id: projectId, manage_users_owner_only: true },
ownerId,
);
expect(value).toBe(true);
});

test("collaborator call returns sanitized value (permission enforced elsewhere)", async () => {
if (!dbAvailable) return;
const value = await db()._user_set_query_project_manage_users_owner_only(
{ project_id: projectId, manage_users_owner_only: true },
collaboratorId,
);
expect(value).toBe(true);
});

test("invalid type is rejected", async () => {
if (!dbAvailable) return;
expect(() =>
db()._user_set_query_project_manage_users_owner_only(
{ project_id: projectId, manage_users_owner_only: "yes" as any },
ownerId,
),
).toThrow("manage_users_owner_only must be a boolean");
});
});
31 changes: 31 additions & 0 deletions src/packages/database/postgres/project/manage-users-owner-only.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
* License: MS-RSL – see LICENSE.md for details
*/

export function sanitizeManageUsersOwnerOnly(
value: unknown,
): boolean | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "object") {
// Allow nested shape { manage_users_owner_only: boolean } from callers that wrap input.
const candidate = (value as any).manage_users_owner_only;
if (candidate !== undefined) {
return sanitizeManageUsersOwnerOnly(candidate);
}
// Allow Immutable.js style get("manage_users_owner_only")
const getter = (value as any).get;
if (typeof getter === "function") {
const maybe = getter.call(value, "manage_users_owner_only");
if (maybe !== undefined) {
return sanitizeManageUsersOwnerOnly(maybe);
}
}
}
if (typeof value !== "boolean") {
throw Error("manage_users_owner_only must be a boolean");
}
return value;
}
15 changes: 11 additions & 4 deletions src/packages/database/postgres/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ export interface QueryOptions<T = UntypedQueryResult> {
cb?: CB<QueryRows<T>>;
}

export interface AsyncQueryOptions<T = UntypedQueryResult>
extends Omit<QueryOptions<T>, "cb"> {}
export interface AsyncQueryOptions<T = UntypedQueryResult> extends Omit<
QueryOptions<T>,
"cb"
> {}

export interface UserQueryOptions {
client_id?: string; // if given, uses to control number of queries at once by one client.
Expand Down Expand Up @@ -406,8 +408,13 @@ export interface PostgreSQL extends EventEmitter {
webapp_error(opts: object);

set_project_settings(opts: { project_id: string; settings: object; cb?: CB });

uncaught_exception: (err:any) => void;

_user_set_query_project_manage_users_owner_only(
obj: any,
account_id: string,
): string | undefined;

uncaught_exception: (err: any) => void;
}

// This is an extension of BaseProject in projects/control/base.ts
Expand Down
2 changes: 1 addition & 1 deletion src/packages/frontend/antd-bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function parse_bsStyle(props: {
let type =
props.bsStyle == null
? "default"
: BS_STYLE_TO_TYPE[props.bsStyle] ?? "default";
: (BS_STYLE_TO_TYPE[props.bsStyle] ?? "default");

let style: React.CSSProperties | undefined = undefined;
// antd has no analogue of "success" & "warning", it's not clear to me what
Expand Down
12 changes: 11 additions & 1 deletion src/packages/frontend/client/project-collaborators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* License: MS-RSL – see LICENSE.md for details
*/

// cSpell:ignore replyto collabs noncloud

import type { ConatClient } from "@cocalc/frontend/conat/client";
import type { AddCollaborator } from "@cocalc/conat/hub/api/projects";

Expand Down Expand Up @@ -57,10 +59,18 @@ export class ProjectCollaborators {
public async add_collaborator(
opts: AddCollaborator,
): Promise<{ project_id?: string | string[] }> {
// project_id is a single string or possibly an array of project_id's
// project_id is a single string or possibly an array of project_id's
// in case of a token.
return await this.conat.hub.projects.addCollaborator({
opts,
});
}

public async change_user_type(opts: {
project_id: string;
target_account_id: string;
new_group: "owner" | "collaborator";
}): Promise<void> {
return await this.conat.hub.projects.changeUserType({ opts });
}
}
55 changes: 47 additions & 8 deletions src/packages/frontend/collaborators/add-collaborators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
Add collaborators to a project
*/

// cSpell:ignore replyto noncloud collabs

import { Alert, Button, Input, Select } from "antd";
import { useIntl } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";

import { labels } from "@cocalc/frontend/i18n";
import {
React,
Expand All @@ -17,12 +20,19 @@ import {
useIsMountedRef,
useMemo,
useRef,
useRedux,
useTypedRedux,
useState,
} from "../app-framework";
import { Well } from "../antd-bootstrap";
import { A, Icon, Loading, ErrorDisplay, Gap } from "../components";
import { webapp_client } from "../webapp-client";
import { Well } from "@cocalc/frontend/antd-bootstrap";
import {
A,
Icon,
Loading,
ErrorDisplay,
Gap,
} from "@cocalc/frontend/components";
import { webapp_client } from "@cocalc/frontend/webapp-client";
import { SITE_NAME } from "@cocalc/util/theme";
import {
contains_url,
Expand All @@ -34,10 +44,10 @@ import {
search_match,
search_split,
} from "@cocalc/util/misc";
import { Project } from "../projects/store";
import { Avatar } from "../account/avatar/avatar";
import { Project } from "@cocalc/frontend/projects/store";
import { Avatar } from "@cocalc/frontend/account/avatar/avatar";
import { ProjectInviteTokens } from "./project-invite-tokens";
import { alert_message } from "../alerts";
import { alert_message } from "@cocalc/frontend/alerts";
import { useStudentProjectFunctionality } from "@cocalc/frontend/course";
import Sandbox from "./sandbox";
import track from "@cocalc/frontend/user-tracking";
Expand Down Expand Up @@ -104,6 +114,20 @@ export const AddCollaborators: React.FC<Props> = ({
() => project_map?.get(project_id),
[project_id, project_map],
);
const get_account_id = useRedux("account", "get_account_id");
const current_account_id = get_account_id();
const strict_collaborator_management =
useTypedRedux("customize", "strict_collaborator_management") ?? false;
const manage_users_owner_only =
strict_collaborator_management ||
(project?.get("manage_users_owner_only") ?? false);
const current_user_group = project?.getIn([
"users",
current_account_id,
"group",
]);
const isOwner = current_user_group === "owner";
const collaboratorManagementRestricted = manage_users_owner_only && !isOwner;

// search that user has typed in so far
const [search, set_search] = useState<string>("");
Expand Down Expand Up @@ -257,7 +281,7 @@ export const AddCollaborators: React.FC<Props> = ({
// react rendered version of this that is much nicer (with pictures!) someday.
const extra: string[] = [];
if (r.account_id != null && user_map.get(r.account_id)) {
extra.push("Collaborator");
extra.push(intl.formatMessage(labels.collaborator));
}
if (r.last_active) {
extra.push(`Active ${new Date(r.last_active).toLocaleDateString()}`);
Expand Down Expand Up @@ -691,6 +715,21 @@ export const AddCollaborators: React.FC<Props> = ({
return <div></div>;
}

if (collaboratorManagementRestricted) {
return (
<Alert
type="info"
showIcon={false}
message={
<FormattedMessage
id="project.collaborators.add.owner_only_setting"
defaultMessage="Only project owners can add collaborators when owner-only management is enabled."
/>
}
/>
);
}

return (
<div
style={isFlyout ? { paddingLeft: "5px", paddingRight: "5px" } : undefined}
Expand Down
Loading
Loading