Skip to content

Commit 80c8f6b

Browse files
committed
server/api: add change-user-type to add/remove collaborators
1 parent c9c677a commit 80c8f6b

File tree

3 files changed

+170
-0
lines changed

3 files changed

+170
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { z } from "../../../framework";
2+
3+
import { FailedAPIOperationSchema, OkAPIOperationSchema } from "../../common";
4+
5+
import { ProjectIdSchema } from "../common";
6+
import { AccountIdSchema } from "../../accounts/common";
7+
8+
export const UserGroupSchema = z
9+
.enum(["owner", "collaborator"])
10+
.describe("Project user role (owner or collaborator).");
11+
12+
export const ChangeProjectUserTypeInputSchema = z
13+
.object({
14+
project_id: ProjectIdSchema,
15+
target_account_id: AccountIdSchema.describe(
16+
"Account id of the user whose role will be changed.",
17+
),
18+
new_group: UserGroupSchema.describe(
19+
"New role to assign; must be owner or collaborator.",
20+
),
21+
})
22+
.describe(
23+
"Change a collaborator's role in a project. Only owners can promote or demote users; validation enforces ownership rules (e.g., cannot demote the last owner).",
24+
);
25+
26+
export const ChangeProjectUserTypeOutputSchema = z.union([
27+
FailedAPIOperationSchema,
28+
OkAPIOperationSchema,
29+
]);
30+
31+
export type ChangeProjectUserTypeInput = z.infer<
32+
typeof ChangeProjectUserTypeInputSchema
33+
>;
34+
export type ChangeProjectUserTypeOutput = z.infer<
35+
typeof ChangeProjectUserTypeOutputSchema
36+
>;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/** @jest-environment node */
2+
3+
import { createMocks } from "lib/api/test-framework";
4+
import handler from "./change-user-type";
5+
6+
jest.mock("@cocalc/server/projects/collaborators", () => ({
7+
changeUserType: jest.fn(),
8+
}));
9+
jest.mock("lib/account/get-account", () => jest.fn());
10+
11+
describe("/api/v2/projects/collaborators/change-user-type", () => {
12+
beforeEach(() => {
13+
jest.clearAllMocks();
14+
});
15+
16+
test("unauthenticated request returns error", async () => {
17+
const getAccountId = require("lib/account/get-account");
18+
getAccountId.mockResolvedValue(undefined);
19+
20+
const { req, res } = createMocks({
21+
method: "POST",
22+
url: "/api/v2/projects/collaborators/change-user-type",
23+
body: {
24+
project_id: "00000000-0000-0000-0000-000000000000",
25+
target_account_id: "11111111-1111-1111-1111-111111111111",
26+
new_group: "owner",
27+
},
28+
});
29+
30+
await expect(handler(req, res)).resolves.not.toThrow();
31+
32+
const collaborators = require("@cocalc/server/projects/collaborators");
33+
expect(collaborators.changeUserType).not.toHaveBeenCalled();
34+
35+
const data = res._getJSONData();
36+
expect(data).toHaveProperty("error");
37+
expect(data.error).toContain("signed in");
38+
});
39+
40+
test("authenticated request calls changeUserType", async () => {
41+
const mockAccountId = "22222222-2222-2222-2222-222222222222";
42+
const mockProjectId = "00000000-0000-0000-0000-000000000000";
43+
const mockTargetAccountId = "11111111-1111-1111-1111-111111111111";
44+
45+
const getAccountId = require("lib/account/get-account");
46+
getAccountId.mockResolvedValue(mockAccountId);
47+
48+
const collaborators = require("@cocalc/server/projects/collaborators");
49+
collaborators.changeUserType.mockResolvedValue(undefined);
50+
51+
const { req, res } = createMocks({
52+
method: "POST",
53+
url: "/api/v2/projects/collaborators/change-user-type",
54+
body: {
55+
project_id: mockProjectId,
56+
target_account_id: mockTargetAccountId,
57+
new_group: "collaborator",
58+
},
59+
});
60+
61+
await handler(req, res);
62+
63+
expect(collaborators.changeUserType).toHaveBeenCalledWith({
64+
account_id: mockAccountId,
65+
opts: {
66+
project_id: mockProjectId,
67+
target_account_id: mockTargetAccountId,
68+
new_group: "collaborator",
69+
},
70+
});
71+
72+
const data = res._getJSONData();
73+
expect(data).toHaveProperty("status");
74+
expect(data.status).toBe("ok");
75+
});
76+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
API endpoint to change a user's collaborator type on an existing project.
3+
4+
Permissions checks are performed by the underlying API call and are NOT
5+
executed at this stage.
6+
7+
*/
8+
import { changeUserType } from "@cocalc/server/projects/collaborators";
9+
10+
import getAccountId from "lib/account/get-account";
11+
import getParams from "lib/api/get-params";
12+
import { apiRoute, apiRouteOperation } from "lib/api";
13+
import { OkStatus } from "lib/api/status";
14+
import {
15+
ChangeProjectUserTypeInputSchema,
16+
ChangeProjectUserTypeOutputSchema,
17+
} from "lib/api/schema/projects/collaborators/change-user-type";
18+
19+
async function handle(req, res) {
20+
const { project_id, target_account_id, new_group } = getParams(req);
21+
const client_account_id = await getAccountId(req);
22+
23+
try {
24+
if (!client_account_id) {
25+
throw Error("must be signed in");
26+
}
27+
28+
await changeUserType({
29+
account_id: client_account_id,
30+
opts: { project_id, target_account_id, new_group },
31+
});
32+
33+
res.json(OkStatus);
34+
} catch (err) {
35+
res.json({ error: err.message });
36+
}
37+
}
38+
39+
export default apiRoute({
40+
changeProjectUserType: apiRouteOperation({
41+
method: "POST",
42+
openApiOperation: {
43+
tags: ["Projects", "Admin"],
44+
},
45+
})
46+
.input({
47+
contentType: "application/json",
48+
body: ChangeProjectUserTypeInputSchema,
49+
})
50+
.outputs([
51+
{
52+
status: 200,
53+
contentType: "application/json",
54+
body: ChangeProjectUserTypeOutputSchema,
55+
},
56+
])
57+
.handler(handle),
58+
});

0 commit comments

Comments
 (0)