Skip to content

Commit 2552dd7

Browse files
committed
project/collaborators: implement ownership management -- #7718
1 parent f5f0ddf commit 2552dd7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+3263
-256
lines changed

src/.claude/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"Bash(gh pr view:*)",
1414
"Bash(gh:*)",
1515
"Bash(git add:*)",
16+
"Bash(git grep:*)",
1617
"Bash(git branch:*)",
1718
"Bash(git checkout:*)",
1819
"Bash(git commit:*)",

src/packages/conat/hub/api/projects.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { authFirstRequireAccount } from "./util";
22
import { type CreateProjectOptions } from "@cocalc/util/db-schema/projects";
33
import { type UserCopyOptions } from "@cocalc/util/db-schema/projects";
4+
import { type UserGroup } from "@cocalc/util/project-ownership";
45

56
export const projects = {
67
createProject: authFirstRequireAccount,
@@ -9,6 +10,7 @@ export const projects = {
910
addCollaborator: authFirstRequireAccount,
1011
inviteCollaborator: authFirstRequireAccount,
1112
inviteCollaboratorWithoutAccount: authFirstRequireAccount,
13+
changeUserType: authFirstRequireAccount,
1214
setQuotas: authFirstRequireAccount,
1315
start: authFirstRequireAccount,
1416
stop: authFirstRequireAccount,
@@ -87,6 +89,18 @@ export interface Projects {
8789
};
8890
}) => Promise<void>;
8991

92+
changeUserType: ({
93+
account_id,
94+
opts,
95+
}: {
96+
account_id?: string;
97+
opts: {
98+
project_id: string;
99+
target_account_id: string;
100+
new_group: UserGroup;
101+
};
102+
}) => Promise<void>;
103+
90104
setQuotas: (opts: {
91105
account_id?: string;
92106
project_id: string;

src/packages/database/postgres-user-queries.coffee

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ required = defaults.required
3030
{queryIsCmp, userGetQueryFilter} = require("./user-query/user-get-query")
3131

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

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

843+
_user_set_query_project_manage_users_owner_only: (obj, account_id) =>
844+
# This hook is called from the schema functional substitution to validate
845+
# the manage_users_owner_only flag. This must be synchronous - async validation
846+
# (permission checks) is done in the check_hook instead.
847+
# Just do basic type validation and sanitization here
848+
return sanitizeManageUsersOwnerOnly(obj.manage_users_owner_only)
849+
842850
project_action: (opts) =>
843851
opts = defaults opts,
844852
project_id : required
@@ -933,6 +941,12 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext
933941
cb("Only the owner of the project can currently change the project name.")
934942
return
935943

944+
if new_val?.manage_users_owner_only? and new_val.manage_users_owner_only != old_val?.manage_users_owner_only
945+
# Permission is enforced in the set-field interceptor; nothing to do here.
946+
# Leaving this block for clarity and to avoid silent bypass if future callers
947+
# modify manage_users_owner_only via another path.
948+
dbg("manage_users_owner_only change requested")
949+
936950
if new_val?.action_request? and JSON.stringify(new_val.action_request.time) != JSON.stringify(old_val?.action_request?.time)
937951
# Requesting an action, e.g., save, restart, etc.
938952
dbg("action_request -- #{misc.to_json(new_val.action_request)}")
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import getPool, { initEphemeralDatabase } from "@cocalc/database/pool";
7+
import { db } from "@cocalc/database";
8+
import { uuid } from "@cocalc/util/misc";
9+
10+
let pool: ReturnType<typeof getPool> | undefined;
11+
let dbAvailable = true;
12+
13+
beforeAll(async () => {
14+
try {
15+
await initEphemeralDatabase();
16+
pool = getPool();
17+
} catch (err) {
18+
// Skip locally if postgres is unavailable.
19+
dbAvailable = false;
20+
console.warn("Skipping manage_users_owner_only tests: " + err);
21+
}
22+
}, 15000);
23+
24+
afterAll(async () => {
25+
if (pool) {
26+
await pool.end();
27+
}
28+
});
29+
30+
async function insertProject(opts: {
31+
projectId: string;
32+
ownerId: string;
33+
collaboratorId: string;
34+
}) {
35+
const { projectId, ownerId, collaboratorId } = opts;
36+
if (!pool) {
37+
throw Error("Pool not initialized");
38+
}
39+
await pool.query("INSERT INTO projects(project_id, users) VALUES ($1, $2)", [
40+
projectId,
41+
{
42+
[ownerId]: { group: "owner" },
43+
[collaboratorId]: { group: "collaborator" },
44+
},
45+
]);
46+
}
47+
48+
describe("manage_users_owner_only set hook", () => {
49+
const projectId = uuid();
50+
const ownerId = uuid();
51+
const collaboratorId = uuid();
52+
53+
beforeAll(async () => {
54+
if (!dbAvailable) return;
55+
await insertProject({ projectId, ownerId, collaboratorId });
56+
});
57+
58+
test("owner can set manage_users_owner_only", async () => {
59+
if (!dbAvailable) return;
60+
const value = await db()._user_set_query_project_manage_users_owner_only(
61+
{ project_id: projectId, manage_users_owner_only: true },
62+
ownerId,
63+
);
64+
expect(value).toBe(true);
65+
});
66+
67+
test("collaborator call returns sanitized value (permission enforced elsewhere)", async () => {
68+
if (!dbAvailable) return;
69+
const value = await db()._user_set_query_project_manage_users_owner_only(
70+
{ project_id: projectId, manage_users_owner_only: true },
71+
collaboratorId,
72+
);
73+
expect(value).toBe(true);
74+
});
75+
76+
test("invalid type is rejected", async () => {
77+
if (!dbAvailable) return;
78+
expect(() =>
79+
db()._user_set_query_project_manage_users_owner_only(
80+
{ project_id: projectId, manage_users_owner_only: "yes" as any },
81+
ownerId,
82+
),
83+
).toThrow("manage_users_owner_only must be a boolean");
84+
});
85+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
export function sanitizeManageUsersOwnerOnly(
7+
value: unknown,
8+
): boolean | undefined {
9+
if (value === undefined || value === null) {
10+
return undefined;
11+
}
12+
if (typeof value === "object") {
13+
// Allow nested shape { manage_users_owner_only: boolean } from callers that wrap input.
14+
const candidate = (value as any).manage_users_owner_only;
15+
if (candidate !== undefined) {
16+
return sanitizeManageUsersOwnerOnly(candidate);
17+
}
18+
// Allow Immutable.js style get("manage_users_owner_only")
19+
const getter = (value as any).get;
20+
if (typeof getter === "function") {
21+
const maybe = getter.call(value, "manage_users_owner_only");
22+
if (maybe !== undefined) {
23+
return sanitizeManageUsersOwnerOnly(maybe);
24+
}
25+
}
26+
}
27+
if (typeof value !== "boolean") {
28+
throw Error("manage_users_owner_only must be a boolean");
29+
}
30+
return value;
31+
}

src/packages/database/postgres/types.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ export interface QueryOptions<T = UntypedQueryResult> {
4848
cb?: CB<QueryRows<T>>;
4949
}
5050

51-
export interface AsyncQueryOptions<T = UntypedQueryResult>
52-
extends Omit<QueryOptions<T>, "cb"> {}
51+
export interface AsyncQueryOptions<T = UntypedQueryResult> extends Omit<
52+
QueryOptions<T>,
53+
"cb"
54+
> {}
5355

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

408410
set_project_settings(opts: { project_id: string; settings: object; cb?: CB });
409-
410-
uncaught_exception: (err:any) => void;
411+
412+
_user_set_query_project_manage_users_owner_only(
413+
obj: any,
414+
account_id: string,
415+
): string | undefined;
416+
417+
uncaught_exception: (err: any) => void;
411418
}
412419

413420
// This is an extension of BaseProject in projects/control/base.ts

src/packages/frontend/antd-bootstrap.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ function parse_bsStyle(props: {
8686
let type =
8787
props.bsStyle == null
8888
? "default"
89-
: BS_STYLE_TO_TYPE[props.bsStyle] ?? "default";
89+
: (BS_STYLE_TO_TYPE[props.bsStyle] ?? "default");
9090

9191
let style: React.CSSProperties | undefined = undefined;
9292
// antd has no analogue of "success" & "warning", it's not clear to me what

src/packages/frontend/client/project-collaborators.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* License: MS-RSL – see LICENSE.md for details
44
*/
55

6+
// cSpell:ignore replyto collabs noncloud
7+
68
import type { ConatClient } from "@cocalc/frontend/conat/client";
79
import type { AddCollaborator } from "@cocalc/conat/hub/api/projects";
810

@@ -57,10 +59,18 @@ export class ProjectCollaborators {
5759
public async add_collaborator(
5860
opts: AddCollaborator,
5961
): Promise<{ project_id?: string | string[] }> {
60-
// project_id is a single string or possibly an array of project_id's
62+
// project_id is a single string or possibly an array of project_id's
6163
// in case of a token.
6264
return await this.conat.hub.projects.addCollaborator({
6365
opts,
6466
});
6567
}
68+
69+
public async change_user_type(opts: {
70+
project_id: string;
71+
target_account_id: string;
72+
new_group: "owner" | "collaborator";
73+
}): Promise<void> {
74+
return await this.conat.hub.projects.changeUserType({ opts });
75+
}
6676
}

src/packages/frontend/collaborators/add-collaborators.tsx

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
Add collaborators to a project
88
*/
99

10+
// cSpell:ignore replyto noncloud collabs
11+
1012
import { Alert, Button, Input, Select } from "antd";
11-
import { useIntl } from "react-intl";
13+
import { FormattedMessage, useIntl } from "react-intl";
14+
1215
import { labels } from "@cocalc/frontend/i18n";
1316
import {
1417
React,
@@ -17,12 +20,19 @@ import {
1720
useIsMountedRef,
1821
useMemo,
1922
useRef,
23+
useRedux,
2024
useTypedRedux,
2125
useState,
2226
} from "../app-framework";
23-
import { Well } from "../antd-bootstrap";
24-
import { A, Icon, Loading, ErrorDisplay, Gap } from "../components";
25-
import { webapp_client } from "../webapp-client";
27+
import { Well } from "@cocalc/frontend/antd-bootstrap";
28+
import {
29+
A,
30+
Icon,
31+
Loading,
32+
ErrorDisplay,
33+
Gap,
34+
} from "@cocalc/frontend/components";
35+
import { webapp_client } from "@cocalc/frontend/webapp-client";
2636
import { SITE_NAME } from "@cocalc/util/theme";
2737
import {
2838
contains_url,
@@ -34,10 +44,10 @@ import {
3444
search_match,
3545
search_split,
3646
} from "@cocalc/util/misc";
37-
import { Project } from "../projects/store";
38-
import { Avatar } from "../account/avatar/avatar";
47+
import { Project } from "@cocalc/frontend/projects/store";
48+
import { Avatar } from "@cocalc/frontend/account/avatar/avatar";
3949
import { ProjectInviteTokens } from "./project-invite-tokens";
40-
import { alert_message } from "../alerts";
50+
import { alert_message } from "@cocalc/frontend/alerts";
4151
import { useStudentProjectFunctionality } from "@cocalc/frontend/course";
4252
import Sandbox from "./sandbox";
4353
import track from "@cocalc/frontend/user-tracking";
@@ -104,6 +114,20 @@ export const AddCollaborators: React.FC<Props> = ({
104114
() => project_map?.get(project_id),
105115
[project_id, project_map],
106116
);
117+
const get_account_id = useRedux("account", "get_account_id");
118+
const current_account_id = get_account_id();
119+
const strict_collaborator_management =
120+
useTypedRedux("customize", "strict_collaborator_management") ?? false;
121+
const manage_users_owner_only =
122+
strict_collaborator_management ||
123+
(project?.get("manage_users_owner_only") ?? false);
124+
const current_user_group = project?.getIn([
125+
"users",
126+
current_account_id,
127+
"group",
128+
]);
129+
const isOwner = current_user_group === "owner";
130+
const collaboratorManagementRestricted = manage_users_owner_only && !isOwner;
107131

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

718+
if (collaboratorManagementRestricted) {
719+
return (
720+
<Alert
721+
type="info"
722+
showIcon={false}
723+
message={
724+
<FormattedMessage
725+
id="project.collaborators.add.owner_only_setting"
726+
defaultMessage="Only project owners can add collaborators when owner-only management is enabled."
727+
/>
728+
}
729+
/>
730+
);
731+
}
732+
694733
return (
695734
<div
696735
style={isFlyout ? { paddingLeft: "5px", paddingRight: "5px" } : undefined}

0 commit comments

Comments
 (0)