diff --git a/src/.claude/settings.json b/src/.claude/settings.json index ebc6cbff4b5..2841132f5b6 100644 --- a/src/.claude/settings.json +++ b/src/.claude/settings.json @@ -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:*)", diff --git a/src/packages/conat/hub/api/projects.ts b/src/packages/conat/hub/api/projects.ts index 3414522c25b..eb412c8b044 100644 --- a/src/packages/conat/hub/api/projects.ts +++ b/src/packages/conat/hub/api/projects.ts @@ -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"; import { type ProjectState, type ProjectStatus, @@ -13,6 +14,7 @@ export const projects = { addCollaborator: authFirstRequireAccount, inviteCollaborator: authFirstRequireAccount, inviteCollaboratorWithoutAccount: authFirstRequireAccount, + changeUserType: authFirstRequireAccount, setQuotas: authFirstRequireAccount, start: authFirstRequireAccount, stop: authFirstRequireAccount, @@ -95,6 +97,18 @@ export interface Projects { }; }) => Promise; + changeUserType: ({ + account_id, + opts, + }: { + account_id?: string; + opts: { + project_id: string; + target_account_id: string; + new_group: UserGroup; + }; + }) => Promise; + setQuotas: (opts: { account_id?: string; project_id: string; diff --git a/src/packages/database/postgres-user-queries.coffee b/src/packages/database/postgres-user-queries.coffee index 2cb8f7754d7..d1b07741858 100644 --- a/src/packages/database/postgres-user-queries.coffee +++ b/src/packages/database/postgres-user-queries.coffee @@ -26,10 +26,12 @@ lodash = require('lodash') {defaults} = misc = require('@cocalc/util/misc') required = defaults.required -{PROJECT_UPGRADES, SCHEMA, OPERATORS, isToOperand} = require('@cocalc/util/schema') +{SCHEMA, OPERATORS, isToOperand} = require('@cocalc/util/schema') {queryIsCmp, userGetQueryFilter} = require("./user-query/user-get-query") {updateRetentionData} = require('./postgres/retention') +{sanitizeManageUsersOwnerOnly} = require('./postgres/project/manage-users-owner-only') +{sanitizeUserSetQueryProjectUsers} = require('./postgres/project/user-set-query-project-users') { checkProjectName } = require("@cocalc/util/db-schema/name-rules"); {callback2} = require('@cocalc/util/async-utils') @@ -793,51 +795,14 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext y[k0] = v0 _user_set_query_project_users: (obj, account_id) => - dbg = @_dbg("_user_set_query_project_users") - if not obj.users? - # nothing to do -- not changing users. - return - ##dbg("disabled") - ##return obj.users - # - ensures all keys of users are valid uuid's (though not that they are valid users). - # - and format is: - # {group:'owner' or 'collaborator', hide:bool, upgrades:{a map}} - # with valid upgrade fields. - upgrade_fields = PROJECT_UPGRADES.params - users = {} - # TODO: we obviously should check that a user is only changing the part - # of this object involving themselves... or adding/removing collaborators. - # That is not currently done below. TODO TODO TODO SECURITY. - for id, x of obj.users - if misc.is_valid_uuid_string(id) - for key in misc.keys(x) - if key not in ['group', 'hide', 'upgrades', 'ssh_keys'] - throw Error("unknown field '#{key}") - if x.group? and (x.group not in ['owner', 'collaborator']) - throw Error("invalid value for field 'group'") - if x.hide? and typeof(x.hide) != 'boolean' - throw Error("invalid type for field 'hide'") - if x.upgrades? - if not misc.is_object(x.upgrades) - throw Error("invalid type for field 'upgrades'") - for k,_ of x.upgrades - if not upgrade_fields[k] - throw Error("invalid upgrades field '#{k}'") - if x.ssh_keys - # do some checks. - if not misc.is_object(x.ssh_keys) - throw Error("ssh_keys must be an object") - for fingerprint, key of x.ssh_keys - if not key # deleting - continue - if not misc.is_object(key) - throw Error("each key in ssh_keys must be an object") - for k, v of key - # the two dates are just numbers not actual timestamps... - if k not in ['title', 'value', 'creation_date', 'last_use_date'] - throw Error("invalid ssh_keys field '#{k}'") - users[id] = x - return users + return sanitizeUserSetQueryProjectUsers(obj, account_id) + + _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, @@ -933,6 +898,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)}") diff --git a/src/packages/database/postgres/manage-users-owner-only.test.ts b/src/packages/database/postgres/manage-users-owner-only.test.ts new file mode 100644 index 00000000000..a352ae4d609 --- /dev/null +++ b/src/packages/database/postgres/manage-users-owner-only.test.ts @@ -0,0 +1,74 @@ +/* + * 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 | undefined; + +beforeAll(async () => { + await initEphemeralDatabase(); + pool = getPool(); +}, 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 () => { + await insertProject({ projectId, ownerId, collaboratorId }); + }); + + test("owner can set manage_users_owner_only", async () => { + 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 () => { + 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 () => { + 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"); + }); +}); diff --git a/src/packages/database/postgres/project/manage-users-owner-only.ts b/src/packages/database/postgres/project/manage-users-owner-only.ts new file mode 100644 index 00000000000..858b65c1b0b --- /dev/null +++ b/src/packages/database/postgres/project/manage-users-owner-only.ts @@ -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; +} diff --git a/src/packages/database/postgres/project/user-set-query-project-users.test.ts b/src/packages/database/postgres/project/user-set-query-project-users.test.ts new file mode 100644 index 00000000000..56ae9998759 --- /dev/null +++ b/src/packages/database/postgres/project/user-set-query-project-users.test.ts @@ -0,0 +1,131 @@ +/* + * This file is part of CoCalc: Copyright © 2025 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import { uuid } from "@cocalc/util/misc"; +import { sanitizeUserSetQueryProjectUsers } from "./user-set-query-project-users"; + +describe("_user_set_query_project_users sanitizer", () => { + const accountId = uuid(); + const otherId = uuid(); + + test("returns undefined when users is not provided", () => { + const value = sanitizeUserSetQueryProjectUsers({}, accountId); + expect(value).toBeUndefined(); + }); + + test("allows updating own hide and upgrades", () => { + const value = sanitizeUserSetQueryProjectUsers( + { + users: { + [accountId]: { hide: true, upgrades: { memory: 1024 } }, + }, + }, + accountId, + ); + expect(value).toEqual({ + [accountId]: { hide: true, upgrades: { memory: 1024 } }, + }); + }); + + test("rejects modifying another account", () => { + expect(() => + sanitizeUserSetQueryProjectUsers( + { + users: { + [otherId]: { upgrades: { memory: 1024 } }, + }, + }, + accountId, + ), + ).toThrow( + "users set queries may only change upgrades for the requesting account", + ); + }); + + test("allows system-style updates when no account_id is provided", () => { + const value = sanitizeUserSetQueryProjectUsers({ + users: { + [accountId]: { hide: false, ssh_keys: {} }, + }, + }); + expect(value).toEqual({ + [accountId]: { hide: false, ssh_keys: {} }, + }); + }); + + test("allows system operations to set group to owner", () => { + const value = sanitizeUserSetQueryProjectUsers({ + users: { + [accountId]: { group: "owner", hide: false }, + }, + }); + expect(value).toEqual({ + [accountId]: { group: "owner", hide: false }, + }); + }); + + test("allows system operations to set group to collaborator", () => { + const value = sanitizeUserSetQueryProjectUsers({ + users: { + [accountId]: { group: "collaborator" }, + }, + }); + expect(value).toEqual({ + [accountId]: { group: "collaborator" }, + }); + }); + + test("rejects group changes", () => { + expect(() => + sanitizeUserSetQueryProjectUsers( + { + users: { + [accountId]: { group: "owner" }, + }, + }, + accountId, + ), + ).toThrow("changing collaborator group via user_set_query is not allowed"); + }); + + test("rejects invalid group values in system operations", () => { + expect(() => + sanitizeUserSetQueryProjectUsers({ + users: { + [accountId]: { group: "admin" }, + }, + }), + ).toThrow( + "invalid group value 'admin' - must be 'owner' or 'collaborator'", + ); + }); + + test("allows hiding another collaborator", () => { + const value = sanitizeUserSetQueryProjectUsers( + { + users: { + [otherId]: { hide: true }, + }, + }, + accountId, + ); + expect(value).toEqual({ + [otherId]: { hide: true }, + }); + }); + + test("rejects invalid upgrade field", () => { + expect(() => + sanitizeUserSetQueryProjectUsers( + { + users: { + [accountId]: { upgrades: { invalidQuota: 1 } }, + }, + }, + accountId, + ), + ).toThrow("invalid upgrades field 'invalidQuota'"); + }); +}); diff --git a/src/packages/database/postgres/project/user-set-query-project-users.ts b/src/packages/database/postgres/project/user-set-query-project-users.ts new file mode 100644 index 00000000000..159e3d3822f --- /dev/null +++ b/src/packages/database/postgres/project/user-set-query-project-users.ts @@ -0,0 +1,158 @@ +/* + * This file is part of CoCalc: Copyright © 2025 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import { PROJECT_UPGRADES } from "@cocalc/util/schema"; +import { + assert_valid_account_id, + is_object, + is_valid_uuid_string, +} from "@cocalc/util/misc"; +import { type UserGroup } from "@cocalc/util/project-ownership"; + +type AllowedUserFields = { + group?: UserGroup; + hide?: boolean; + upgrades?: Record; + ssh_keys?: Record | undefined>; +}; + +function ensureAllowedKeys( + user: Record, + allowGroupChanges: boolean, +): void { + const allowed = new Set(["hide", "upgrades", "ssh_keys"]); + for (const key of Object.keys(user)) { + if (key === "group") { + if (!allowGroupChanges) { + throw Error( + "changing collaborator group via user_set_query is not allowed", + ); + } + continue; + } + if (!allowed.has(key)) { + throw Error(`unknown field '${key}'`); + } + } +} + +function sanitizeUpgrades(upgrades: unknown): Record { + if (!is_object(upgrades)) { + throw Error("invalid type for field 'upgrades'"); + } + const allowedUpgrades = PROJECT_UPGRADES.params; + for (const key of Object.keys(upgrades)) { + if (!Object.prototype.hasOwnProperty.call(allowedUpgrades, key)) { + throw Error(`invalid upgrades field '${key}'`); + } + } + return upgrades as Record; +} + +function sanitizeSshKeys( + ssh_keys: unknown, +): Record | undefined> { + if (!is_object(ssh_keys)) { + throw Error("ssh_keys must be an object"); + } + const sanitized: Record | undefined> = {}; + for (const fingerprint of Object.keys(ssh_keys)) { + const key = (ssh_keys as Record)[fingerprint]; + if (!key) { + sanitized[fingerprint] = undefined; + continue; + } + if (!is_object(key)) { + throw Error("each key in ssh_keys must be an object"); + } + for (const field of Object.keys(key)) { + if ( + !["title", "value", "creation_date", "last_use_date"].includes(field) + ) { + throw Error(`invalid ssh_keys field '${field}'`); + } + } + sanitized[fingerprint] = key as Record; + } + return sanitized; +} + +/** + * Sanitize and security-check project user mutations submitted via user set query. + * + * Only permits modifying the requesting user's own entry (hide/upgrades/ssh_keys). + * Collaborator role changes must use dedicated APIs that enforce ownership rules. + */ +export function sanitizeUserSetQueryProjectUsers( + obj: { users?: unknown } | undefined, + account_id?: string, +): Record | undefined { + if (obj?.users == null) { + return undefined; + } + if (account_id != null) { + assert_valid_account_id(account_id); + } + if (!is_object(obj.users)) { + throw Error("users must be an object"); + } + + const sanitized: Record = {}; + const usersInput = obj.users as Record; + + for (const id of Object.keys(usersInput)) { + if (!is_valid_uuid_string(id)) { + throw Error(`invalid account_id '${id}'`); + } + const user = usersInput[id]; + if (!is_object(user)) { + throw Error("user entry must be an object"); + } + + const isSelf = account_id == null || id === account_id; + ensureAllowedKeys(user as Record, account_id == null); + + const entry: AllowedUserFields = {}; + if ("group" in user) { + if (account_id != null) { + throw Error( + "changing collaborator group via user_set_query is not allowed", + ); + } + const group = (user as any).group; + if (group !== "owner" && group !== "collaborator") { + throw Error( + `invalid group value '${group}' - must be 'owner' or 'collaborator'`, + ); + } + entry.group = group; + } + if ("hide" in user) { + if (typeof (user as any).hide !== "boolean") { + throw Error("invalid type for field 'hide'"); + } + entry.hide = (user as any).hide; + } + if ("upgrades" in user) { + if (!isSelf) { + throw Error( + "users set queries may only change upgrades for the requesting account", + ); + } + entry.upgrades = sanitizeUpgrades((user as any).upgrades); + } + if ("ssh_keys" in user) { + if (!isSelf) { + throw Error( + "users set queries may only change ssh_keys for the requesting account", + ); + } + entry.ssh_keys = sanitizeSshKeys((user as any).ssh_keys); + } + sanitized[id] = entry; + } + + return sanitized; +} diff --git a/src/packages/database/postgres/types.ts b/src/packages/database/postgres/types.ts index 939a4b81cff..ce44cf4528d 100644 --- a/src/packages/database/postgres/types.ts +++ b/src/packages/database/postgres/types.ts @@ -48,8 +48,10 @@ export interface QueryOptions { cb?: CB>; } -export interface AsyncQueryOptions - extends Omit, "cb"> {} +export interface AsyncQueryOptions extends Omit< + QueryOptions, + "cb" +> {} export interface UserQueryOptions { client_id?: string; // if given, uses to control number of queries at once by one client. @@ -153,6 +155,17 @@ export interface PostgreSQL extends EventEmitter { cb: CB; }): void; + remove_collaborator_from_project(opts: { + account_id: string; + project_id: string; + cb: CB; + }): void; + + _user_set_query_project_users( + obj: any, + account_id?: string, + ): Record | undefined; + user_is_in_project_group(opts: { account_id: string; project_id: string; @@ -406,8 +419,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 diff --git a/src/packages/frontend/antd-bootstrap.tsx b/src/packages/frontend/antd-bootstrap.tsx index 1bf7e016748..94ecc4a9cb6 100644 --- a/src/packages/frontend/antd-bootstrap.tsx +++ b/src/packages/frontend/antd-bootstrap.tsx @@ -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 diff --git a/src/packages/frontend/client/project-collaborators.ts b/src/packages/frontend/client/project-collaborators.ts index ee2af1ff072..7ee6f904c3b 100644 --- a/src/packages/frontend/client/project-collaborators.ts +++ b/src/packages/frontend/client/project-collaborators.ts @@ -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"; @@ -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 { + return await this.conat.hub.projects.changeUserType({ opts }); + } } diff --git a/src/packages/frontend/collaborators/add-collaborators.tsx b/src/packages/frontend/collaborators/add-collaborators.tsx index 1ba217d9612..b52c63b6c93 100644 --- a/src/packages/frontend/collaborators/add-collaborators.tsx +++ b/src/packages/frontend/collaborators/add-collaborators.tsx @@ -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, @@ -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, @@ -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"; @@ -104,6 +114,20 @@ export const AddCollaborators: React.FC = ({ () => 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(""); @@ -257,7 +281,7 @@ export const AddCollaborators: React.FC = ({ // 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()}`); @@ -691,6 +715,21 @@ export const AddCollaborators: React.FC = ({ return
; } + if (collaboratorManagementRestricted) { + return ( + + } + /> + ); + } + return (
) { + const intl = useIntl(); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + + const project_id = project.get("project_id"); + const siteEnforced = + useTypedRedux("customize", "strict_collaborator_management") ?? false; + const manage_users_owner_only = + siteEnforced || project.get("manage_users_owner_only") || false; + + // Check if current user is an owner + const account_id = webapp_client.account_id; + const userGroup = project.getIn(["users", account_id, "group"]); + const isOwner = userGroup === "owner"; + + async function handleChange(checked: boolean): Promise { + if (siteEnforced) return; + if (!isOwner) return; + + setError(""); + setSaving(true); + + try { + await webapp_client.async_query({ + query: { + projects: { + project_id, + manage_users_owner_only: checked, + }, + }, + }); + } catch (err) { + setError(`Error updating setting: ${err}`); + } finally { + setSaving(false); + } + } + + const switchComponent = ( + handleChange(e.target.checked)} + disabled={siteEnforced || !isOwner || saving} + > + + + ); + + const content = ( + <> + {!isOwner ? ( + + {switchComponent} + + ) : ( + switchComponent + )} + + {siteEnforced && ( + + } + /> + )} + + {error && ( + setError("")} + message={error} + /> + )} + + ); + + if (!withSettingBox) { + return content; + } + + return ( + + {content} + + ); +} diff --git a/src/packages/frontend/collaborators/current-collabs.tsx b/src/packages/frontend/collaborators/current-collabs.tsx index 767c381c9de..9b913790777 100644 --- a/src/packages/frontend/collaborators/current-collabs.tsx +++ b/src/packages/frontend/collaborators/current-collabs.tsx @@ -1,17 +1,26 @@ /* - * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. + * This file is part of CoCalc: Copyright © 2025 Sagemath, Inc. * License: MS-RSL – see LICENSE.md for details */ -import { Button, Card, Popconfirm } from "antd"; -import React from "react"; +// cSpell:ignore replyto collabs noncloud + +import { Alert, Button, Card, Dropdown, Popconfirm } from "antd"; +import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { CSS, redux, useRedux } from "@cocalc/frontend/app-framework"; + +import { + CSS, + redux, + useRedux, + useTypedRedux, +} from "@cocalc/frontend/app-framework"; import { - Gap, Icon, Paragraph, SettingBox, + Text, + Tip, Title, } from "@cocalc/frontend/components"; import { useStudentProjectFunctionality } from "@cocalc/frontend/course"; @@ -19,9 +28,20 @@ import { labels } from "@cocalc/frontend/i18n"; import { CancelText } from "@cocalc/frontend/i18n/components"; import { Project } from "@cocalc/frontend/project/settings/types"; import { COLORS } from "@cocalc/util/theme"; +import { CollaboratorsSetting } from "./collaborators-setting"; import { FIX_BORDER } from "../project/page/common"; import { User } from "../users"; +const LIST_STYLE: CSS = { + maxHeight: "20em", + overflowY: "auto", + overflowX: "hidden", + marginBottom: "0", + display: "flex", + flexDirection: "column", + gap: "12px", +} as const; + interface Props { project: Project; user_map?: any; @@ -35,16 +55,46 @@ export const CurrentCollaboratorsPanel: React.FC = (props: Props) => { const get_account_id = useRedux("account", "get_account_id"); const sort_by_activity = useRedux("projects", "sort_by_activity"); const student = useStudentProjectFunctionality(project.get("project_id")); + const [error, setError] = useState(""); + + const project_id = project.get("project_id"); + const current_account_id = get_account_id(); + const users = project.get("users"); + const current_user_group = users?.getIn([current_account_id, "group"]); + const is_requester_owner = current_user_group === "owner"; + 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); + + // Count owners to check if this is the last owner + const owner_count = users + ? users.valueSeq().count((u: any) => u?.get?.("group") === "owner") + : 0; function remove_collaborator(account_id: string) { - const project_id = project.get("project_id"); redux.getActions("projects").remove_collaborator(project_id, account_id); - if (account_id === get_account_id()) { - (redux.getActions("page") as any).close_project_tab(project_id); + if (account_id === current_account_id) { + (redux.getActions("page") as any).close_project_tab(project_id); // TODO: better types } } + async function change_user_type( + account_id: string, + new_group: "owner" | "collaborator", + ) { + try { + setError(""); + await redux + .getActions("projects") + .change_user_type(project_id, account_id, new_group); + } catch (err) { + setError(`Error: ${err}`); + } + } + function user_remove_confirm_text(account_id: string) { const style: CSS = { maxWidth: "300px" }; if (account_id === get_account_id()) { @@ -73,51 +123,217 @@ export const CurrentCollaboratorsPanel: React.FC = (props: Props) => { } } - function user_remove_button(account_id: string, group?: string) { + function renderRoleSetting(account_id: string, group?: string) { + const isOwner = group === "owner"; + const isLastOwner = isOwner && owner_count === 1; + const can_promote = !isOwner && is_requester_owner; + const can_demote = isOwner && is_requester_owner && !isLastOwner; + + const buttonSize = isFlyout ? "small" : "middle"; + const roleLabel = intl.formatMessage( + isOwner ? labels.owner : labels.collaborator, + ); + + // If not allowed to change owner/collab status, simply report the role of the given user + if (student.disableCollaborators || !is_requester_owner) { + const label = ( + + {`(${roleLabel})`} + + ); + return isFlyout ? ( +
+ {label} +
+ ) : ( + label + ); + } + + const menuItems = [ + { + key: "promote", + label: ( + + + + ), + disabled: !can_promote, + onClick: () => change_user_type(account_id, "owner"), + }, + { + key: "demote", + label: ( + + + + ), + disabled: !can_demote, + onClick: () => change_user_type(account_id, "collaborator"), + }, + ]; + + const dropdown = ( + + + + ); + + if (isFlyout) { + return ( +
+ {dropdown} +
+ ); + } else { + return dropdown; + } + } + + function renderRemoveButton(account_id: string, group?: string) { if (student.disableCollaborators) return; const text = user_remove_confirm_text(account_id); const isOwner = group === "owner"; + const isSelf = account_id === current_account_id; + const disabledBySetting = + manage_users_owner_only && !is_requester_owner && !isSelf; + const disabled = isOwner || disabledBySetting; + + const disabledReason = (() => { + if (isOwner) { + return intl.formatMessage({ + id: "collaborators.current-collabs.remove.owner_disabled", + defaultMessage: "Owners must be demoted before they can be removed.", + }); + } + if (disabledBySetting) { + return intl.formatMessage({ + id: "collaborators.current-collabs.remove.setting_disabled", + defaultMessage: + "Only owners can remove collaborators when this setting is enabled.", + }); + } + return undefined; + })(); + + const buttonType = isFlyout ? "link" : "default"; + const buttonSize = isFlyout ? "small" : "middle"; + return ( - remove_collaborator(account_id)} - okText={"Yes, remove collaborator"} - cancelText={} - disabled={isOwner} - > - - + + + ); } function render_user(user: any, is_last?: boolean) { - const style = { + const baseStyle: CSS = { width: "100%", flex: "1 1 auto", ...(!is_last ? { marginBottom: "20px" } : {}), }; + + if (isFlyout) { + return ( +
+
+ +
+
+ {renderRoleSetting(user.account_id, user.group)} + {renderRemoveButton(user.account_id, user.group)} +
+
+ ); + } + return ( -
- - - ({user.group}) - - {user_remove_button(user.account_id, user.group)} +
+
+ +
+
+ {renderRoleSetting(user.account_id, user.group)} + {renderRemoveButton(user.account_id, user.group)} +
); } @@ -131,70 +347,111 @@ export const CurrentCollaboratorsPanel: React.FC = (props: Props) => { .map((v, k) => ({ account_id: k, group: v.get("group") })) .toList() .toJS(); - return sort_by_activity(users, project.get("project_id")).map((u, i) => - render_user(u, i === users.length - 1), + return sort_by_activity(users, project.get("project_id")).map( + (u: any, i: number) => render_user(u, i === users.length - 1), + ); + } + + function render_setting() { + return ( +
+ +
); } function render_collaborators_list() { - const style: CSS = { - maxHeight: "20em", - overflowY: "auto", - overflowX: "hidden", - marginBottom: "0", - display: "flex", - flexDirection: "column", - }; + const header = ( + <> + {error && ( + setError("")} + style={{ marginBottom: "10px" }} + /> + )} + + ); + + const list =
{render_users()}
; + if (isFlyout) { return ( -
- {render_users()} +
+ {header} + {list}
); } else { return ( - - {render_users()} + + {header} + {list} ); } } - const introText = intl.formatMessage({ - id: "collaborators.current-collabs.intro", - defaultMessage: - "Everybody listed below can collaboratively work with you on any Jupyter Notebook, Linux Terminal or file in this project, and add or remove other collaborators.", + const introText = intl.formatMessage( + { + id: "collaborators.current-collabs.intro2", + defaultMessage: `Everybody listed below can collaboratively work with you on any Jupyter Notebook, Linux Terminal or file in this project. + {manageUsersOnly, select, + true { Only project owners can add or remove collaborators.} + other { Collaborators can also add or remove other collaborators.}}`, + }, + { manageUsersOnly: manage_users_owner_only ? "true" : "false" }, + ); + + const nonOwnerNote = !is_requester_owner + ? intl.formatMessage({ + id: "project.collaborators.non_owner_note", + defaultMessage: "Only project owners can manage user roles.", + }) + : null; + + const titleText = intl.formatMessage({ + id: "collaborators.current-collabs.title", + defaultMessage: "Current Collaborators", + description: "Title of a table listing users collaborating on that project", }); switch (mode) { case "project": return ( - {introText} +
+ {introText} + {nonOwnerNote && ( + <> + {" "} + {nonOwnerNote} + + )} +

{render_collaborators_list()} +
+ {render_setting()}
); case "flyout": return (
- <Icon name="user" />{" "} - <FormattedMessage - id="collaborators.current-collabs.title" - defaultMessage={"Current Collaborators"} - description={ - "Title of a table listing users collaborating on that project" - } - /> + <Icon name="user" /> {titleText} {introText} + {nonOwnerNote && <> {nonOwnerNote}} {render_collaborators_list()} + {render_setting()}
); } diff --git a/src/packages/frontend/customize.tsx b/src/packages/frontend/customize.tsx index decc79733f4..7b82449dddf 100644 --- a/src/packages/frontend/customize.tsx +++ b/src/packages/frontend/customize.tsx @@ -146,6 +146,7 @@ export interface CustomizeState { organization_name: string; organization_url: string; share_server: boolean; + strict_collaborator_management: boolean; site_description: string; site_name: string; splash_image: string; @@ -261,7 +262,7 @@ export class CustomizeActions extends Actions { unlicensed_project_timetravel_limit: undefined, }); }; - + reload = async () => { await loadCustomizeState(); }; diff --git a/src/packages/frontend/i18n/common.ts b/src/packages/frontend/i18n/common.ts index 654ebdd2d57..6c518b88ec1 100644 --- a/src/packages/frontend/i18n/common.ts +++ b/src/packages/frontend/i18n/common.ts @@ -544,6 +544,18 @@ export const labels = defineMessages({ description: "Short label of a table, which shows the list of users having access", }, + owner: { + id: "labels.owner", + defaultMessage: "Owner", + description: + "Label for a project owner role - a user with full administrative rights", + }, + collaborator: { + id: "labels.collaborator", + defaultMessage: "Collaborator", + description: + "Label for a project collaborator role - a user with access to work on the project", + }, project_info_title: { id: "labels.project_info_title", defaultMessage: "Processes", @@ -1941,3 +1953,35 @@ export const course = defineMessages({ defaultMessage: "Restrict Student Projects", }, }); + +export const ownershipErrors = defineMessages({ + LAST_OWNER: { + id: "errors.ownership.last_owner", + defaultMessage: + "Cannot remove owner, because at least one owner is required.", + }, + NOT_OWNER: { + id: "errors.ownership.not_owner", + defaultMessage: "Only project owners can change user types.", + }, + CANNOT_REMOVE_OWNER: { + id: "errors.ownership.cannot_remove_owner", + defaultMessage: "Cannot remove an owner. Demote to collaborator first.", + }, + INVALID_TARGET: { + id: "errors.ownership.invalid_target", + defaultMessage: "Target user is not a member of this project.", + }, + INVALID_USER: { + id: "errors.ownership.invalid_user", + defaultMessage: "User not found or invalid.", + }, + INVALID_REQUESTING_USER: { + id: "errors.ownership.invalid_requesting_user", + defaultMessage: "You are not a project member.", + }, + INVALID_PROJECT_STATE: { + id: "errors.ownership.invalid_project_state", + defaultMessage: "Project data is missing or invalid.", + }, +}); diff --git a/src/packages/frontend/i18n/trans/ar_EG.json b/src/packages/frontend/i18n/trans/ar_EG.json index 73aa13b39a6..d66ce4b3027 100644 --- a/src/packages/frontend/i18n/trans/ar_EG.json +++ b/src/packages/frontend/i18n/trans/ar_EG.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "إدراج الرد الكامل", "codemirror.extensions.ai_formula.title": "توليد صيغة LaTeX", "collaborators.current-collabs.intro": "كل شخص مدرج أدناه يمكنه العمل معك بشكل تعاوني على أي دفتر Jupyter، أو Terminal Linux، أو ملف في هذا المشروع، وإضافة أو إزالة متعاونين آخرين.", + "collaborators.current-collabs.intro2": "يمكن للجميع المذكورين أدناه العمل معك بشكل تعاوني على أي دفتر Jupyter أو محطة Linux أو ملف في هذا المشروع. {manageUsersOnly, select, true { يمكن لمالكي المشروع فقط إضافة أو إزالة المتعاونين.} other { يمكن للمتعاونين أيضًا إضافة أو إزالة متعاونين آخرين.}}", "collaborators.current-collabs.remove_other": "هل أنت متأكد أنك تريد إزالة {user} من هذا المشروع؟ لن يكون لديهم وصول إلى هذا المشروع بعد الآن.", "collaborators.current-collabs.remove_self": "هل أنت متأكد أنك تريد إزالة نفسك من هذا المشروع؟ لن يكون لديك حق الوصول إلى هذا المشروع بعد الآن ولا يمكنك إضافة نفسك مرة أخرى.", + "collaborators.current-collabs.remove.ok_button": "نعم، إزالة {role}", + "collaborators.current-collabs.remove.owner_disabled": "يجب خفض رتبة المالكين قبل أن يتم إزالتهم.", + "collaborators.current-collabs.remove.setting_disabled": "يمكن للمالكين فقط إزالة المتعاونين عند تمكين هذا الإعداد.", "collaborators.current-collabs.title": "المتعاونون الحاليون", "command.format.ai_formula.button": "الصيغة", "command.format.ai_formula.label": "صيغة مولدة بواسطة AI", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "عرض الوثائق لاستخدام محطة لينكس في CoCalc", "editor.toggle_pdf_dark_mode.label": "تبديل وضع PDF الداكن", "editor.toggle_pdf_dark_mode.title": "إيقاف الوضع الداكن لملف PDF لرؤية الملف الأصلي", + "errors.ownership.cannot_remove_owner": "لا يمكن إزالة المالك. قم بتخفيضه إلى متعاون أولاً.", + "errors.ownership.invalid_project_state": "بيانات المشروع مفقودة أو غير صالحة.", + "errors.ownership.invalid_requesting_user": "أنت لست عضوًا في المشروع.", + "errors.ownership.invalid_target": "المستخدم المستهدف ليس عضوًا في هذا المشروع.", + "errors.ownership.invalid_user": "المستخدم غير موجود أو غير صالح.", + "errors.ownership.last_owner": "لا يمكن إزالة المالك، لأن وجود مالك واحد على الأقل مطلوب.", + "errors.ownership.not_owner": "فقط مالكو المشروع يمكنهم تغيير أنواع المستخدمين.", "file_actions.compress.name": "ضغط", "file_actions.copy.name": "نسخ", "file_actions.create.name": "إنشاء", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "أنظمة ملفات السحابة", "labels.cloud_storage_remote_filesystems": "التخزين السحابي وأنظمة الملفات البعيدة", "labels.code_folding": "طي الكود", + "labels.collaborator": "متعاون", "labels.collaborators": "المتعاونون", "labels.color": "لون", "labels.communication": "الاتصال", @@ -1081,6 +1093,7 @@ "labels.open": "افتح", "labels.other": "أخرى", "labels.overview": "نظرة عامة", + "labels.owner": "المالك", "labels.pages": "الصفحات", "labels.paste": "لصق", "labels.pay_as_you_go": "الدفع حسب الاستخدام", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "إعادة تسمية {src}", "project-start-warning.content": "يجب أن تبدأ المشروع \"{project_title}\" قبل أن تتمكن من {what}. {title}", "project-start-warning.title": "بدء هذا المشروع؟", + "project.collaborators.add.owner_only_setting": "فقط مالكو المشروع يمكنهم إضافة المتعاونين عندما يكون تمكين الإدارة لمالكي المشروع فقط.", + "project.collaborators.demote.label": "خفض إلى متعاون", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {لا يمكن تخفيض مالك الحساب الأخير} other {تخفيض هذا المالك إلى متعاون}}", + "project.collaborators.non_owner_note": "يمكن لمالكي المشاريع فقط إدارة أدوار المستخدمين.", + "project.collaborators.promote.label": "ترقية إلى مالك", + "project.collaborators.promote.tooltip": "ترقية هذا المتعاون إلى مالك، مما يمنحهم التحكم الكامل في المشروع", "project.explorer.action-bar.check_all.button": "{checked, select, true {إلغاء تحديد الكل} other {تحديد الكل}}", "project.explorer.action-bar.currently_selected.info": "اضغط على مربع الاختيار إلى يسار الملف لنسخ أو تنزيل أو غير ذلك", "project.explorer.action-bar.currently_selected.items": "{checked} من {total} {items} محدد", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "يفتح الدليل الحالي في مثيل خادم {name}، الذي يعمل داخل هذا المشروع", "project.explorer.search-bar.placeholder": "تصفية الملفات أو \"/\" للمحطة...", "project.explorer.start_project.warning": "لمشاهدة الملفات في هذا الدليل، عليك بدء هذا المشروع", + "project.history.log-entry.change_collaborator_type": "غيّر {target} من {old_group} إلى {new_group}", "project.history.log-entry.invited_user": "المستخدم المدعو", "project.history.log-entry.invited_user_via": "دعا مستخدم جديد عبر", "project.history.log-entry.miniterm": "تم تنفيذ أمر المحطة المصغرة {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "الاسم (اختياري)", "project.settings.about-box.starred.help": "يمكن تصفية المشاريع المميزة بالضغط على زر التصفية المميزة في قائمة مشاريعك.", "project.settings.about-box.title.label": "العنوان", + "project.settings.collaborators.title": "إدارة المتعاونين", "project.settings.compute-image-selector.button.save-restart": "حفظ وإعادة التشغيل", "project.settings.compute-image-selector.doubt": "{default, select, true {هذا هو الاختيار الافتراضي} other {ملاحظة: في حالة الشك، اختر \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "يوفر بيئة البرمجيات جميع البرامج التي يمكن لهذا المشروع الاستفادة منها. إذا كنت بحاجة إلى برامج إضافية، يمكنك إما تثبيتها في المشروع أو الاتصال بالدعم. تعلم عن تثبيت حزم Python، نواة Jupyter لـ Python، حزم R وحزم Julia.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {إظهار المشروع} other {إخفاء المشروع}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {مخفي} other {مرئي}}", "project.settings.hide-delete-box.title": "إخفاء أو حذف المشروع", + "project.settings.manage_users_owner_only": "السماح فقط للمالكين بإدارة المتعاونين.", + "project.settings.manage_users_owner_only.note": "لا يمكن تغيير هذا الإعداد إلا من قبل مالكي المشروع.", + "project.settings.manage_users_owner_only.site_enforced": "هذا الإعداد مفروض من قبل مدير الموقع لجميع المشاريع.", "project.settings.restart-project.button.label": "{is_running, select, true {إعادة التشغيل} other {بدء}}", "project.settings.site-license.body.info": "معلومات حول التراخيص المرفقة. انقر على صف لعرض التفاصيل.", "project.settings.site-license.button.label": "الترقية باستخدام مفتاح الترخيص...", diff --git a/src/packages/frontend/i18n/trans/de_DE.json b/src/packages/frontend/i18n/trans/de_DE.json index 95db74315a1..8fe2cedfcaf 100644 --- a/src/packages/frontend/i18n/trans/de_DE.json +++ b/src/packages/frontend/i18n/trans/de_DE.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Antwort einfügen", "codemirror.extensions.ai_formula.title": "LaTeX-Formel generieren", "collaborators.current-collabs.intro": "Jeder, der unten aufgeführt ist, kann mit Ihnen an jedem Jupyter-Notebook, Linux-Terminal oder jeder Datei in diesem Projekt zusammenarbeiten und andere Mitwirkende hinzufügen oder entfernen.", + "collaborators.current-collabs.intro2": "Jeder, der unten aufgeführt ist, kann mit Ihnen an jedem Jupyter Notebook, Linux-Terminal oder jeder Datei in diesem Projekt zusammenarbeiten. {manageUsersOnly, select, true { Nur Projekteigentümer können Mitarbeiter hinzufügen oder entfernen.} other { Mitarbeiter können auch andere Mitarbeiter hinzufügen oder entfernen.}}", "collaborators.current-collabs.remove_other": "Sind Sie sicher, dass Sie {user} aus diesem Projekt entfernen möchten? Sie werden keinen Zugriff mehr auf dieses Projekt haben.", "collaborators.current-collabs.remove_self": "Sind Sie sicher, dass Sie sich selbst aus diesem Projekt entfernen möchten? Sie werden keinen Zugriff mehr auf dieses Projekt haben und können sich nicht wieder hinzufügen.", + "collaborators.current-collabs.remove.ok_button": "Ja, {role} entfernen", + "collaborators.current-collabs.remove.owner_disabled": "Eigentümer müssen herabgestuft werden, bevor sie entfernt werden können.", + "collaborators.current-collabs.remove.setting_disabled": "Nur Eigentümer können Mitarbeiter entfernen, wenn diese Einstellung aktiviert ist.", "collaborators.current-collabs.title": "Aktuelle Mitarbeiter", "command.format.ai_formula.button": "KI-Formel", "command.format.ai_formula.label": "KI-generierte Formel", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Öffne die Dokumentation für die Nutzung des Linux-Terminals in CoCalc.", "editor.toggle_pdf_dark_mode.label": "PDF-Dunkelmodus umschalten", "editor.toggle_pdf_dark_mode.title": "Schalten Sie den Dunkelmodus der PDF aus, um die Originaldatei zu sehen", + "errors.ownership.cannot_remove_owner": "Kann einen Eigentümer nicht entfernen. Zuerst zum Mitarbeiter herabstufen.", + "errors.ownership.invalid_project_state": "Projektdaten fehlen oder sind ungültig.", + "errors.ownership.invalid_requesting_user": "Sie sind kein Projektmitglied.", + "errors.ownership.invalid_target": "Zielbenutzer ist kein Mitglied dieses Projekts.", + "errors.ownership.invalid_user": "Benutzer nicht gefunden oder ungültig.", + "errors.ownership.last_owner": "Eigentümer kann nicht entfernt werden, da mindestens ein Eigentümer erforderlich ist.", + "errors.ownership.not_owner": "Nur Projekteigentümer können Benutzertypen ändern.", "file_actions.compress.name": "Komprimieren", "file_actions.copy.name": "Kopieren", "file_actions.create.name": "Erstellen", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Cloud Dateisystem", "labels.cloud_storage_remote_filesystems": "Cloud-Speicher & entfernte Dateisysteme", "labels.code_folding": "Code-Faltung", + "labels.collaborator": "Mitarbeiter", "labels.collaborators": "Mitarbeiter", "labels.color": "Farbe", "labels.communication": "Kommunikation", @@ -1081,6 +1093,7 @@ "labels.open": "Öffnen", "labels.other": "Andere", "labels.overview": "Übersicht", + "labels.owner": "Eigentümer", "labels.pages": "Seiten", "labels.paste": "Einfügen", "labels.pay_as_you_go": "Bezahlen nach Verbrauch", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "umbenennen {src}", "project-start-warning.content": "Sie müssen das Projekt \"{project_title}\" starten, bevor Sie {what}. {title}", "project-start-warning.title": "Dieses Projekt starten?", + "project.collaborators.add.owner_only_setting": "Nur Projekteigentümer können Mitarbeiter hinzufügen, wenn die Verwaltung nur durch den Eigentümer aktiviert ist.", + "project.collaborators.demote.label": "Herabstufen zum Mitarbeiter", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Kann den letzten Besitzer nicht degradieren} other {Diesen Besitzer zum Mitarbeiter degradieren}}", + "project.collaborators.non_owner_note": "Nur Projekteigentümer können Benutzerrollen verwalten.", + "project.collaborators.promote.label": "Zum Besitzer befördern", + "project.collaborators.promote.tooltip": "Befördern Sie diesen Mitarbeiter zum Eigentümer und geben Sie ihm die volle Projektkontrolle.", "project.explorer.action-bar.check_all.button": "{checked, select, true {Keine auswählen} other {Alle auswählen}}", "project.explorer.action-bar.currently_selected.info": "Klicke links neben einer Datei auf die Checkbox, um sie zu kopieren, herunterzuladen usw.", "project.explorer.action-bar.currently_selected.items": "{checked} von {total} {items} ausgewählt", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Öffnet das aktuelle Verzeichnis in einer {name}-Serverinstanz, die innerhalb dieses Projekts läuft.", "project.explorer.search-bar.placeholder": "Dateien filtern oder \"/\" für Terminal...", "project.explorer.start_project.warning": "Um die Dateien in diesem Verzeichnis zu sehen, müssen Sie dieses Projekt starten.", + "project.history.log-entry.change_collaborator_type": "änderte {target} von {old_group} zu {new_group}", "project.history.log-entry.invited_user": "eingeladener Benutzer", "project.history.log-entry.invited_user_via": "neuen Benutzer eingeladen über", "project.history.log-entry.miniterm": "ausgeführter Mini-Terminal-Befehl {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Name (optional)", "project.settings.about-box.starred.help": "Markierte Projekte können gefiltert werden, indem Sie in Ihrer Projektliste auf die Schaltfläche mit dem Sternfilter klicken.", "project.settings.about-box.title.label": "Titel", + "project.settings.collaborators.title": "Kollaborationsverwaltung", "project.settings.compute-image-selector.button.save-restart": "Speichern und neu starten", "project.settings.compute-image-selector.doubt": "{default, select, true {Dies ist die Standardauswahl} other {Hinweis: im Zweifelsfall wählen Sie \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Eine Softwareumgebung stellt die gesamte Software bereit, die dieses Projekt nutzen kann. Wenn Sie zusätzliche Software benötigen, können Sie sie entweder im Projekt installieren oder den Support kontaktieren. Erfahren Sie mehr über Installation von Python-Paketen, Python Jupyter Kernel, R-Pakete und Julia-Pakete.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Projekt einblenden} other {Projekt ausblenden}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Ausgeblendet} other {Sichtbar}}", "project.settings.hide-delete-box.title": "Projekt ausblenden oder löschen", + "project.settings.manage_users_owner_only": "Nur Eigentümern erlauben, Mitarbeiter zu verwalten.", + "project.settings.manage_users_owner_only.note": "Diese Einstellung kann nur von Projekteigentümern geändert werden.", + "project.settings.manage_users_owner_only.site_enforced": "Diese Einstellung wird vom Site-Administrator für alle Projekte erzwungen.", "project.settings.restart-project.button.label": "{is_running, select, true {Neustarten} other {Starten}}", "project.settings.site-license.body.info": "Informationen über angehängte Lizenzen. Klicken Sie auf eine Zeile, um Details anzuzeigen.", "project.settings.site-license.button.label": "Upgrade mit einem Lizenzschlüssel...", diff --git a/src/packages/frontend/i18n/trans/es_ES.json b/src/packages/frontend/i18n/trans/es_ES.json index 8b11ef9204d..ab9772c9cf5 100644 --- a/src/packages/frontend/i18n/trans/es_ES.json +++ b/src/packages/frontend/i18n/trans/es_ES.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Insertar respuesta completa", "codemirror.extensions.ai_formula.title": "Generar fórmula LaTeX", "collaborators.current-collabs.intro": "Todos los enumerados a continuación pueden trabajar colaborativamente contigo en cualquier Jupyter Notebook, Terminal de Linux o archivo en este proyecto, y agregar o eliminar otros colaboradores.", + "collaborators.current-collabs.intro2": "Todos los enumerados a continuación pueden trabajar de manera colaborativa contigo en cualquier Jupyter Notebook, Terminal de Linux o archivo de este proyecto. {manageUsersOnly, select, true { Solo los propietarios del proyecto pueden añadir o eliminar colaboradores.} other { Los colaboradores también pueden añadir o eliminar a otros colaboradores.}}", "collaborators.current-collabs.remove_other": "¿Estás seguro de que quieres eliminar a {user} de este proyecto? Ya no tendrá acceso a este proyecto.", "collaborators.current-collabs.remove_self": "¿Está seguro de que quiere eliminar su cuenta de este proyecto? Ya no tendrá acceso a este proyecto y no podrá añadirse de nuevo.", + "collaborators.current-collabs.remove.ok_button": "Sí, eliminar {role}", + "collaborators.current-collabs.remove.owner_disabled": "Los propietarios deben ser degradados antes de que puedan ser eliminados.", + "collaborators.current-collabs.remove.setting_disabled": "Solo los propietarios pueden eliminar colaboradores cuando esta configuración está habilitada.", "collaborators.current-collabs.title": "Colaboradores Actuales", "command.format.ai_formula.button": "Fórmula", "command.format.ai_formula.label": "Fórmula Generada por IA", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Mostrar documentación para usar el Terminal de Linux en CoCalc", "editor.toggle_pdf_dark_mode.label": "Alternar modo oscuro de PDF", "editor.toggle_pdf_dark_mode.title": "Desactivar el modo oscuro del PDF para ver el archivo original", + "errors.ownership.cannot_remove_owner": "No se puede eliminar a un propietario. Degradar a colaborador primero.", + "errors.ownership.invalid_project_state": "Los datos del proyecto están faltando o son inválidos.", + "errors.ownership.invalid_requesting_user": "No eres miembro del proyecto.", + "errors.ownership.invalid_target": "El usuario objetivo no es miembro de este proyecto.", + "errors.ownership.invalid_user": "Usuario no encontrado o no válido.", + "errors.ownership.last_owner": "No se puede eliminar al propietario, porque se requiere al menos un propietario.", + "errors.ownership.not_owner": "Solo los propietarios del proyecto pueden cambiar los tipos de usuario.", "file_actions.compress.name": "Comprimir", "file_actions.copy.name": "Copiar", "file_actions.create.name": "Crear", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Sistemas de Archivos en la Nube", "labels.cloud_storage_remote_filesystems": "Almacenamiento en la Nube y Sistemas de Archivos Remotos", "labels.code_folding": "Plegado de Código", + "labels.collaborator": "Colaborador", "labels.collaborators": "Colaboradores", "labels.color": "Color", "labels.communication": "Comunicación", @@ -1081,6 +1093,7 @@ "labels.open": "Abrir", "labels.other": "Otro", "labels.overview": "Visión general", + "labels.owner": "Propietario", "labels.pages": "Páginas", "labels.paste": "Pegar", "labels.pay_as_you_go": "Pago por Uso", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "renombrar {src}", "project-start-warning.content": "Debes iniciar el proyecto \"{project_title}\" antes de que puedas {what}. {title}", "project-start-warning.title": "¿Iniciar este proyecto?", + "project.collaborators.add.owner_only_setting": "Solo los propietarios del proyecto pueden añadir colaboradores cuando la gestión de solo propietarios está habilitada.", + "project.collaborators.demote.label": "Degradar a Colaborador", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {No se puede degradar al último propietario} other {Degradar este propietario a colaborador}}", + "project.collaborators.non_owner_note": "Solo los propietarios del proyecto pueden gestionar los roles de usuario.", + "project.collaborators.promote.label": "Promover a propietario", + "project.collaborators.promote.tooltip": "Promocionar a este colaborador a propietario, dándole control total del proyecto", "project.explorer.action-bar.check_all.button": "{checked, select, true {Desmarcar todo} other {Marcar todo}}", "project.explorer.action-bar.currently_selected.info": "Haga clic en la casilla a la izquierda de un archivo para copiar, descargar, etc.", "project.explorer.action-bar.currently_selected.items": "{checked} de {total} {items} seleccionados", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Abre el directorio actual en una instancia del servidor {name}, que se ejecuta dentro de este proyecto.", "project.explorer.search-bar.placeholder": "Filtrar archivos o \"/\" para terminal...", "project.explorer.start_project.warning": "Para ver los archivos en este directorio, tienes que iniciar este proyecto.", + "project.history.log-entry.change_collaborator_type": "cambió {target} de {old_group} a {new_group}", "project.history.log-entry.invited_user": "usuario invitado", "project.history.log-entry.invited_user_via": "invitó a un nuevo usuario a través de", "project.history.log-entry.miniterm": "ejecutó el comando del mini terminal {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Nombre (opcional)", "project.settings.about-box.starred.help": "Los proyectos destacados se pueden filtrar haciendo clic en el botón de filtro destacado en tu lista de proyectos.", "project.settings.about-box.title.label": "Título", + "project.settings.collaborators.title": "Gestión de colaboradores", "project.settings.compute-image-selector.button.save-restart": "Guardar y Reiniciar", "project.settings.compute-image-selector.doubt": "{default, select, true {Esta es la selección predeterminada} other {Nota: en caso de duda, seleccione \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Un entorno de software proporciona todo el software que este proyecto puede utilizar. Si necesitas software adicional, puedes instalarlo en el proyecto o contactar con soporte. Aprende sobre instalar paquetes de Python, Kernel de Jupyter de Python, Paquetes de R y paquetes de Julia.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Mostrar proyecto} other {Ocultar proyecto}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Oculto} other {Visible}}", "project.settings.hide-delete-box.title": "Ocultar o Eliminar Proyecto", + "project.settings.manage_users_owner_only": "Permitir solo a los propietarios gestionar colaboradores.", + "project.settings.manage_users_owner_only.note": "Este ajuste solo puede ser cambiado por los propietarios del proyecto.", + "project.settings.manage_users_owner_only.site_enforced": "Esta configuración está impuesta por el administrador del sitio para todos los proyectos.", "project.settings.restart-project.button.label": "{is_running, select, true {Reiniciar} other {Iniciar}}", "project.settings.site-license.body.info": "Información sobre las licencias adjuntas. Haz clic en una fila para expandir detalles.", "project.settings.site-license.button.label": "Actualizar con una clave de licencia...", diff --git a/src/packages/frontend/i18n/trans/es_PV.json b/src/packages/frontend/i18n/trans/es_PV.json index 5814b75b346..f5f1fe6ae34 100644 --- a/src/packages/frontend/i18n/trans/es_PV.json +++ b/src/packages/frontend/i18n/trans/es_PV.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Txertatu erantzun osoa", "codemirror.extensions.ai_formula.title": "Sortu LaTeX Formula", "collaborators.current-collabs.intro": "Jarraian zerrendatutako guztiek zurekin lankidetzan aritu daitezke proiektu honetako edozein Jupyter Notebook, Linux Terminal edo fitxategitan, eta beste kolaboratzaile batzuk gehitu edo kendu ditzakete.", + "collaborators.current-collabs.intro2": "Jarraian zerrendatutako pertsona guztiek zurekin elkarlanean aritu daitezke proiektu honetako edozein Jupyter Notebok, Linux Terminal edo fitxategitan. {manageUsersOnly, select, true { Proiektuaren jabeek bakarrik gehi ditzakete edo kendu lankideak.} other { Lankideek ere gehi edo kendu ditzakete beste lankide batzuk.}}", "collaborators.current-collabs.remove_other": "Ziur zaude {user} proiektu honetatik kendu nahi duzula? Ez dute proiektu honetara sarbiderik izango.", "collaborators.current-collabs.remove_self": "Ziur zaude proiektu honetatik zeure burua kendu nahi duzula? Ez duzu proiektu honetara sarbiderik izango eta ezin izango duzu zeure buru barriro gehitu.", + "collaborators.current-collabs.remove.ok_button": "Bai, kendu {role}", + "collaborators.current-collabs.remove.owner_disabled": "Jabeak kendu aurretik jaitsi behar dira.", + "collaborators.current-collabs.remove.setting_disabled": "Ezarpen hau gaituta dagoenean, jabeek bakarrik ken ditzakete lankideak.", "collaborators.current-collabs.title": "Uneko lankideak", "command.format.ai_formula.button": "Formula", "command.format.ai_formula.label": "AI-k Sortutako Formula", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Erakutsi dokumentazioa Linux Terminala CoCalc-en erabiltzeko.", "editor.toggle_pdf_dark_mode.label": "Aldatu PDF Modu Ilunera", "editor.toggle_pdf_dark_mode.title": "PDFaren ilunpeko modua desaktibatu, jatorrizko fitxategia ikusteko", + "errors.ownership.cannot_remove_owner": "Ezin da jabe bat kendu. Lehenik lankide izatera jaitsi.", + "errors.ownership.invalid_project_state": "Proiektuaren datuak falta dira edo baliogabeak dira.", + "errors.ownership.invalid_requesting_user": "Ez zara proiektuaren kide.", + "errors.ownership.invalid_target": "Helburu-erabiltzailea ez da proiektu honetako kidea.", + "errors.ownership.invalid_user": "Erabiltzailea ez da aurkitu edo baliogabea da.", + "errors.ownership.last_owner": "Ezin da jabea kendu, gutxienez jabe bat beharrezkoa baita.", + "errors.ownership.not_owner": "Proiektuaren jabeek bakarrik alda dezakete erabiltzaileen motak.", "file_actions.compress.name": "Konprimitu", "file_actions.copy.name": "Kopiatu", "file_actions.create.name": "Sortu", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Hodeiko Fitxategi Sistemak", "labels.cloud_storage_remote_filesystems": "Hodeiko Biltegiratzea eta Urruneko Fitxategi Sistemak", "labels.code_folding": "Kode Tolestea", + "labels.collaborator": "Lankide", "labels.collaborators": "Lankideak", "labels.color": "Kolore", "labels.communication": "Komunikazioa", @@ -1081,6 +1093,7 @@ "labels.open": "Ireki", "labels.other": "Beste", "labels.overview": "Orokorra", + "labels.owner": "Jabea", "labels.pages": "Orriak", "labels.paste": "Itsatsi", "labels.pay_as_you_go": "Ordaindu Erabiltzen Duzun Neurrira", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "berrizendatu {src}", "project-start-warning.content": "Proiektua hasi behar duzu \"{project_title}\" {what} baino lehen. {title}", "project-start-warning.title": "Hasi proiektu hau?", + "project.collaborators.add.owner_only_setting": "Jabe bakarrak proiektuaren lankideak gehi ditzake jabearen kudeaketa bakarrik gaituta dagoenean.", + "project.collaborators.demote.label": "Jaitsi Kolaboratzailera", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Ezin da azken jabea jaitsi} other {Jabe hau kolaboratzaile bihurtu}}", + "project.collaborators.non_owner_note": "Proiektuaren jabeek bakarrik kudeatu ditzakete erabiltzaileen rolak.", + "project.collaborators.promote.label": "Jabe bihurtu", + "project.collaborators.promote.tooltip": "Kolaboratzaile hau jabe bihurtu, proiektuaren kontrol osoa emanez", "project.explorer.action-bar.check_all.button": "{checked, select, true {Desmarkatu denak} other {Markatu denak}}", "project.explorer.action-bar.currently_selected.info": "Egin klik fitxategi baten ezkerrean dagoen kontrol-laukian kopiatzeko, deskargatzeko, etab.", "project.explorer.action-bar.currently_selected.items": "{checked} {total} {items} hautatuta", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Irekitzen du uneko direktorioa {name} zerbitzari instantzia batean, proiektu honen barruan exekutatzen.", "project.explorer.search-bar.placeholder": "Fitxategiak iragazi edo \"/\" terminalerako...", "project.explorer.start_project.warning": "Direktorio honetako fitxategiak ikusteko, proiektu hau hasi behar duzu.", + "project.history.log-entry.change_collaborator_type": "aldatu {target} {old_group} tik {new_group} ra", "project.history.log-entry.invited_user": "gonbidatutako erabiltzailea", "project.history.log-entry.invited_user_via": "gonbidatu berri bat bidali bidez", "project.history.log-entry.miniterm": "exekutatu mini terminaleko komandoa {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Izena (aukerakoa)", "project.settings.about-box.starred.help": "Izar-markatutako proiektuak iragazi daitezke izar-markatutako iragazkiaren botoian klik eginez zure proiektuen zerrendan.", "project.settings.about-box.title.label": "Izenburua", + "project.settings.collaborators.title": "Kolaboratzaileen Kudeaketa", "project.settings.compute-image-selector.button.save-restart": "Gorde eta Berrabiarazi", "project.settings.compute-image-selector.doubt": "{default, select, true {Hau da lehenetsitako hautapena} other {Oharra: zalantzan, hautatu \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Software ingurune batek proiektu honek erabil dezakeen software guztia eskaintzen du. Software gehigarria behar baduzu, proiektuan instalatu dezakezu edo laguntzarekin harremanetan jarri. Python paketeak instalatzea, Python Jupyter Kernel, R paketeak eta Julia paketeak ikasi.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Proiektua Bistaratu} other {Proiektua Ezkutatu}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Ezkutua} other {Ikusgai}}", "project.settings.hide-delete-box.title": "Ezkutatu edo Ezabatu Proiektua", + "project.settings.manage_users_owner_only": "Jabeei bakarrik utzi lankideak kudeatzen.", + "project.settings.manage_users_owner_only.note": "Ezarpen hau proiektuaren jabeek soilik alda dezakete.", + "project.settings.manage_users_owner_only.site_enforced": "Ezarpen hau gune-administratzaileak proiektu guztientzat ezartzen du.", "project.settings.restart-project.button.label": "{is_running, select, true {Berrabiarazi} other {Hasi}}", "project.settings.site-license.body.info": "Informazioa erantsitako lizentziei buruz. Xehetasunak zabaltzeko, klik egin errenkadan.", "project.settings.site-license.button.label": "Eguneratu lizentzia-gako bat erabiliz...", diff --git a/src/packages/frontend/i18n/trans/fr_FR.json b/src/packages/frontend/i18n/trans/fr_FR.json index 0ad050e6d0d..fd3d5ea54e3 100644 --- a/src/packages/frontend/i18n/trans/fr_FR.json +++ b/src/packages/frontend/i18n/trans/fr_FR.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Insérer la réponse complète", "codemirror.extensions.ai_formula.title": "Générer une formule LaTeX", "collaborators.current-collabs.intro": "Toutes les personnes listées ci-dessous peuvent collaborer avec vous sur n'importe quel Jupyter Notebook, Terminal Linux ou fichier dans ce projet, et ajouter ou supprimer d'autres collaborateurs.", + "collaborators.current-collabs.intro2": "Tout le monde listé ci-dessous peut collaborer avec vous sur n'importe quel Jupyter Notebook, Terminal Linux ou fichier dans ce projet. {manageUsersOnly, select, true { Seuls les propriétaires du projet peuvent ajouter ou supprimer des collaborateurs.} other { Les collaborateurs peuvent également ajouter ou supprimer d'autres collaborateurs.}}", "collaborators.current-collabs.remove_other": "Êtes-vous sûr de vouloir retirer {user} de ce projet ? Ils n'auront plus accès à ce projet.", "collaborators.current-collabs.remove_self": "Êtes-vous sûr de vouloir vous retirer de ce projet ? Vous n'aurez plus accès à ce projet et ne pourrez pas vous y ajouter de nouveau.", + "collaborators.current-collabs.remove.ok_button": "Oui, supprimer {role}", + "collaborators.current-collabs.remove.owner_disabled": "Les propriétaires doivent être rétrogradés avant de pouvoir être supprimés.", + "collaborators.current-collabs.remove.setting_disabled": "Seuls les propriétaires peuvent supprimer des collaborateurs lorsque ce paramètre est activé.", "collaborators.current-collabs.title": "Collaborateurs actuels", "command.format.ai_formula.button": "Formule", "command.format.ai_formula.label": "Formule Générée par AI", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Afficher la documentation pour utiliser le terminal Linux dans CoCalc.", "editor.toggle_pdf_dark_mode.label": "Basculer le mode sombre PDF", "editor.toggle_pdf_dark_mode.title": "Désactiver le mode sombre du PDF pour voir le fichier original", + "errors.ownership.cannot_remove_owner": "Impossible de supprimer un propriétaire. Rétrogradez-le d'abord en collaborateur.", + "errors.ownership.invalid_project_state": "Les données du projet sont manquantes ou invalides.", + "errors.ownership.invalid_requesting_user": "Vous n'êtes pas membre du projet.", + "errors.ownership.invalid_target": "L'utilisateur cible n'est pas membre de ce projet.", + "errors.ownership.invalid_user": "Utilisateur non trouvé ou invalide.", + "errors.ownership.last_owner": "Impossible de supprimer le propriétaire, car au moins un propriétaire est requis.", + "errors.ownership.not_owner": "Seuls les propriétaires de projet peuvent modifier les types d'utilisateur.", "file_actions.compress.name": "Compresser", "file_actions.copy.name": "Copier", "file_actions.create.name": "Créer", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Systèmes de fichiers en cloud", "labels.cloud_storage_remote_filesystems": "Stockage Cloud & Systèmes de Fichiers à Distance", "labels.code_folding": "Repliage de code", + "labels.collaborator": "Collaborateur", "labels.collaborators": "Collaborateurs", "labels.color": "Couleur", "labels.communication": "Communication", @@ -1081,6 +1093,7 @@ "labels.open": "Ouvrir", "labels.other": "Autre", "labels.overview": "Aperçu", + "labels.owner": "Propriétaire", "labels.pages": "Pages", "labels.paste": "Coller", "labels.pay_as_you_go": "Paiement à l'utilisation", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "renommer {src}", "project-start-warning.content": "Vous devez démarrer le projet \"{project_title}\" avant de pouvoir {what}. {title}", "project-start-warning.title": "Démarrer ce projet ?", + "project.collaborators.add.owner_only_setting": "Seuls les propriétaires de projet peuvent ajouter des collaborateurs lorsque la gestion réservée aux propriétaires est activée.", + "project.collaborators.demote.label": "Rétrograder en Collaborateur", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Impossible de rétrograder le dernier propriétaire} other {Rétrograder ce propriétaire à collaborateur}}", + "project.collaborators.non_owner_note": "Seuls les propriétaires de projet peuvent gérer les rôles des utilisateurs.", + "project.collaborators.promote.label": "Promouvoir au propriétaire", + "project.collaborators.promote.tooltip": "Promouvoir ce collaborateur au statut de propriétaire, en lui donnant le contrôle total du projet", "project.explorer.action-bar.check_all.button": "{checked, select, true {Tout décocher} other {Tout cocher}}", "project.explorer.action-bar.currently_selected.info": "Cliquez sur la case à cocher à gauche d'un fichier pour copier, télécharger, etc.", "project.explorer.action-bar.currently_selected.items": "{checked} sur {total} {items} sélectionnés", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Ouvre le répertoire actuel dans une instance de serveur {name}, exécutée à l'intérieur de ce projet", "project.explorer.search-bar.placeholder": "Filtrer les fichiers ou \"/\" pour le terminal...", "project.explorer.start_project.warning": "Pour voir les fichiers dans ce répertoire, vous devez démarrer ce projet.", + "project.history.log-entry.change_collaborator_type": "a changé {target} de {old_group} à {new_group}", "project.history.log-entry.invited_user": "utilisateur invité", "project.history.log-entry.invited_user_via": "invité un nouvel utilisateur via", "project.history.log-entry.miniterm": "commande mini terminal exécutée {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Nom (facultatif)", "project.settings.about-box.starred.help": "Les projets étoilés peuvent être filtrés en cliquant sur le bouton de filtre étoilé dans votre liste de projets.", "project.settings.about-box.title.label": "Titre", + "project.settings.collaborators.title": "Gestion des collaborateurs", "project.settings.compute-image-selector.button.save-restart": "Enregistrer et Redémarrer", "project.settings.compute-image-selector.doubt": "{default, select, true {Ceci est la sélection par défaut} other {Remarque : en cas de doute, sélectionnez \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Un environnement logiciel fournit tous les logiciels dont ce projet peut bénéficier. Si vous avez besoin de logiciels supplémentaires, vous pouvez soit les installer dans le projet, soit contacter le support. Apprenez-en plus sur l'installation de packages Python, le noyau Jupyter Python, les packages R et les packages Julia.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Afficher le projet} other {Masquer le projet}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Caché} other {Visible}}", "project.settings.hide-delete-box.title": "Masquer ou Supprimer le Projet", + "project.settings.manage_users_owner_only": "Autoriser uniquement les propriétaires à gérer les collaborateurs.", + "project.settings.manage_users_owner_only.note": "Cette option ne peut être modifiée que par les propriétaires du projet.", + "project.settings.manage_users_owner_only.site_enforced": "Ce paramètre est imposé par l'administrateur du site pour tous les projets.", "project.settings.restart-project.button.label": "{is_running, select, true {Redémarrer} other {Démarrer}}", "project.settings.site-license.body.info": "Informations sur les licences attachées. Cliquez sur une ligne pour afficher les détails.", "project.settings.site-license.button.label": "Mettre à niveau avec une clé de licence...", diff --git a/src/packages/frontend/i18n/trans/he_IL.json b/src/packages/frontend/i18n/trans/he_IL.json index d31a194da5a..8efe806ffa8 100644 --- a/src/packages/frontend/i18n/trans/he_IL.json +++ b/src/packages/frontend/i18n/trans/he_IL.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "הכנס תגובה מלאה", "codemirror.extensions.ai_formula.title": "צור נוסחת LaTeX", "collaborators.current-collabs.intro": "כל מי שמופיע ברשימה למטה יכול לעבוד בשיתוף פעולה איתך על כל Jupyter Notebook, טרמינל לינוקס או קובץ בפרויקט הזה, ולהוסיף או להסיר משתפי פעולה נוספים.", + "collaborators.current-collabs.intro2": "כולם המפורטים למטה יכולים לעבוד איתך בשיתוף פעולה על כל Jupyter Notebook, טרמינל לינוקס או קובץ בפרויקט זה. {manageUsersOnly, select, true {רק בעלי הפרויקט יכולים להוסיף או להסיר משתפי פעולה.} other {משתפי פעולה יכולים גם להוסיף או להסיר משתפי פעולה אחרים.}}", "collaborators.current-collabs.remove_other": "האם אתה בטוח שברצונך להסיר את {user} מפרויקט זה? לא תהיה להם יותר גישה לפרויקט זה.", "collaborators.current-collabs.remove_self": "האם אתה בטוח שברצונך להסיר את עצמך מפרויקט זה? לא תהיה לך גישה לפרויקט זה ולא תוכל להוסיף את עצמך מחדש.", + "collaborators.current-collabs.remove.ok_button": "כן, הסר {role}", + "collaborators.current-collabs.remove.owner_disabled": "יש להוריד דרגה של בעלי המניות לפני שניתן להסיר אותם.", + "collaborators.current-collabs.remove.setting_disabled": "רק בעלי החשבון יכולים להסיר משתפי פעולה כאשר הגדרה זו מופעלת.", "collaborators.current-collabs.title": "משתפי פעולה נוכחיים", "command.format.ai_formula.button": "נוסחה", "command.format.ai_formula.label": "נוסחה שנוצרה על ידי AI", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "הצג תיעוד לשימוש בטרמינל לינוקס ב-CoCalc", "editor.toggle_pdf_dark_mode.label": "החלף מצב כהה עבור PDF", "editor.toggle_pdf_dark_mode.title": "כבה את מצב החושך של ה-PDF, כדי לראות את הקובץ המקורי", + "errors.ownership.cannot_remove_owner": "לא ניתן להסיר בעלים. יש להוריד תחילה לשתף פעולה.", + "errors.ownership.invalid_project_state": "נתוני הפרויקט חסרים או לא תקינים.", + "errors.ownership.invalid_requesting_user": "אינך חבר בפרויקט.", + "errors.ownership.invalid_target": "המשתמש המיועד אינו חבר בפרויקט זה.", + "errors.ownership.invalid_user": "משתמש לא נמצא או לא חוקי.", + "errors.ownership.last_owner": "לא ניתן להסיר את הבעלים, מכיוון שנדרש לפחות בעלים אחד.", + "errors.ownership.not_owner": "רק בעלי הפרויקט יכולים לשנות סוגי משתמשים.", "file_actions.compress.name": "דחוס", "file_actions.copy.name": "העתק", "file_actions.create.name": "צור", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "מערכות קבצים בענן", "labels.cloud_storage_remote_filesystems": "אחסון ענן ומערכות קבצים מרוחקות", "labels.code_folding": "קיפול קוד", + "labels.collaborator": "שותף פעולה", "labels.collaborators": "משתפי פעולה", "labels.color": "צבע", "labels.communication": "תקשורת", @@ -1081,6 +1093,7 @@ "labels.open": "פתח", "labels.other": "אחר", "labels.overview": "סקירה", + "labels.owner": "בעלים", "labels.pages": "דפים", "labels.paste": "הדבק", "labels.pay_as_you_go": "שלם לפי שימוש", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "שנה שם {src}", "project-start-warning.content": "עליך להתחיל את הפרויקט \"{project_title}\" לפני שתוכל {what}. {title}", "project-start-warning.title": "להתחיל את הפרויקט הזה?", + "project.collaborators.add.owner_only_setting": "רק בעלי פרויקט יכולים להוסיף משתפי פעולה כאשר ניהול לבעלים בלבד מופעל.", + "project.collaborators.demote.label": "להוריד מעמד לשותף", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {לא ניתן להוריד את הדרגה של הבעלים האחרון} other {להוריד את הבעלים הזה לדרגת משתף פעולה}}", + "project.collaborators.non_owner_note": "רק בעלי הפרויקט יכולים לנהל תפקידי משתמשים.", + "project.collaborators.promote.label": "קדם לבעלים", + "project.collaborators.promote.tooltip": "קדם את שותף הפעולה הזה לבעלים, ותן לו שליטה מלאה על הפרויקט", "project.explorer.action-bar.check_all.button": "{checked, select, true {בטל סימון הכל} other {סמן הכל}}", "project.explorer.action-bar.currently_selected.info": "לחץ על תיבת הסימון משמאל לקובץ כדי להעתיק, להוריד, וכו'.", "project.explorer.action-bar.currently_selected.items": "{checked} מתוך {total} {items} נבחרו", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "פותח את הספרייה הנוכחית במופע שרת {name}, הפועל בתוך פרויקט זה", "project.explorer.search-bar.placeholder": "סנן קבצים או \"/\" למסוף...", "project.explorer.start_project.warning": "על מנת לראות את הקבצים בתיקייה זו, עליך להתחיל את הפרויקט הזה", + "project.history.log-entry.change_collaborator_type": "שינה {target} מ-{old_group} ל-{new_group}", "project.history.log-entry.invited_user": "משתמש מוזמן", "project.history.log-entry.invited_user_via": "הזמין משתמש חדש באמצעות", "project.history.log-entry.miniterm": "בוצעה פקודת מסוף מיני {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "שם (לא חובה)", "project.settings.about-box.starred.help": "פרויקטים עם כוכבית ניתן לסנן על ידי לחיצה על כפתור המסנן עם כוכבית ברשימת הפרויקטים שלך.", "project.settings.about-box.title.label": "כותרת", + "project.settings.collaborators.title": "ניהול משתפי פעולה", "project.settings.compute-image-selector.button.save-restart": "שמור והפעל מחדש", "project.settings.compute-image-selector.doubt": "{default, select, true {זוהי הבחירה המוגדרת כברירת מחדל} other {הערה: אם יש ספק, בחר \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "סביבת תוכנה מספקת את כל התוכנה שהפרויקט הזה יכול להשתמש בה. אם אתה צריך תוכנה נוספת, אתה יכול להתקין אותה בפרויקט או לפנות לתמיכה. למד על התקנת חבילות Python, ליבת Jupyter של Python, חבילות R ו-חבילות Julia.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {הצג פרויקט} other {הסתר פרויקט}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {מוסתר} other {גלוי}}", "project.settings.hide-delete-box.title": "הסתר או מחק פרויקט", + "project.settings.manage_users_owner_only": "לאפשר רק לבעלים לנהל משתפי פעולה.", + "project.settings.manage_users_owner_only.note": "ניתן לשנות הגדרה זו רק על ידי בעלי הפרויקט.", + "project.settings.manage_users_owner_only.site_enforced": "הגדרה זו נאכפת על ידי מנהל האתר עבור כל הפרויקטים.", "project.settings.restart-project.button.label": "{is_running, select, true {הפעל מחדש} other {הפעל}}", "project.settings.site-license.body.info": "מידע על רישיונות מצורפים. לחץ על שורה כדי להרחיב פרטים.", "project.settings.site-license.button.label": "שדרג באמצעות מפתח רישיון...", diff --git a/src/packages/frontend/i18n/trans/hi_IN.json b/src/packages/frontend/i18n/trans/hi_IN.json index 8ee7b765c72..a62e3f99056 100644 --- a/src/packages/frontend/i18n/trans/hi_IN.json +++ b/src/packages/frontend/i18n/trans/hi_IN.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "पूरा उत्तर डालें", "codemirror.extensions.ai_formula.title": "LaTeX सूत्र उत्पन्न करें", "collaborators.current-collabs.intro": "सभी नीचे सूचीबद्ध लोग आपके साथ इस परियोजना में किसी भी Jupyter Notebook, Linux Terminal या फ़ाइल पर सहयोगात्मक रूप से काम कर सकते हैं, और अन्य सहयोगियों को जोड़ या हटा सकते हैं।", + "collaborators.current-collabs.intro2": "नीचे सूचीबद्ध सभी लोग इस परियोजना में किसी भी Jupyter Notebook, Linux Terminal या फ़ाइल पर आपके साथ सहयोगी रूप से काम कर सकते हैं। {manageUsersOnly, select, true { केवल परियोजना के मालिक सहयोगियों को जोड़ या हटा सकते हैं।} other { सहयोगी भी अन्य सहयोगियों को जोड़ या हटा सकते हैं।}}", "collaborators.current-collabs.remove_other": "क्या आप निश्चित हैं कि आप {user} को इस परियोजना से हटाना चाहते हैं? उन्हें अब इस परियोजना तक पहुंच नहीं होगी।", "collaborators.current-collabs.remove_self": "क्या आप सुनिश्चित हैं कि आप स्वयं को इस प्रोजेक्ट से हटाना चाहते हैं? आपके पास अब इस प्रोजेक्ट तक पहुंच नहीं होगी और आप स्वयं को वापस नहीं जोड़ सकते।", + "collaborators.current-collabs.remove.ok_button": "हां, {role} हटाएं", + "collaborators.current-collabs.remove.owner_disabled": "स्वामियों को हटाने से पहले उन्हें पदावनत किया जाना चाहिए।", + "collaborators.current-collabs.remove.setting_disabled": "केवल मालिक इस सेटिंग के सक्षम होने पर सहयोगियों को हटा सकते हैं।", "collaborators.current-collabs.title": "वर्तमान सहयोगी", "command.format.ai_formula.button": "फॉर्मूला", "command.format.ai_formula.label": "एआई जनरेटेड फॉर्मूला", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "CoCalc में Linux टर्मिनल का उपयोग करने के लिए दस्तावेज़ दिखाएं.", "editor.toggle_pdf_dark_mode.label": "पीडीएफ डार्क मोड टॉगल करें", "editor.toggle_pdf_dark_mode.title": "पीडीएफ के डार्क मोड को बंद करें, मूल फ़ाइल देखने के लिए", + "errors.ownership.cannot_remove_owner": "स्वामी को नहीं हटा सकते। पहले सहयोगी के रूप में पदावनत करें।", + "errors.ownership.invalid_project_state": "प्रोजेक्ट डेटा गायब है या अमान्य है।", + "errors.ownership.invalid_requesting_user": "आप परियोजना सदस्य नहीं हैं।", + "errors.ownership.invalid_target": "लक्षित उपयोगकर्ता इस परियोजना का सदस्य नहीं है।", + "errors.ownership.invalid_user": "उपयोगकर्ता नहीं मिला या अमान्य है।", + "errors.ownership.last_owner": "स्वामी को नहीं हटा सकते, क्योंकि कम से कम एक स्वामी आवश्यक है।", + "errors.ownership.not_owner": "केवल परियोजना के मालिक ही उपयोगकर्ता प्रकार बदल सकते हैं।", "file_actions.compress.name": "संपीड़ित करें", "file_actions.copy.name": "कॉपी", "file_actions.create.name": "बनाएँ", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "क्लाउड फाइल सिस्टम्स", "labels.cloud_storage_remote_filesystems": "क्लाउड स्टोरेज और रिमोट फ़ाइल सिस्टम", "labels.code_folding": "कोड फोल्डिंग", + "labels.collaborator": "सहयोगी", "labels.collaborators": "सहयोगी", "labels.color": "रंग", "labels.communication": "संचार", @@ -1081,6 +1093,7 @@ "labels.open": "खोलें", "labels.other": "अन्य", "labels.overview": "सारांश", + "labels.owner": "मालिक", "labels.pages": "पृष्ठों", "labels.paste": "पेस्ट", "labels.pay_as_you_go": "पे ऐज़ यू गो", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "{src} का नाम बदलें", "project-start-warning.content": "आपको {what} से पहले \"{project_title}\" प्रोजेक्ट शुरू करना होगा। {title}", "project-start-warning.title": "इस परियोजना को शुरू करें?", + "project.collaborators.add.owner_only_setting": "केवल प्रोजेक्ट मालिक ही सहयोगियों को जोड़ सकते हैं जब केवल मालिक प्रबंधन सक्षम होता है।", + "project.collaborators.demote.label": "सहयोगी के रूप में अवनत करें", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {अंतिम मालिक को पदावनत नहीं किया जा सकता} other {इस मालिक को सहयोगी के रूप में पदावनत करें}}", + "project.collaborators.non_owner_note": "केवल परियोजना के मालिक उपयोगकर्ता भूमिकाओं का प्रबंधन कर सकते हैं।", + "project.collaborators.promote.label": "स्वामी के रूप में पदोन्नत करें", + "project.collaborators.promote.tooltip": "इस सहयोगी को स्वामी बनाएं, उन्हें पूर्ण परियोजना नियंत्रण दें", "project.explorer.action-bar.check_all.button": "{checked, select, true {सभी अनचेक करें} other {सभी चेक करें}}", "project.explorer.action-bar.currently_selected.info": "फ़ाइल की प्रतिलिपि बनाने, डाउनलोड करने आदि के लिए बाईं ओर स्थित चेकबॉक्स पर क्लिक करें।", "project.explorer.action-bar.currently_selected.items": "{checked} में से {total} {items} चुने गए", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "वर्तमान निर्देशिका को इस प्रोजेक्ट के अंदर चल रहे एक {name} सर्वर उदाहरण में खोलता है", "project.explorer.search-bar.placeholder": "फ़ाइलों को फ़िल्टर करें या टर्मिनल के लिए \"/\"...", "project.explorer.start_project.warning": "इस निर्देशिका में फाइलें देखने के लिए, आपको इस प्रोजेक्ट को शुरू करना होगा।", + "project.history.log-entry.change_collaborator_type": "{target} को {old_group} से {new_group} में बदला गया", "project.history.log-entry.invited_user": "आमंत्रित उपयोगकर्ता", "project.history.log-entry.invited_user_via": "नए उपयोगकर्ता को आमंत्रित किया गया द्वारा", "project.history.log-entry.miniterm": "{cmd} मिनी टर्मिनल कमांड निष्पादित", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "नाम (वैकल्पिक)", "project.settings.about-box.starred.help": "आपके प्रोजेक्ट सूची में स्टार फ़िल्टर बटन पर क्लिक करके स्टार किए गए प्रोजेक्ट को फ़िल्टर किया जा सकता है।", "project.settings.about-box.title.label": "शीर्षक", + "project.settings.collaborators.title": "सहयोगी प्रबंधन", "project.settings.compute-image-selector.button.save-restart": "सहेजें और पुनः आरंभ करें", "project.settings.compute-image-selector.doubt": "{default, select, true {यह डिफ़ॉल्ट चयन है} other {नोट: संदेह में, \"{default_title}\" चुनें}}", "project.settings.compute-image-selector.software-env-info": "एक सॉफ़्टवेयर वातावरण वह सारी सॉफ़्टवेयर प्रदान करता है, जिसका यह प्रोजेक्ट उपयोग कर सकता है। यदि आपको अतिरिक्त सॉफ़्टवेयर की आवश्यकता है, तो आप इसे प्रोजेक्ट में स्थापित कर सकते हैं या समर्थन से संपर्क कर सकते हैं। Python पैकेज स्थापित करने, Python Jupyter Kernel, R पैकेज और Julia पैकेज के बारे में जानें।", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {प्रोजेक्ट छिपाएं} other {प्रोजेक्ट छिपाएं}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {छिपा हुआ} other {दृश्यमान}}", "project.settings.hide-delete-box.title": "प्रोजेक्ट छिपाएँ या हटाएँ", + "project.settings.manage_users_owner_only": "केवल मालिकों को सहयोगियों का प्रबंधन करने की अनुमति दें।", + "project.settings.manage_users_owner_only.note": "यह सेटिंग केवल प्रोजेक्ट मालिकों द्वारा बदली जा सकती है।", + "project.settings.manage_users_owner_only.site_enforced": "यह सेटिंग साइट व्यवस्थापक द्वारा सभी परियोजनाओं के लिए लागू की गई है।", "project.settings.restart-project.button.label": "{is_running, select, true {पुनः आरंभ करें} other {शुरू करें}}", "project.settings.site-license.body.info": "संलग्न लाइसेंसों की जानकारी। विवरण देखने के लिए एक पंक्ति पर क्लिक करें।", "project.settings.site-license.button.label": "लाइसेंस कुंजी का उपयोग करके अपग्रेड करें...", diff --git a/src/packages/frontend/i18n/trans/hu_HU.json b/src/packages/frontend/i18n/trans/hu_HU.json index 9fbbac33e5d..d24d04b96c4 100644 --- a/src/packages/frontend/i18n/trans/hu_HU.json +++ b/src/packages/frontend/i18n/trans/hu_HU.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Helyezze be a teljes választ", "codemirror.extensions.ai_formula.title": "Generálj LaTeX képletet", "collaborators.current-collabs.intro": "Az alábbi személyek mindegyike együtt dolgozhat Önnel bármely Jupyter Notebookon, Linux Terminálon vagy fájlon ebben a projektben, és más együttműködőket is hozzáadhat vagy eltávolíthat.", + "collaborators.current-collabs.intro2": "Mindenki, aki az alábbi listában szerepel, együttműködhet veled bármely Jupyter Notebookon, Linux Terminálon vagy fájlon ebben a projektben. {manageUsersOnly, select, true { Csak a projekt tulajdonosai adhatnak hozzá vagy távolíthatnak el együttműködőket.} other { Az együttműködők is hozzáadhatnak vagy eltávolíthatnak más együttműködőket.}}", "collaborators.current-collabs.remove_other": "Biztosan el akarja távolítani {user} ezt a projektből? Többé nem fognak hozzáférni ehhez a projekthez.", "collaborators.current-collabs.remove_self": "Biztosan eltávolítja magát ebből a projektből? Többé nem fog hozzáférni ehhez a projekthez, és nem tudja újra hozzáadni magát.", + "collaborators.current-collabs.remove.ok_button": "Igen, távolítsa el a {role}", + "collaborators.current-collabs.remove.owner_disabled": "A tulajdonosokat lefokozni szükséges, mielőtt eltávolíthatók.", + "collaborators.current-collabs.remove.setting_disabled": "Csak a tulajdonosok távolíthatnak el közreműködőket, ha ez a beállítás engedélyezve van.", "collaborators.current-collabs.title": "Jelenlegi Munkatársak", "command.format.ai_formula.button": "Képlet", "command.format.ai_formula.label": "AI által Generált Képlet", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Dokumentáció megjelenítése a Linux terminál használatához a CoCalc-ban.", "editor.toggle_pdf_dark_mode.label": "PDF sötét mód váltása", "editor.toggle_pdf_dark_mode.title": "Kapcsolja ki a PDF sötét módját, hogy lássa az eredeti fájlt", + "errors.ownership.cannot_remove_owner": "Nem lehet eltávolítani egy tulajdonost. Először fokozza le munkatárssá.", + "errors.ownership.invalid_project_state": "A projekt adatai hiányoznak vagy érvénytelenek.", + "errors.ownership.invalid_requesting_user": "Ön nem tagja a projektnek.", + "errors.ownership.invalid_target": "A célfelhasználó nem tagja ennek a projektnek.", + "errors.ownership.invalid_user": "Felhasználó nem található vagy érvénytelen.", + "errors.ownership.last_owner": "Nem lehet eltávolítani a tulajdonost, mert legalább egy tulajdonos szükséges.", + "errors.ownership.not_owner": "Csak a projekt tulajdonosai módosíthatják a felhasználói típusokat.", "file_actions.compress.name": "Tömörít", "file_actions.copy.name": "Másolás", "file_actions.create.name": "Létrehozás", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Felhő fájlrendszerek", "labels.cloud_storage_remote_filesystems": "Felhőtárhely és távoli fájlrendszerek", "labels.code_folding": "Kódrendszer összecsukása", + "labels.collaborator": "Közreműködő", "labels.collaborators": "Munkatársak", "labels.color": "Szín", "labels.communication": "Kommunikáció", @@ -1081,6 +1093,7 @@ "labels.open": "Megnyitás", "labels.other": "Egyéb", "labels.overview": "Áttekintés", + "labels.owner": "Tulajdonos", "labels.pages": "Oldalak", "labels.paste": "Beillesztés", "labels.pay_as_you_go": "Fizess, ahogy használod", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "átnevezés {src}", "project-start-warning.content": "El kell indítania a(z) \"{project_title}\" projektet, mielőtt {what}. {title}", "project-start-warning.title": "Elindítja ezt a projektet?", + "project.collaborators.add.owner_only_setting": "Csak a projekt tulajdonosai adhatnak hozzá munkatársakat, ha be van kapcsolva a csak tulajdonos általi kezelés.", + "project.collaborators.demote.label": "Lefokozás munkatárssá", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Nem lehet lefokozni az utolsó tulajdonost} other {Fokozza le ezt a tulajdonost közreműködővé}}", + "project.collaborators.non_owner_note": "Csak a projekt tulajdonosai kezelhetik a felhasználói szerepeket.", + "project.collaborators.promote.label": "Előléptetés tulajdonossá", + "project.collaborators.promote.tooltip": "Emelje fel ezt a közreműködőt tulajdonossá, hogy teljes projektkontrollt biztosítson neki", "project.explorer.action-bar.check_all.button": "{checked, select, true {Mindet kivenni} other {Mindet bejelölni}}", "project.explorer.action-bar.currently_selected.info": "Kattintson a fájl melletti jelölőnégyzetre a másoláshoz, letöltéshez, stb.", "project.explorer.action-bar.currently_selected.items": "{checked} a {total} {items} közül kiválasztva", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Megnyitja az aktuális könyvtárat egy {name} szerver példányban, amely ebben a projektben fut.", "project.explorer.search-bar.placeholder": "Szűrje a fájlokat vagy \"/\" a terminálhoz...", "project.explorer.start_project.warning": "A fájlok megtekintéséhez ebben a könyvtárban, indítsd el ezt a projektet.", + "project.history.log-entry.change_collaborator_type": "megváltoztatta {target} {old_group}-ról {new_group}-ra", "project.history.log-entry.invited_user": "meghívott felhasználó", "project.history.log-entry.invited_user_via": "új felhasználó meghívása μέσω", "project.history.log-entry.miniterm": "végrehajtott mini terminál parancs {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Név (opcionális)", "project.settings.about-box.starred.help": "A csillagozott projektek szűrhetők a csillagozott szűrő gombra kattintva a projektlistádban.", "project.settings.about-box.title.label": "Cím", + "project.settings.collaborators.title": "Együttműködő kezelése", "project.settings.compute-image-selector.button.save-restart": "Mentés és újraindítás", "project.settings.compute-image-selector.doubt": "{default, select, true {Ez az alapértelmezett kiválasztás} other {Megjegyzés: ha kétségei vannak, válassza a(z) \"{default_title}\" lehetőséget}}", "project.settings.compute-image-selector.software-env-info": "Egy szoftverkörnyezet biztosítja az összes szoftvert, amelyet ez a projekt használhat. Ha további szoftverre van szüksége, telepítheti a projektben, vagy kapcsolatba léphet a támogatással. Tudjon meg többet a Python csomagok telepítése, Python Jupyter Kernel, R csomagok és Julia csomagok témakörben.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Projekt felfedése} other {Projekt elrejtése}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Rejtett} other {Látható}}", "project.settings.hide-delete-box.title": "Rejtés vagy Törlés Projekt", + "project.settings.manage_users_owner_only": "Csak a tulajdonosok kezelhetik a közreműködőket.", + "project.settings.manage_users_owner_only.note": "Ezt a beállítást csak a projekt tulajdonosai módosíthatják.", + "project.settings.manage_users_owner_only.site_enforced": "Ezt a beállítást a webhely rendszergazdája érvényesíti minden projektre.", "project.settings.restart-project.button.label": "{is_running, select, true {Újraindítás} other {Indítás}}", "project.settings.site-license.body.info": "Információ a csatolt licencekről. Kattintson egy sorra a részletek kibontásához.", "project.settings.site-license.button.label": "Frissítés licenckulccsal...", diff --git a/src/packages/frontend/i18n/trans/it_IT.json b/src/packages/frontend/i18n/trans/it_IT.json index 887b4059fe2..f55dba9306a 100644 --- a/src/packages/frontend/i18n/trans/it_IT.json +++ b/src/packages/frontend/i18n/trans/it_IT.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Inserisci risposta completa", "codemirror.extensions.ai_formula.title": "Genera Formula LaTeX", "collaborators.current-collabs.intro": "Tutti quelli elencati di seguito possono lavorare collaborativamente con te su qualsiasi Jupyter Notebook, Terminale Linux o file in questo progetto, e aggiungere o rimuovere altri collaboratori.", + "collaborators.current-collabs.intro2": "Tutti gli utenti elencati di seguito possono lavorare con te in modo collaborativo su qualsiasi Jupyter Notebook, Terminale Linux o file in questo progetto. {manageUsersOnly, select, true { Solo i proprietari del progetto possono aggiungere o rimuovere collaboratori.} other { Anche i collaboratori possono aggiungere o rimuovere altri collaboratori.}}", "collaborators.current-collabs.remove_other": "Sei sicuro di voler rimuovere {user} da questo progetto? Non avranno più accesso a questo progetto.", "collaborators.current-collabs.remove_self": "Sei sicuro di voler rimuovere te stesso da questo progetto? Non avrai più accesso a questo progetto e non potrai aggiungerti di nuovo.", + "collaborators.current-collabs.remove.ok_button": "Sì, rimuovi {role}", + "collaborators.current-collabs.remove.owner_disabled": "I proprietari devono essere retrocessi prima di poter essere rimossi.", + "collaborators.current-collabs.remove.setting_disabled": "Solo i proprietari possono rimuovere i collaboratori quando questa impostazione è attivata.", "collaborators.current-collabs.title": "Collaboratori attuali", "command.format.ai_formula.button": "Formula", "command.format.ai_formula.label": "Formula Generata dall'AI", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Mostra la documentazione per l'utilizzo del Terminale Linux in CoCalc.", "editor.toggle_pdf_dark_mode.label": "Attiva/disattiva modalità scura PDF", "editor.toggle_pdf_dark_mode.title": "Disattiva la modalità scura del PDF per vedere il file originale", + "errors.ownership.cannot_remove_owner": "Impossibile rimuovere un proprietario. Declassalo a collaboratore prima.", + "errors.ownership.invalid_project_state": "I dati del progetto sono mancanti o non validi.", + "errors.ownership.invalid_requesting_user": "Non sei un membro del progetto.", + "errors.ownership.invalid_target": "L'utente di destinazione non è un membro di questo progetto.", + "errors.ownership.invalid_user": "Utente non trovato o non valido.", + "errors.ownership.last_owner": "Impossibile rimuovere il proprietario, perché è richiesto almeno un proprietario.", + "errors.ownership.not_owner": "Solo i proprietari del progetto possono cambiare i tipi di utente.", "file_actions.compress.name": "Comprimi", "file_actions.copy.name": "Copia", "file_actions.create.name": "Crea", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Sistemi di File Cloud", "labels.cloud_storage_remote_filesystems": "Archiviazione Cloud & Sistemi di File Remoti", "labels.code_folding": "Ripiegamento del codice", + "labels.collaborator": "Collaboratore", "labels.collaborators": "Collaboratori", "labels.color": "Colore", "labels.communication": "Comunicazione", @@ -1081,6 +1093,7 @@ "labels.open": "Apri", "labels.other": "Altro", "labels.overview": "Panoramica", + "labels.owner": "Proprietario", "labels.pages": "Pagine", "labels.paste": "Incolla", "labels.pay_as_you_go": "Paga a Consumo", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "rinomina {src}", "project-start-warning.content": "Devi avviare il progetto \"{project_title}\" prima di poter {what}. {title}", "project-start-warning.title": "Avviare questo progetto?", + "project.collaborators.add.owner_only_setting": "Solo i proprietari del progetto possono aggiungere collaboratori quando la gestione riservata ai proprietari è abilitata.", + "project.collaborators.demote.label": "Retrocedi a Collaboratore", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Non è possibile declassare l'ultimo proprietario} other {Declassa questo proprietario a collaboratore}}", + "project.collaborators.non_owner_note": "Solo i proprietari del progetto possono gestire i ruoli degli utenti.", + "project.collaborators.promote.label": "Promuovi a Proprietario", + "project.collaborators.promote.tooltip": "Promuovi questo collaboratore a proprietario, dando loro il pieno controllo del progetto", "project.explorer.action-bar.check_all.button": "{checked, select, true {Deseleziona tutto} other {Seleziona tutto}}", "project.explorer.action-bar.currently_selected.info": "Fai clic sulla casella di controllo a sinistra di un file per copiare, scaricare, ecc.", "project.explorer.action-bar.currently_selected.items": "{checked} di {total} {items} selezionati", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Apre la directory corrente in un'istanza del server {name}, in esecuzione all'interno di questo progetto.", "project.explorer.search-bar.placeholder": "Filtra file o \"/\" per terminale...", "project.explorer.start_project.warning": "Per vedere i file in questa directory, devi avviare questo progetto.", + "project.history.log-entry.change_collaborator_type": "cambiato {target} da {old_group} a {new_group}", "project.history.log-entry.invited_user": "utente invitato", "project.history.log-entry.invited_user_via": "ha invitato un nuovo utente tramite", "project.history.log-entry.miniterm": "eseguito mini comando terminale {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Nome (opzionale)", "project.settings.about-box.starred.help": "I progetti con stella possono essere filtrati cliccando sul pulsante di filtro con stella nella tua lista di progetti.", "project.settings.about-box.title.label": "Titolo", + "project.settings.collaborators.title": "Gestione dei collaboratori", "project.settings.compute-image-selector.button.save-restart": "Salva e Riavvia", "project.settings.compute-image-selector.doubt": "{default, select, true {Questa è la selezione predefinita} other {Nota: in caso di dubbio, selezionare \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Un ambiente software fornisce tutto il software di cui questo progetto può usufruire. Se hai bisogno di software aggiuntivo, puoi installarlo nel progetto o contattare il supporto. Scopri di più su installare pacchetti Python, Python Jupyter Kernel, pacchetti R e pacchetti Julia.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Mostra Progetto} other {Nascondi Progetto}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Nascosto} other {Visibile}}", "project.settings.hide-delete-box.title": "Nascondi o Elimina Progetto", + "project.settings.manage_users_owner_only": "Consenti solo ai proprietari di gestire i collaboratori.", + "project.settings.manage_users_owner_only.note": "Questa impostazione può essere modificata solo dai proprietari del progetto.", + "project.settings.manage_users_owner_only.site_enforced": "Questa impostazione è imposta dall'amministratore del sito per tutti i progetti.", "project.settings.restart-project.button.label": "{is_running, select, true {Riavvia} other {Avvia}}", "project.settings.site-license.body.info": "Informazioni sulle licenze allegate. Fai clic su una riga per espandere i dettagli.", "project.settings.site-license.button.label": "Aggiorna utilizzando una chiave di licenza...", diff --git a/src/packages/frontend/i18n/trans/ja_JP.json b/src/packages/frontend/i18n/trans/ja_JP.json index 432dbee4339..0417f3ae7ef 100644 --- a/src/packages/frontend/i18n/trans/ja_JP.json +++ b/src/packages/frontend/i18n/trans/ja_JP.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "返信を挿入", "codemirror.extensions.ai_formula.title": "LaTeX 公式を生成", "collaborators.current-collabs.intro": "以下に記載された全員が、このプロジェクト内の任意のJupyter Notebook、Linux Terminal、またはファイルであなたと共同作業を行い、他の共同作業者を追加または削除することができます。", + "collaborators.current-collabs.intro2": "以下に記載されているすべての人が、このプロジェクト内の任意のJupyter Notebook、Linux Terminal、またはファイルで共同作業を行うことができます。{manageUsersOnly, select, true { プロジェクトの所有者のみがコラボレーターを追加または削除できます。} other { コラボレーターも他のコラボレーターを追加または削除できます。}}", "collaborators.current-collabs.remove_other": "{user} をこのプロジェクトから削除してもよろしいですか?このプロジェクトへのアクセスができなくなります。", "collaborators.current-collabs.remove_self": "本当に自分をこのプロジェクトから削除しますか?このプロジェクトへのアクセスができなくなり、自分を再追加することはできません。", + "collaborators.current-collabs.remove.ok_button": "はい、{role}を削除します", + "collaborators.current-collabs.remove.owner_disabled": "所有者は削除される前に降格される必要があります。", + "collaborators.current-collabs.remove.setting_disabled": "この設定が有効な場合、コラボレーターを削除できるのは所有者のみです。", "collaborators.current-collabs.title": "現在の共同作業者", "command.format.ai_formula.button": "数式", "command.format.ai_formula.label": "AI生成式", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "CoCalcでLinuxターミナルを使用するためのドキュメントを表示。", "editor.toggle_pdf_dark_mode.label": "PDFダークモードを切り替え", "editor.toggle_pdf_dark_mode.title": "PDFのダークモードをオフにして、元のファイルを表示", + "errors.ownership.cannot_remove_owner": "オーナーを削除できません。まず共同作業者に降格してください。", + "errors.ownership.invalid_project_state": "プロジェクトデータが見つからないか無効です。", + "errors.ownership.invalid_requesting_user": "あなたはプロジェクトメンバーではありません。", + "errors.ownership.invalid_target": "対象ユーザーはこのプロジェクトのメンバーではありません。", + "errors.ownership.invalid_user": "ユーザーが見つからないか無効です。", + "errors.ownership.last_owner": "所有者を削除できません。少なくとも1人の所有者が必要です。", + "errors.ownership.not_owner": "プロジェクト所有者のみがユーザータイプを変更できます。", "file_actions.compress.name": "圧縮", "file_actions.copy.name": "コピー", "file_actions.create.name": "作成", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "クラウドファイルシステム", "labels.cloud_storage_remote_filesystems": "クラウドストレージとリモートファイルシステム", "labels.code_folding": "コードフォールディング", + "labels.collaborator": "共同作業者", "labels.collaborators": "共同作業者", "labels.color": "色", "labels.communication": "通信", @@ -1081,6 +1093,7 @@ "labels.open": "開く", "labels.other": "その他", "labels.overview": "概要", + "labels.owner": "所有者", "labels.pages": "ページ", "labels.paste": "貼り付け", "labels.pay_as_you_go": "従量課金", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "{src} を名前変更", "project-start-warning.content": "プロジェクト \"{project_title}\" を開始する必要があります。その前に {what} はできません。{title}", "project-start-warning.title": "このプロジェクトを開始しますか?", + "project.collaborators.add.owner_only_setting": "オーナー限定の管理が有効な場合、プロジェクトオーナーのみが共同作業者を追加できます。", + "project.collaborators.demote.label": "共同作業者に降格", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {最後の所有者を降格することはできません} other {この所有者を共同作業者に降格}}", + "project.collaborators.non_owner_note": "プロジェクトのオーナーのみがユーザーの役割を管理できます。", + "project.collaborators.promote.label": "オーナーに昇格", + "project.collaborators.promote.tooltip": "このコラボレーターをオーナーに昇格し、プロジェクトのフルコントロールを与える", "project.explorer.action-bar.check_all.button": "{checked, select, true {すべてのチェックを外す} other {すべてをチェックする}}", "project.explorer.action-bar.currently_selected.info": "ファイルの左にあるチェックボックスをクリックして、コピーやダウンロードなどを行います。", "project.explorer.action-bar.currently_selected.items": "{checked} 個の {total} 個の {items} が選択されました", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "このプロジェクト内で実行中の{name}サーバーインスタンスで現在のディレクトリを開きます。", "project.explorer.search-bar.placeholder": "ファイルをフィルターまたは\"/\"でターミナル...", "project.explorer.start_project.warning": "このディレクトリ内のファイルを見るには、このプロジェクトを開始する必要があります。", + "project.history.log-entry.change_collaborator_type": "{target}を{old_group}から{new_group}に変更しました", "project.history.log-entry.invited_user": "招待されたユーザー", "project.history.log-entry.invited_user_via": "経由で新しいユーザーを招待", "project.history.log-entry.miniterm": "{cmd} ミニターミナルコマンドを実行", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "名前(任意)", "project.settings.about-box.starred.help": "スター付きプロジェクトは、プロジェクトリストでスター付きフィルターボタンをクリックするとフィルタリングできます。", "project.settings.about-box.title.label": "タイトル", + "project.settings.collaborators.title": "共同作業者管理", "project.settings.compute-image-selector.button.save-restart": "保存して再起動", "project.settings.compute-image-selector.doubt": "{default, select, true {これはデフォルトの選択です} other {注: 迷った場合は「{default_title}」を選択してください}}", "project.settings.compute-image-selector.software-env-info": "ソフトウェア環境は、このプロジェクトが利用できるすべてのソフトウェアを提供します。追加のソフトウェアが必要な場合は、プロジェクトにインストールするか、サポートに連絡してください。PythonパッケージのインストールPython JupyterカーネルRパッケージJuliaパッケージについて学びましょう。", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {プロジェクトを表示} other {プロジェクトを非表示}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {非表示} other {表示}}", "project.settings.hide-delete-box.title": "プロジェクトを非表示または削除", + "project.settings.manage_users_owner_only": "所有者のみが共同作業者を管理できるようにする。", + "project.settings.manage_users_owner_only.note": "この設定はプロジェクトオーナーのみが変更できます。", + "project.settings.manage_users_owner_only.site_enforced": "この設定は、すべてのプロジェクトに対してサイト管理者によって強制されています。", "project.settings.restart-project.button.label": "{is_running, select, true {再起動} other {開始}}", "project.settings.site-license.body.info": "添付されたライセンスに関する情報。行をクリックして詳細を表示します。", "project.settings.site-license.button.label": "ライセンスキーを使用してアップグレード...", diff --git a/src/packages/frontend/i18n/trans/ko_KR.json b/src/packages/frontend/i18n/trans/ko_KR.json index 8af44511097..21b0cd49a4a 100644 --- a/src/packages/frontend/i18n/trans/ko_KR.json +++ b/src/packages/frontend/i18n/trans/ko_KR.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "전체 답장 삽입", "codemirror.extensions.ai_formula.title": "LaTeX 수식 생성", "collaborators.current-collabs.intro": "아래에 나열된 모든 사람들은 이 프로젝트의 Jupyter Notebook, Linux Terminal 또는 파일에서 여러분과 공동 작업을 할 수 있으며, 다른 협업자를 추가하거나 제거할 수 있습니다.", + "collaborators.current-collabs.intro2": "아래 나열된 모든 사람들은 이 프로젝트의 Jupyter Notebook, Linux Terminal 또는 파일에서 협업할 수 있습니다. {manageUsersOnly, select, true {프로젝트 소유자만 협력자를 추가하거나 제거할 수 있습니다.} other {협력자도 다른 협력자를 추가하거나 제거할 수 있습니다.}}", "collaborators.current-collabs.remove_other": "이 사용자를 이 프로젝트에서 제거하시겠습니까? 더 이상 이 프로젝트에 접근할 수 없습니다.", "collaborators.current-collabs.remove_self": "이 프로젝트에서 본인을 제거하시겠습니까? 이 프로젝트에 더 이상 접근할 수 없으며 본인을 다시 추가할 수 없습니다.", + "collaborators.current-collabs.remove.ok_button": "예, {role} 제거", + "collaborators.current-collabs.remove.owner_disabled": "소유자는 제거되기 전에 강등되어야 합니다.", + "collaborators.current-collabs.remove.setting_disabled": "이 설정이 활성화되면 소유자만 협업자를 제거할 수 있습니다.", "collaborators.current-collabs.title": "현재 협력자", "command.format.ai_formula.button": "수식", "command.format.ai_formula.label": "AI 생성 공식", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "CoCalc에서 Linux 터미널 사용 설명서 보기", "editor.toggle_pdf_dark_mode.label": "PDF 다크 모드 전환", "editor.toggle_pdf_dark_mode.title": "PDF의 다크 모드를 끄고 원본 파일을 확인하세요", + "errors.ownership.cannot_remove_owner": "소유자를 제거할 수 없습니다. 먼저 협력자로 강등하세요.", + "errors.ownership.invalid_project_state": "프로젝트 데이터가 누락되었거나 유효하지 않습니다.", + "errors.ownership.invalid_requesting_user": "프로젝트 멤버가 아닙니다.", + "errors.ownership.invalid_target": "대상 사용자는 이 프로젝트의 멤버가 아닙니다.", + "errors.ownership.invalid_user": "사용자를 찾을 수 없거나 잘못되었습니다.", + "errors.ownership.last_owner": "소유자를 제거할 수 없습니다. 최소한 한 명의 소유자가 필요합니다.", + "errors.ownership.not_owner": "프로젝트 소유자만 사용자 유형을 변경할 수 있습니다.", "file_actions.compress.name": "압축", "file_actions.copy.name": "복사", "file_actions.create.name": "생성", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "클라우드 파일 시스템", "labels.cloud_storage_remote_filesystems": "클라우드 저장소 & 원격 파일 시스템", "labels.code_folding": "코드 접기", + "labels.collaborator": "협력자", "labels.collaborators": "협력자", "labels.color": "색상", "labels.communication": "소통", @@ -1081,6 +1093,7 @@ "labels.open": "열기", "labels.other": "기타", "labels.overview": "개요", + "labels.owner": "소유자", "labels.pages": "페이지", "labels.paste": "붙여넣기", "labels.pay_as_you_go": "사용한 만큼 지불하세요", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "{src} 이름 변경", "project-start-warning.content": "프로젝트 \"{project_title}\"를 시작해야 {what} 할 수 있습니다. {title}", "project-start-warning.title": "이 프로젝트를 시작하시겠습니까?", + "project.collaborators.add.owner_only_setting": "소유자 전용 관리가 활성화된 경우 프로젝트 소유자만 공동 작업자를 추가할 수 있습니다.", + "project.collaborators.demote.label": "협력자로 강등", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {마지막 소유자를 강등할 수 없습니다} other {이 소유자를 협력자로 강등}}", + "project.collaborators.non_owner_note": "프로젝트 소유자만 사용자 역할을 관리할 수 있습니다.", + "project.collaborators.promote.label": "소유자로 승격", + "project.collaborators.promote.tooltip": "이 협력자를 소유자로 승격하여 전체 프로젝트 제어 권한 부여", "project.explorer.action-bar.check_all.button": "{checked, select, true {모두 선택 해제} other {모두 선택}}", "project.explorer.action-bar.currently_selected.info": "파일 왼쪽의 체크박스를 클릭하여 복사, 다운로드 등을 수행하세요.", "project.explorer.action-bar.currently_selected.items": "{checked}개 중 {total}개의 {items} 선택됨", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "현재 디렉토리를 이 프로젝트 내부에서 실행 중인 {name} 서버 인스턴스에서 엽니다.", "project.explorer.search-bar.placeholder": "파일 필터 또는 \"/\"로 터미널...", "project.explorer.start_project.warning": "이 디렉터리의 파일을 보려면 이 프로젝트를 시작해야 합니다.", + "project.history.log-entry.change_collaborator_type": "{target}을(를) {old_group}에서 {new_group}(으)로 변경했습니다", "project.history.log-entry.invited_user": "초대된 사용자", "project.history.log-entry.invited_user_via": "새 사용자 초대 via", "project.history.log-entry.miniterm": "미니 터미널 명령 {cmd} 실행됨", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "이름 (선택 사항)", "project.settings.about-box.starred.help": "별표 표시된 프로젝트는 프로젝트 목록에서 별표 필터 버튼을 클릭하여 필터링할 수 있습니다.", "project.settings.about-box.title.label": "제목", + "project.settings.collaborators.title": "협력자 관리", "project.settings.compute-image-selector.button.save-restart": "저장 및 다시 시작", "project.settings.compute-image-selector.doubt": "{default, select, true {기본 선택입니다} other {참고: 확실하지 않으면 \"{default_title}\"을 선택하세요}}", "project.settings.compute-image-selector.software-env-info": "소프트웨어 환경은 이 프로젝트가 사용할 수 있는 모든 소프트웨어를 제공합니다. 추가 소프트웨어가 필요하면 프로젝트에 설치하거나 지원팀에 문의할 수 있습니다. Python 패키지 설치, Python Jupyter 커널, R 패키지Julia 패키지에 대해 알아보세요.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {프로젝트 보이기} other {프로젝트 숨기기}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {숨김} other {표시됨}}", "project.settings.hide-delete-box.title": "프로젝트 숨기기 또는 삭제", + "project.settings.manage_users_owner_only": "소유자만 협업자를 관리할 수 있도록 허용하십시오.", + "project.settings.manage_users_owner_only.note": "이 설정은 프로젝트 소유자만 변경할 수 있습니다.", + "project.settings.manage_users_owner_only.site_enforced": "이 설정은 모든 프로젝트에 대해 사이트 관리자가 적용합니다.", "project.settings.restart-project.button.label": "{is_running, select, true {다시 시작} other {시작}}", "project.settings.site-license.body.info": "첨부된 라이선스에 대한 정보. 세부 정보를 확장하려면 행을 클릭하세요.", "project.settings.site-license.button.label": "라이선스 키를 사용하여 업그레이드...", diff --git a/src/packages/frontend/i18n/trans/nl_NL.json b/src/packages/frontend/i18n/trans/nl_NL.json index d9f0c91992c..e57085c3cc8 100644 --- a/src/packages/frontend/i18n/trans/nl_NL.json +++ b/src/packages/frontend/i18n/trans/nl_NL.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Volledige reactie invoegen", "codemirror.extensions.ai_formula.title": "Genereer LaTeX-formule", "collaborators.current-collabs.intro": "Iedereen die hieronder vermeld staat, kan samen met jou werken aan elk Jupyter Notebook, Linux Terminal of bestand in dit project, en andere medewerkers toevoegen of verwijderen.", + "collaborators.current-collabs.intro2": "Iedereen hieronder vermeld kan samen met jou werken aan elk Jupyter Notebook, Linux Terminal of bestand in dit project. {manageUsersOnly, select, true { Alleen projecteigenaren kunnen medewerkers toevoegen of verwijderen.} other { Medewerkers kunnen ook andere medewerkers toevoegen of verwijderen.}}", "collaborators.current-collabs.remove_other": "Weet je zeker dat je {user} uit dit project wilt verwijderen? Ze hebben dan geen toegang meer tot dit project.", "collaborators.current-collabs.remove_self": "Weet u zeker dat u uzelf van dit project wilt verwijderen? U heeft dan geen toegang meer tot dit project en kunt uzelf niet opnieuw toevoegen.", + "collaborators.current-collabs.remove.ok_button": "Ja, verwijder {role}", + "collaborators.current-collabs.remove.owner_disabled": "Eigenaren moeten worden gedegradeerd voordat ze kunnen worden verwijderd.", + "collaborators.current-collabs.remove.setting_disabled": "Alleen eigenaren kunnen medewerkers verwijderen wanneer deze instelling is ingeschakeld.", "collaborators.current-collabs.title": "Huidige Samenwerkers", "command.format.ai_formula.button": "Formule", "command.format.ai_formula.label": "AI gegenereerde formule", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Toon documentatie voor het gebruik van de Linux Terminal in CoCalc.", "editor.toggle_pdf_dark_mode.label": "Schakel PDF Donkere Modus in", "editor.toggle_pdf_dark_mode.title": "Donkere modus van PDF uitschakelen om het originele bestand te zien", + "errors.ownership.cannot_remove_owner": "Kan een eigenaar niet verwijderen. Eerst degraderen naar medewerker.", + "errors.ownership.invalid_project_state": "Projectgegevens ontbreken of zijn ongeldig.", + "errors.ownership.invalid_requesting_user": "Je bent geen projectlid.", + "errors.ownership.invalid_target": "Doelgebruiker is geen lid van dit project.", + "errors.ownership.invalid_user": "Gebruiker niet gevonden of ongeldig.", + "errors.ownership.last_owner": "Kan eigenaar niet verwijderen, omdat er ten minste één eigenaar vereist is.", + "errors.ownership.not_owner": "Alleen projecteigenaren kunnen gebruikers typen wijzigen.", "file_actions.compress.name": "Comprimeer", "file_actions.copy.name": "Kopiëren", "file_actions.create.name": "Creëer", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Cloud-bestandssystemen", "labels.cloud_storage_remote_filesystems": "Cloudopslag & Externe Bestandssystemen", "labels.code_folding": "Code-vouwen", + "labels.collaborator": "Medewerker", "labels.collaborators": "Medewerkers", "labels.color": "Kleur", "labels.communication": "Communicatie", @@ -1081,6 +1093,7 @@ "labels.open": "Openen", "labels.other": "Overig", "labels.overview": "Overzicht", + "labels.owner": "Eigenaar", "labels.pages": "Pagina's", "labels.paste": "Plakken", "labels.pay_as_you_go": "Betaal per gebruik", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "hernoem {src}", "project-start-warning.content": "U moet het project \"{project_title}\" starten voordat u kunt {what}. {title}", "project-start-warning.title": "Dit project starten?", + "project.collaborators.add.owner_only_setting": "Alleen projekteigenaren kunnen medewerkers toevoegen wanneer beheer door alleen de eigenaar is ingeschakeld.", + "project.collaborators.demote.label": "Degraderen naar medewerker", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Kan de laatste eigenaar niet degraderen} other {Degradeer deze eigenaar tot medewerker}}", + "project.collaborators.non_owner_note": "Alleen projecteigenaren kunnen gebruikersrollen beheren.", + "project.collaborators.promote.label": "Promoveer tot Eigenaar", + "project.collaborators.promote.tooltip": "Promoveer deze medewerker tot eigenaar, zodat ze volledige projectcontrole krijgen", "project.explorer.action-bar.check_all.button": "{checked, select, true {Alles deselecteren} other {Alles selecteren}}", "project.explorer.action-bar.currently_selected.info": "Klik het selectievakje links van een bestand om te kopiëren, downloaden, enz.", "project.explorer.action-bar.currently_selected.items": "{checked} van {total} {items} geselecteerd", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Opent de huidige map in een {name} serverinstantie, draaiend binnen dit project.", "project.explorer.search-bar.placeholder": "Filter bestanden of \"/\" voor terminal...", "project.explorer.start_project.warning": "Om de bestanden in deze map te zien, moet u dit project starten.", + "project.history.log-entry.change_collaborator_type": "veranderde {target} van {old_group} naar {new_group}", "project.history.log-entry.invited_user": "uitgenodigde gebruiker", "project.history.log-entry.invited_user_via": "uitgenodigde nieuwe gebruiker via", "project.history.log-entry.miniterm": "uitgevoerde mini terminal opdracht {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Naam (optioneel)", "project.settings.about-box.starred.help": "Sterprojecten kunnen worden gefilterd door op de sterfilterknop in je projectenlijst te klikken.", "project.settings.about-box.title.label": "Titel", + "project.settings.collaborators.title": "Beheer van medewerkers", "project.settings.compute-image-selector.button.save-restart": "Opslaan en Herstarten", "project.settings.compute-image-selector.doubt": "{default, select, true {Dit is de standaardselectie} other {Opmerking: bij twijfel, selecteer \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Een software-omgeving biedt alle software die dit project kan gebruiken. Als je extra software nodig hebt, kun je deze in het project installeren of contact opnemen met de ondersteuning. Lees meer over het installeren van Python-pakketten, Python Jupyter Kernel, R-pakketten en Julia-pakketten.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Project zichtbaar maken} other {Project verbergen}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Verborgen} other {Zichtbaar}}", "project.settings.hide-delete-box.title": "Verbergen of Verwijderen Project", + "project.settings.manage_users_owner_only": "Sta alleen eigenaren toe om medewerkers te beheren.", + "project.settings.manage_users_owner_only.note": "Deze instelling kan alleen worden gewijzigd door projecteigenaars.", + "project.settings.manage_users_owner_only.site_enforced": "Deze instelling wordt door de sitebeheerder afgedwongen voor alle projecten.", "project.settings.restart-project.button.label": "{is_running, select, true {Herstarten} other {Starten}}", "project.settings.site-license.body.info": "Informatie over bijgevoegde licenties. Klik op een rij om details uit te vouwen.", "project.settings.site-license.button.label": "Upgrade met een licentiesleutel...", diff --git a/src/packages/frontend/i18n/trans/pl_PL.json b/src/packages/frontend/i18n/trans/pl_PL.json index bce3c5556b3..7e2edd32cd7 100644 --- a/src/packages/frontend/i18n/trans/pl_PL.json +++ b/src/packages/frontend/i18n/trans/pl_PL.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Wstaw pełną odpowiedź", "codemirror.extensions.ai_formula.title": "Generuj formułę LaTeX", "collaborators.current-collabs.intro": "Każdy wymieniony poniżej może wspólnie z Tobą pracować nad dowolnym Jupyter Notebookiem, Terminalem Linux lub plikiem w tym projekcie, a także dodawać lub usuwać innych współpracowników.", + "collaborators.current-collabs.intro2": "Każda osoba wymieniona poniżej może współpracować z Tobą nad dowolnym notatnikiem Jupyter, terminalem Linux lub plikiem w tym projekcie. {manageUsersOnly, select, true { Tylko właściciele projektu mogą dodawać lub usuwać współpracowników.} other { Współpracownicy mogą również dodawać lub usuwać innych współpracowników.}}", "collaborators.current-collabs.remove_other": "Czy na pewno chcesz usunąć {user} z tego projektu? Nie będą mieli już dostępu do tego projektu.", "collaborators.current-collabs.remove_self": "Czy na pewno chcesz usunąć siebie z tego projektu? Nie będziesz już mieć dostępu do tego projektu i nie możesz dodać siebie ponownie.", + "collaborators.current-collabs.remove.ok_button": "Tak, usuń {role}", + "collaborators.current-collabs.remove.owner_disabled": "Właściciele muszą zostać zdegradowani, zanim będą mogli zostać usunięci.", + "collaborators.current-collabs.remove.setting_disabled": "Tylko właściciele mogą usuwać współpracowników, gdy to ustawienie jest włączone.", "collaborators.current-collabs.title": "Obecni Współpracownicy", "command.format.ai_formula.button": "Formuła", "command.format.ai_formula.label": "Formuła wygenerowana przez AI", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Pokaż dokumentację dotyczącą używania terminala Linux w CoCalc", "editor.toggle_pdf_dark_mode.label": "Przełącz Tryb Ciemny PDF", "editor.toggle_pdf_dark_mode.title": "Wyłącz tryb ciemny PDF, aby zobaczyć oryginalny plik", + "errors.ownership.cannot_remove_owner": "Nie można usunąć właściciela. Najpierw zdegraduj do współpracownika.", + "errors.ownership.invalid_project_state": "Brakuje danych projektu lub są one nieprawidłowe.", + "errors.ownership.invalid_requesting_user": "Nie jesteś członkiem projektu.", + "errors.ownership.invalid_target": "Docelowy użytkownik nie jest członkiem tego projektu.", + "errors.ownership.invalid_user": "Użytkownik nie znaleziony lub nieprawidłowy.", + "errors.ownership.last_owner": "Nie można usunąć właściciela, ponieważ wymagany jest przynajmniej jeden właściciel.", + "errors.ownership.not_owner": "Tylko właściciele projektów mogą zmieniać typy użytkowników.", "file_actions.compress.name": "Kompresuj", "file_actions.copy.name": "Kopiuj", "file_actions.create.name": "Utwórz", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Chmurowe Systemy Plików", "labels.cloud_storage_remote_filesystems": "Przechowywanie w chmurze i zdalne systemy plików", "labels.code_folding": "Składanie kodu", + "labels.collaborator": "Współpracownik", "labels.collaborators": "Współpracownicy", "labels.color": "Kolor", "labels.communication": "Komunikacja", @@ -1081,6 +1093,7 @@ "labels.open": "Otwórz", "labels.other": "Inne", "labels.overview": "Przegląd", + "labels.owner": "Właściciel", "labels.pages": "Strony", "labels.paste": "Wklej", "labels.pay_as_you_go": "Płać w miarę użycia", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "zmień nazwę {src}", "project-start-warning.content": "Musisz uruchomić projekt \"{project_title}\" zanim będziesz mógł {what}. {title}", "project-start-warning.title": "Rozpocząć ten projekt?", + "project.collaborators.add.owner_only_setting": "Tylko właściciele projektów mogą dodawać współpracowników, gdy włączone jest zarządzanie tylko dla właścicieli.", + "project.collaborators.demote.label": "Degraduj do Współpracownika", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Nie można zdegradować ostatniego właściciela} other {Zdegradować tego właściciela do współpracownika}}", + "project.collaborators.non_owner_note": "Tylko właściciele projektów mogą zarządzać rolami użytkowników.", + "project.collaborators.promote.label": "Awansuj na właściciela", + "project.collaborators.promote.tooltip": "Awansuj tego współpracownika na właściciela, dając mu pełną kontrolę nad projektem", "project.explorer.action-bar.check_all.button": "{checked, select, true {Odznacz wszystkie} other {Zaznacz wszystkie}}", "project.explorer.action-bar.currently_selected.info": "Kliknij pole wyboru po lewej stronie pliku, aby skopiować, pobrać, itp.", "project.explorer.action-bar.currently_selected.items": "{checked} z {total} {items} wybranych", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Otwiera bieżący katalog w instancji serwera {name}, działającej w ramach tego projektu.", "project.explorer.search-bar.placeholder": "Filtruj pliki lub \"/\" dla terminala...", "project.explorer.start_project.warning": "Aby zobaczyć pliki w tym katalogu, musisz uruchomić ten projekt.", + "project.history.log-entry.change_collaborator_type": "zmieniono {target} z {old_group} na {new_group}", "project.history.log-entry.invited_user": "zaproszony użytkownik", "project.history.log-entry.invited_user_via": "zaproszono nowego użytkownika przez", "project.history.log-entry.miniterm": "wykonano mini polecenie terminala {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Nazwa (opcjonalnie)", "project.settings.about-box.starred.help": "Oznaczone gwiazdką projekty można filtrować, klikając przycisk filtru z gwiazdką na liście projektów.", "project.settings.about-box.title.label": "Tytuł", + "project.settings.collaborators.title": "Zarządzanie współpracownikami", "project.settings.compute-image-selector.button.save-restart": "Zapisz i Uruchom Ponownie", "project.settings.compute-image-selector.doubt": "{default, select, true {To jest domyślny wybór} other {Uwaga: w razie wątpliwości, wybierz \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Środowisko oprogramowania zapewnia całe oprogramowanie, z którego ten projekt może korzystać. Jeśli potrzebujesz dodatkowego oprogramowania, możesz je zainstalować w projekcie lub skontaktować się z pomocą techniczną. Dowiedz się więcej o instalowaniu pakietów Pythona, jądrze Python Jupyter, pakietach R i pakietach Julii.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Odkryj Projekt} other {Ukryj Projekt}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Ukryty} other {Widoczny}}", "project.settings.hide-delete-box.title": "Ukryj lub Usuń Projekt", + "project.settings.manage_users_owner_only": "Zezwalaj tylko właścicielom na zarządzanie współpracownikami.", + "project.settings.manage_users_owner_only.note": "To ustawienie może być zmieniane tylko przez właścicieli projektu.", + "project.settings.manage_users_owner_only.site_enforced": "To ustawienie jest wymuszane przez administratora witryny dla wszystkich projektów.", "project.settings.restart-project.button.label": "{is_running, select, true {Restart} other {Start}}", "project.settings.site-license.body.info": "Informacje o załączonych licencjach. Kliknij wiersz, aby rozwinąć szczegóły.", "project.settings.site-license.button.label": "Uaktualnij za pomocą klucza licencyjnego...", diff --git a/src/packages/frontend/i18n/trans/pt_BR.json b/src/packages/frontend/i18n/trans/pt_BR.json index 7db1bf11a76..130272bc741 100644 --- a/src/packages/frontend/i18n/trans/pt_BR.json +++ b/src/packages/frontend/i18n/trans/pt_BR.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Inserir resposta completa", "codemirror.extensions.ai_formula.title": "Gerar Fórmula LaTeX", "collaborators.current-collabs.intro": "Todos listados abaixo podem trabalhar colaborativamente com você em qualquer Jupyter Notebook, Terminal Linux ou arquivo neste projeto, e adicionar ou remover outros colaboradores.", + "collaborators.current-collabs.intro2": "Todos listados abaixo podem trabalhar colaborativamente com você em qualquer Jupyter Notebook, Terminal Linux ou arquivo neste projeto. {manageUsersOnly, select, true { Somente os proprietários do projeto podem adicionar ou remover colaboradores.} other { Colaboradores também podem adicionar ou remover outros colaboradores.}}", "collaborators.current-collabs.remove_other": "Tem certeza de que deseja remover {user} deste projeto? Eles não terão mais acesso a este projeto.", "collaborators.current-collabs.remove_self": "Tem certeza de que deseja remover você mesmo deste projeto? Você não terá mais acesso a este projeto e não poderá se adicionar novamente.", + "collaborators.current-collabs.remove.ok_button": "Sim, remover {role}", + "collaborators.current-collabs.remove.owner_disabled": "Os proprietários devem ser rebaixados antes de serem removidos.", + "collaborators.current-collabs.remove.setting_disabled": "Somente os proprietários podem remover colaboradores quando esta configuração está ativada.", "collaborators.current-collabs.title": "Colaboradores Atuais", "command.format.ai_formula.button": "Fórmula", "command.format.ai_formula.label": "Fórmula Gerada por IA", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Mostrar documentação para usar o Terminal Linux no CoCalc.", "editor.toggle_pdf_dark_mode.label": "Alternar Modo Escuro do PDF", "editor.toggle_pdf_dark_mode.title": "Desativar o modo escuro do PDF para ver o arquivo original", + "errors.ownership.cannot_remove_owner": "Não é possível remover um proprietário. Rebaixe para colaborador primeiro.", + "errors.ownership.invalid_project_state": "Os dados do projeto estão ausentes ou inválidos.", + "errors.ownership.invalid_requesting_user": "Você não é membro do projeto.", + "errors.ownership.invalid_target": "Usuário alvo não é membro deste projeto.", + "errors.ownership.invalid_user": "Usuário não encontrado ou inválido.", + "errors.ownership.last_owner": "Não é possível remover o proprietário, pois é necessário pelo menos um proprietário.", + "errors.ownership.not_owner": "Apenas os proprietários do projeto podem alterar os tipos de usuário.", "file_actions.compress.name": "Comprimir", "file_actions.copy.name": "Copiar", "file_actions.create.name": "Criar", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Sistemas de Arquivos na Nuvem", "labels.cloud_storage_remote_filesystems": "Armazenamento em Nuvem & Sistemas de Arquivos Remotos", "labels.code_folding": "Dobra de Código", + "labels.collaborator": "Colaborador", "labels.collaborators": "Colaboradores", "labels.color": "Cor", "labels.communication": "Comunicação", @@ -1081,6 +1093,7 @@ "labels.open": "Abrir", "labels.other": "Outros", "labels.overview": "Visão geral", + "labels.owner": "Proprietário", "labels.pages": "Páginas", "labels.paste": "Colar", "labels.pay_as_you_go": "Pague Conforme o Uso", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "renomear {src}", "project-start-warning.content": "Você deve iniciar o projeto \"{project_title}\" antes de poder {what}. {title}", "project-start-warning.title": "Iniciar este projeto?", + "project.collaborators.add.owner_only_setting": "Somente os proprietários de projetos podem adicionar colaboradores quando a gestão exclusiva do proprietário está ativada.", + "project.collaborators.demote.label": "Rebaixar para Colaborador", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Não é possível rebaixar o último proprietário} other {Rebaixar este proprietário para colaborador}}", + "project.collaborators.non_owner_note": "Somente os proprietários do projeto podem gerenciar funções de usuário.", + "project.collaborators.promote.label": "Promover para Proprietário", + "project.collaborators.promote.tooltip": "Promover este colaborador a proprietário, dando a ele controle total do projeto", "project.explorer.action-bar.check_all.button": "{checked, select, true {Desmarcar Todos} other {Marcar Todos}}", "project.explorer.action-bar.currently_selected.info": "Clique na caixa de seleção à esquerda de um arquivo para copiar, baixar, etc.", "project.explorer.action-bar.currently_selected.items": "{checked} de {total} {items} selecionados", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Abre o diretório atual em uma instância do servidor {name}, executando dentro deste projeto.", "project.explorer.search-bar.placeholder": "Filtrar arquivos ou \"/\" para terminal...", "project.explorer.start_project.warning": "Para ver os arquivos neste diretório, você precisa iniciar este projeto.", + "project.history.log-entry.change_collaborator_type": "alterado {target} de {old_group} para {new_group}", "project.history.log-entry.invited_user": "usuário convidado", "project.history.log-entry.invited_user_via": "convidou novo usuário via", "project.history.log-entry.miniterm": "comando de mini terminal executado {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Nome (opcional)", "project.settings.about-box.starred.help": "Projetos estrelados podem ser filtrados clicando no botão de filtro estrelado na sua lista de projetos.", "project.settings.about-box.title.label": "Título", + "project.settings.collaborators.title": "Gerenciamento de Colaboradores", "project.settings.compute-image-selector.button.save-restart": "Salvar e Reiniciar", "project.settings.compute-image-selector.doubt": "{default, select, true {Esta é a seleção padrão} other {Nota: em caso de dúvida, selecione \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Um ambiente de software fornece todo o software que este projeto pode utilizar. Se você precisar de software adicional, pode instalá-lo no projeto ou entrar em contato com o suporte. Saiba mais sobre instalação de pacotes Python, Kernel Python Jupyter, Pacotes R e pacotes Julia.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Mostrar Projeto} other {Ocultar Projeto}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Oculto} other {Visível}}", "project.settings.hide-delete-box.title": "Ocultar ou Excluir Projeto", + "project.settings.manage_users_owner_only": "Permitir apenas que os proprietários gerenciem colaboradores.", + "project.settings.manage_users_owner_only.note": "Esta configuração só pode ser alterada pelos proprietários do projeto.", + "project.settings.manage_users_owner_only.site_enforced": "Esta configuração é imposta pelo administrador do site para todos os projetos.", "project.settings.restart-project.button.label": "{is_running, select, true {Reiniciar} other {Iniciar}}", "project.settings.site-license.body.info": "Informações sobre licenças anexadas. Clique em uma linha para expandir os detalhes.", "project.settings.site-license.button.label": "Atualizar usando uma chave de licença...", diff --git a/src/packages/frontend/i18n/trans/pt_PT.json b/src/packages/frontend/i18n/trans/pt_PT.json index 714b91d09e4..97025eba3ac 100644 --- a/src/packages/frontend/i18n/trans/pt_PT.json +++ b/src/packages/frontend/i18n/trans/pt_PT.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Inserir resposta completa", "codemirror.extensions.ai_formula.title": "Gerar Fórmula LaTeX", "collaborators.current-collabs.intro": "Todos os listados abaixo podem trabalhar colaborativamente consigo em qualquer Jupyter Notebook, Terminal Linux ou ficheiro neste projeto, e adicionar ou remover outros colaboradores.", + "collaborators.current-collabs.intro2": "Todos listados abaixo podem trabalhar colaborativamente consigo em qualquer Jupyter Notebook, Terminal Linux ou ficheiro neste projeto. {manageUsersOnly, select, true { Apenas os proprietários do projeto podem adicionar ou remover colaboradores.} other { Os colaboradores também podem adicionar ou remover outros colaboradores.}}", "collaborators.current-collabs.remove_other": "Tem a certeza de que deseja remover {user} deste projeto? Eles já não terão acesso a este projeto.", "collaborators.current-collabs.remove_self": "Tem a certeza de que quer remover si próprio deste projeto? Deixará de ter acesso a este projeto e não poderá adicionar-se novamente.", + "collaborators.current-collabs.remove.ok_button": "Sim, remover {role}", + "collaborators.current-collabs.remove.owner_disabled": "Os proprietários devem ser rebaixados antes de poderem ser removidos.", + "collaborators.current-collabs.remove.setting_disabled": "Apenas os proprietários podem remover colaboradores quando esta configuração está ativada.", "collaborators.current-collabs.title": "Colaboradores Atuais", "command.format.ai_formula.button": "Fórmula", "command.format.ai_formula.label": "Fórmula Gerada por IA", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Mostrar documentação para usar o Terminal Linux no CoCalc", "editor.toggle_pdf_dark_mode.label": "Alternar Modo Escuro do PDF", "editor.toggle_pdf_dark_mode.title": "Desativar o modo escuro do PDF, para ver o ficheiro original", + "errors.ownership.cannot_remove_owner": "Não é possível remover um proprietário. Demote para colaborador primeiro.", + "errors.ownership.invalid_project_state": "Os dados do projeto estão em falta ou são inválidos.", + "errors.ownership.invalid_requesting_user": "Não é membro do projeto.", + "errors.ownership.invalid_target": "O utilizador alvo não é membro deste projeto.", + "errors.ownership.invalid_user": "Utilizador não encontrado ou inválido.", + "errors.ownership.last_owner": "Não é possível remover o proprietário, porque é necessário pelo menos um proprietário.", + "errors.ownership.not_owner": "Só os proprietários do projeto podem alterar os tipos de utilizador.", "file_actions.compress.name": "Comprimir", "file_actions.copy.name": "Copiar", "file_actions.create.name": "Criar", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Sistemas de Ficheiros na Nuvem", "labels.cloud_storage_remote_filesystems": "Armazenamento na Nuvem e Sistemas de Ficheiros Remotos", "labels.code_folding": "Dobrar Código", + "labels.collaborator": "Colaborador", "labels.collaborators": "Colaboradores", "labels.color": "Cor", "labels.communication": "Comunicação", @@ -1081,6 +1093,7 @@ "labels.open": "Abrir", "labels.other": "Outros", "labels.overview": "Visão geral", + "labels.owner": "Proprietário", "labels.pages": "Páginas", "labels.paste": "Colar", "labels.pay_as_you_go": "Pague Conforme o Uso", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "renomear {src}", "project-start-warning.content": "Tem de iniciar o projeto \"{project_title}\" antes de poder {what}. {title}", "project-start-warning.title": "Iniciar este projeto?", + "project.collaborators.add.owner_only_setting": "Só os proprietários dos projetos podem adicionar colaboradores quando a gestão exclusiva do proprietário está ativada.", + "project.collaborators.demote.label": "Rebaixar para Colaborador", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Não é possível despromover o último proprietário} other {Despromover este proprietário para colaborador}}", + "project.collaborators.non_owner_note": "Apenas os proprietários do projeto podem gerir os papéis dos utilizadores.", + "project.collaborators.promote.label": "Promover a Proprietário", + "project.collaborators.promote.tooltip": "Promover este colaborador a proprietário, dando-lhe controlo total do projeto", "project.explorer.action-bar.check_all.button": "{checked, select, true {Desmarcar Todos} other {Marcar Todos}}", "project.explorer.action-bar.currently_selected.info": "Clique na caixa de seleção à esquerda de um ficheiro para copiar, descarregar, etc.", "project.explorer.action-bar.currently_selected.items": "{checked} de {total} {items} selecionados", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Abre o diretório atual numa instância de servidor {name}, a correr dentro deste projeto.", "project.explorer.search-bar.placeholder": "Filtrar ficheiros ou \"/\" para terminal...", "project.explorer.start_project.warning": "Para ver os ficheiros neste diretório, tem de iniciar este projeto.", + "project.history.log-entry.change_collaborator_type": "alterado {target} de {old_group} para {new_group}", "project.history.log-entry.invited_user": "utilizador convidado", "project.history.log-entry.invited_user_via": "convidou novo utilizador via", "project.history.log-entry.miniterm": "comando de terminal mini executado {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Nome (opcional)", "project.settings.about-box.starred.help": "Os projetos com estrela podem ser filtrados clicando no botão de filtro de estrelas na sua lista de projetos.", "project.settings.about-box.title.label": "Título", + "project.settings.collaborators.title": "Gestão de Colaboradores", "project.settings.compute-image-selector.button.save-restart": "Guardar e Reiniciar", "project.settings.compute-image-selector.doubt": "{default, select, true {Esta é a seleção padrão} other {Nota: em caso de dúvida, selecione \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Um ambiente de software fornece todo o software que este projeto pode utilizar. Se precisar de software adicional, pode instalá-lo no projeto ou contactar o suporte. Saiba mais sobre instalar pacotes Python, Kernel Python Jupyter, Pacotes R e Pacotes Julia.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Mostrar Projeto} other {Ocultar Projeto}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Oculto} other {Visível}}", "project.settings.hide-delete-box.title": "Ocultar ou Eliminar Projeto", + "project.settings.manage_users_owner_only": "Permitir apenas aos proprietários gerir colaboradores.", + "project.settings.manage_users_owner_only.note": "Esta definição só pode ser alterada pelos proprietários do projeto.", + "project.settings.manage_users_owner_only.site_enforced": "Esta definição é aplicada pelo administrador do site a todos os projetos.", "project.settings.restart-project.button.label": "{is_running, select, true {Reiniciar} other {Iniciar}}", "project.settings.site-license.body.info": "Informação sobre licenças anexadas. Clique numa linha para expandir os detalhes.", "project.settings.site-license.button.label": "Atualizar usando uma chave de licença...", diff --git a/src/packages/frontend/i18n/trans/ru_RU.json b/src/packages/frontend/i18n/trans/ru_RU.json index 57217b0ad71..a23e63a4b41 100644 --- a/src/packages/frontend/i18n/trans/ru_RU.json +++ b/src/packages/frontend/i18n/trans/ru_RU.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Вставить полный ответ", "codemirror.extensions.ai_formula.title": "Создать формулу LaTeX", "collaborators.current-collabs.intro": "Все перечисленные ниже могут совместно работать с вами над любым Jupyter Notebook, терминалом Linux или файлом в этом проекте, а также добавлять или удалять других участников.", + "collaborators.current-collabs.intro2": "Все перечисленные ниже могут совместно работать с вами над любым Jupyter Notebook, терминалом Linux или файлом в этом проекте. {manageUsersOnly, select, true { Только владельцы проекта могут добавлять или удалять соавторов.} other { Соавторы также могут добавлять или удалять других соавторов.}}", "collaborators.current-collabs.remove_other": "Вы уверены, что хотите удалить {user} из этого проекта? У них больше не будет доступа к этому проекту.", "collaborators.current-collabs.remove_self": "Вы уверены, что хотите удалить себя из этого проекта? У вас больше не будет доступа к этому проекту, и вы не сможете добавить себя обратно.", + "collaborators.current-collabs.remove.ok_button": "Да, удалить {role}", + "collaborators.current-collabs.remove.owner_disabled": "Перед удалением владельцы должны быть понижены в должности.", + "collaborators.current-collabs.remove.setting_disabled": "Только владельцы могут удалять сотрудников, когда эта настройка включена.", "collaborators.current-collabs.title": "Текущие сотрудники", "command.format.ai_formula.button": "Формула", "command.format.ai_formula.label": "Формула, сгенерированная ИИ", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "Показать документацию по использованию терминала Linux в CoCalc", "editor.toggle_pdf_dark_mode.label": "Переключить тёмный режим PDF", "editor.toggle_pdf_dark_mode.title": "Выключите темный режим PDF, чтобы увидеть оригинальный файл", + "errors.ownership.cannot_remove_owner": "Нельзя удалить владельца. Сначала понизьте до уровня сотрудника.", + "errors.ownership.invalid_project_state": "Данные проекта отсутствуют или недействительны.", + "errors.ownership.invalid_requesting_user": "Вы не являетесь участником проекта.", + "errors.ownership.invalid_target": "Целевой пользователь не является участником этого проекта.", + "errors.ownership.invalid_user": "Пользователь не найден или недействителен.", + "errors.ownership.last_owner": "Невозможно удалить владельца, так как требуется как минимум один владелец.", + "errors.ownership.not_owner": "Только владельцы проектов могут изменять типы пользователей.", "file_actions.compress.name": "Сжать", "file_actions.copy.name": "Копировать", "file_actions.create.name": "Создать", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Облачные файловые системы", "labels.cloud_storage_remote_filesystems": "Облачное хранилище и удаленные файловые системы", "labels.code_folding": "Сворачивание кода", + "labels.collaborator": "Сотрудник", "labels.collaborators": "Участники", "labels.color": "Цвет", "labels.communication": "Общение", @@ -1081,6 +1093,7 @@ "labels.open": "Открыть", "labels.other": "Другие", "labels.overview": "Обзор", + "labels.owner": "Владелец", "labels.pages": "Страницы", "labels.paste": "Вставить", "labels.pay_as_you_go": "Оплата по мере использования", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "переименовать {src}", "project-start-warning.content": "Вы должны запустить проект \"{project_title}\", прежде чем сможете {what}. {title}", "project-start-warning.title": "Начать этот проект?", + "project.collaborators.add.owner_only_setting": "Только владельцы проектов могут добавлять сотрудников, когда включено управление только владельцами.", + "project.collaborators.demote.label": "Понизить до Сотрудника", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Нельзя понизить последнего владельца} other {Понизить этого владельца до сотрудника}}", + "project.collaborators.non_owner_note": "Только владельцы проектов могут управлять ролями пользователей.", + "project.collaborators.promote.label": "Повысить до владельца", + "project.collaborators.promote.tooltip": "Повысить этого сотрудника до владельца, предоставив ему полный контроль над проектом", "project.explorer.action-bar.check_all.button": "{checked, select, true {Снять все} other {Выбрать все}}", "project.explorer.action-bar.currently_selected.info": "Нажмите флажок слева от файла, чтобы копировать, скачать и т.д.", "project.explorer.action-bar.currently_selected.items": "{checked} из {total} {items} выбрано", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Открывает текущий каталог в экземпляре сервера {name}, работающем внутри этого проекта", "project.explorer.search-bar.placeholder": "Фильтровать файлы или \"/\" для терминала...", "project.explorer.start_project.warning": "Чтобы увидеть файлы в этом каталоге, вам нужно запустить этот проект.", + "project.history.log-entry.change_collaborator_type": "изменено {target} с {old_group} на {new_group}", "project.history.log-entry.invited_user": "приглашенный пользователь", "project.history.log-entry.invited_user_via": "пригласил нового пользователя через", "project.history.log-entry.miniterm": "выполнена мини-команда терминала {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "Имя (необязательно)", "project.settings.about-box.starred.help": "Помеченные звёздочкой проекты можно отфильтровать, нажав на кнопку фильтрации по звёздочкам в списке ваших проектов.", "project.settings.about-box.title.label": "Заголовок", + "project.settings.collaborators.title": "Управление сотрудниками", "project.settings.compute-image-selector.button.save-restart": "Сохранить и перезапустить", "project.settings.compute-image-selector.doubt": "{default, select, true {Это выбор по умолчанию} other {Примечание: если не уверены, выберите \"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "Программная среда предоставляет все программное обеспечение, которое может использовать этот проект. Если вам нужно дополнительное программное обеспечение, вы можете либо установить его в проекте, либо связаться с поддержкой. Узнайте о установке пакетов Python, ядре Python Jupyter, пакетах R и пакетах Julia.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Показать проект} other {Скрыть проект}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Скрыто} other {Видимо}}", "project.settings.hide-delete-box.title": "Скрыть или Удалить Проект", + "project.settings.manage_users_owner_only": "Разрешить только владельцам управлять сотрудниками.", + "project.settings.manage_users_owner_only.note": "Этот параметр может изменяться только владельцами проекта.", + "project.settings.manage_users_owner_only.site_enforced": "Этот параметр установлен администратором сайта для всех проектов.", "project.settings.restart-project.button.label": "{is_running, select, true {Перезапустить} other {Запустить}}", "project.settings.site-license.body.info": "Информация о прикрепленных лицензиях. Щелкните по строке, чтобы развернуть детали.", "project.settings.site-license.button.label": "Обновить с помощью лицензионного ключа...", diff --git a/src/packages/frontend/i18n/trans/tr_TR.json b/src/packages/frontend/i18n/trans/tr_TR.json index b006a48dfd2..802f9f9d222 100644 --- a/src/packages/frontend/i18n/trans/tr_TR.json +++ b/src/packages/frontend/i18n/trans/tr_TR.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "Tam yanıtı ekle", "codemirror.extensions.ai_formula.title": "LaTeX Formülü Oluştur", "collaborators.current-collabs.intro": "Aşağıda listelenen herkes, bu projedeki herhangi bir Jupyter Notebook, Linux Terminal veya dosya üzerinde sizinle işbirliği yapabilir ve diğer işbirlikçileri ekleyebilir veya kaldırabilir.", + "collaborators.current-collabs.intro2": "Aşağıda listelenen herkes bu projedeki herhangi bir Jupyter Notebook, Linux Terminal veya dosya üzerinde sizinle birlikte çalışabilir. {manageUsersOnly, select, true { Yalnızca proje sahipleri işbirlikçileri ekleyebilir veya kaldırabilir.} other { İşbirlikçiler de diğer işbirlikçileri ekleyebilir veya kaldırabilir.}}", "collaborators.current-collabs.remove_other": "Bu {user} kullanıcısını bu projeden kaldırmak istediğinizden emin misiniz? Artık bu projeye erişimleri olmayacak.", "collaborators.current-collabs.remove_self": "Bu projeden kendinizi çıkarmak istediğinizden emin misiniz? Bu projeye artık erişiminiz olmayacak ve kendinizi geri ekleyemezsiniz.", + "collaborators.current-collabs.remove.ok_button": "Evet, {role} kaldır", + "collaborators.current-collabs.remove.owner_disabled": "Sahiplerin kaldırılmadan önce rütbeleri düşürülmelidir.", + "collaborators.current-collabs.remove.setting_disabled": "Bu ayar etkinleştirildiğinde yalnızca sahipler işbirlikçileri kaldırabilir.", "collaborators.current-collabs.title": "Mevcut İşbirlikçiler", "command.format.ai_formula.button": "Formül", "command.format.ai_formula.label": "AI Tarafından Oluşturulan Formül", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "CoCalc'ta Linux Terminalini kullanma belgelerini göster.", "editor.toggle_pdf_dark_mode.label": "PDF Koyu Modu Değiştir", "editor.toggle_pdf_dark_mode.title": "PDF'nin karanlık modunu kapat, orijinal dosyayı görmek için", + "errors.ownership.cannot_remove_owner": "Bir sahibi kaldıramazsınız. Önce işbirlikçiye düşürün.", + "errors.ownership.invalid_project_state": "Proje verileri eksik veya geçersiz.", + "errors.ownership.invalid_requesting_user": "Proje üyesi değilsiniz.", + "errors.ownership.invalid_target": "Hedef kullanıcı bu projenin bir üyesi değil.", + "errors.ownership.invalid_user": "Kullanıcı bulunamadı veya geçersiz.", + "errors.ownership.last_owner": "Sahibi kaldıramazsınız, çünkü en az bir sahip gereklidir.", + "errors.ownership.not_owner": "Yalnızca proje sahipleri kullanıcı türlerini değiştirebilir.", "file_actions.compress.name": "Sıkıştır", "file_actions.copy.name": "Kopyala", "file_actions.create.name": "Oluştur", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "Bulut Dosya Sistemleri", "labels.cloud_storage_remote_filesystems": "Bulut Depolama & Uzaktan Dosya Sistemleri", "labels.code_folding": "Kod Katlama", + "labels.collaborator": "İşbirlikçi", "labels.collaborators": "Katılımcılar", "labels.color": "Renk", "labels.communication": "İletişim", @@ -1081,6 +1093,7 @@ "labels.open": "Aç", "labels.other": "Diğer", "labels.overview": "Genel Bakış", + "labels.owner": "Sahip", "labels.pages": "Sayfalar", "labels.paste": "Yapıştır", "labels.pay_as_you_go": "Kullandıkça Öde", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "{src} yeniden adlandır", "project-start-warning.content": "Projeyi \"{project_title}\" başlatmalısınız, önce {what} yapabilirsiniz. {title}", "project-start-warning.title": "Bu projeyi başlat?", + "project.collaborators.add.owner_only_setting": "Yalnızca proje sahipleri, yalnızca sahip yönetimi etkinleştirildiğinde iş birliği yapabilecek kişileri ekleyebilir.", + "project.collaborators.demote.label": "Ortak Çalışan Olarak Düşür", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {Son sahibi düşürülemez} other {Bu sahibi işbirlikçiye düşür}}", + "project.collaborators.non_owner_note": "Yalnızca proje sahipleri kullanıcı rollerini yönetebilir.", + "project.collaborators.promote.label": "Sahip Olarak Terfi Et", + "project.collaborators.promote.tooltip": "Bu işbirlikçiyi tam proje kontrolü vererek sahip olarak terfi ettir", "project.explorer.action-bar.check_all.button": "{checked, select, true {Tümünü Kaldır} other {Tümünü Seç}}", "project.explorer.action-bar.currently_selected.info": "Bir dosyayı kopyalamak, indirmek vb. için solundaki onay kutusuna tıklayın", "project.explorer.action-bar.currently_selected.items": "{checked} / {total} {items} seçildi", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "Bu projede çalışan bir {name} sunucu örneğinde geçerli dizini açar.", "project.explorer.search-bar.placeholder": "Dosyaları filtrele veya terminal için \"/\"...", "project.explorer.start_project.warning": "Bu dizindeki dosyaları görmek için bu projeyi başlatmalısınız.", + "project.history.log-entry.change_collaborator_type": "{target} öğesi {old_group} grubundan {new_group} grubuna değiştirildi", "project.history.log-entry.invited_user": "davet edilen kullanıcı", "project.history.log-entry.invited_user_via": "davet edilen yeni kullanıcı üzerinden", "project.history.log-entry.miniterm": "çalıştırılan mini terminal komutu {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "İsim (isteğe bağlı)", "project.settings.about-box.starred.help": "Yıldızlı projeler, proje listenizdeki yıldızlı filtre düğmesine tıklanarak filtrelenebilir.", "project.settings.about-box.title.label": "Başlık", + "project.settings.collaborators.title": "Katılımcı Yönetimi", "project.settings.compute-image-selector.button.save-restart": "Kaydet ve Yeniden Başlat", "project.settings.compute-image-selector.doubt": "{default, select, true {Bu varsayılan seçimdir} other {Not: şüphe duyarsanız \"{default_title}\" seçin}}", "project.settings.compute-image-selector.software-env-info": "Bir yazılım ortamı, bu projenin kullanabileceği tüm yazılımları sağlar. Ek yazılıma ihtiyacınız varsa projeye yükleyebilir veya destek ile iletişime geçebilirsiniz. Python paketlerini yükleme, Python Jupyter Çekirdeği, R Paketleri ve Julia paketleri hakkında bilgi edinin.", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {Proje Görünür Yap} other {Proje Gizle}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {Gizli} other {Görünür}}", "project.settings.hide-delete-box.title": "Projeyi Gizle veya Sil", + "project.settings.manage_users_owner_only": "Yalnızca sahiplerin işbirlikçileri yönetmesine izin ver.", + "project.settings.manage_users_owner_only.note": "Bu ayar yalnızca proje sahipleri tarafından değiştirilebilir.", + "project.settings.manage_users_owner_only.site_enforced": "Bu ayar, site yöneticisi tarafından tüm projeler için zorunlu kılınmıştır.", "project.settings.restart-project.button.label": "{is_running, select, true {Yeniden Başlat} other {Başlat}}", "project.settings.site-license.body.info": "Bağlı lisanslar hakkında bilgi. Detayları genişletmek için bir satıra tıklayın.", "project.settings.site-license.button.label": "Lisans anahtarı kullanarak yükselt...", diff --git a/src/packages/frontend/i18n/trans/zh_CN.json b/src/packages/frontend/i18n/trans/zh_CN.json index e0498d5ad55..f3087392392 100644 --- a/src/packages/frontend/i18n/trans/zh_CN.json +++ b/src/packages/frontend/i18n/trans/zh_CN.json @@ -179,8 +179,12 @@ "codemirror.extensions.ai_formula.insert_full_reply_button": "插入完整回复", "codemirror.extensions.ai_formula.title": "生成 LaTeX 公式", "collaborators.current-collabs.intro": "以下列出的所有人都可以在此项目中的任何Jupyter Notebook、Linux Terminal或文件上与你协作,并添加或删除其他协作者。", + "collaborators.current-collabs.intro2": "下列所有人都可以在此项目中与你协作处理任何 Jupyter Notebook、Linux 终端或文件。{manageUsersOnly, select, true { 只有项目所有者可以添加或移除协作者。} other { 协作者也可以添加或移除其他协作者。}}", "collaborators.current-collabs.remove_other": "您确定要将{user}从此项目中移除吗?他们将不再有权访问此项目。", "collaborators.current-collabs.remove_self": "您确定要将自己从此项目中移除吗?您将无法再访问此项目,也无法重新添加自己。", + "collaborators.current-collabs.remove.ok_button": "是的,移除{role}", + "collaborators.current-collabs.remove.owner_disabled": "必须先降级所有者,然后才能将其移除。", + "collaborators.current-collabs.remove.setting_disabled": "启用此设置时,只有所有者可以移除协作者。", "collaborators.current-collabs.title": "当前合作者", "command.format.ai_formula.button": "公式", "command.format.ai_formula.label": "AI生成公式", @@ -636,6 +640,13 @@ "editor.terminal.cmd.help.title": "显示在 CoCalc 中使用 Linux 终端的文档", "editor.toggle_pdf_dark_mode.label": "切换 PDF 暗模式", "editor.toggle_pdf_dark_mode.title": "关闭PDF的暗模式,以查看原始文件", + "errors.ownership.cannot_remove_owner": "无法移除所有者。请先降为合作者。", + "errors.ownership.invalid_project_state": "项目数据缺失或无效。", + "errors.ownership.invalid_requesting_user": "您不是项目成员。", + "errors.ownership.invalid_target": "目标用户不是此项目的成员。", + "errors.ownership.invalid_user": "用户未找到或无效。", + "errors.ownership.last_owner": "无法移除所有者,因为至少需要一个所有者。", + "errors.ownership.not_owner": "只有项目所有者可以更改用户类型。", "file_actions.compress.name": "压缩", "file_actions.copy.name": "复制", "file_actions.create.name": "创建", @@ -984,6 +995,7 @@ "labels.cloud_file_system": "云文件系统", "labels.cloud_storage_remote_filesystems": "云存储和远程文件系统", "labels.code_folding": "代码折叠", + "labels.collaborator": "协作者", "labels.collaborators": "合作者", "labels.color": "颜色", "labels.communication": "通信", @@ -1081,6 +1093,7 @@ "labels.open": "打开", "labels.other": "其他", "labels.overview": "概述", + "labels.owner": "拥有者", "labels.pages": "页面", "labels.paste": "粘贴", "labels.pay_as_you_go": "随用随付", @@ -1288,6 +1301,12 @@ "project_actions.rename_file.what": "重命名 {src}", "project-start-warning.content": "你必须启动项目 \"{project_title}\" 才能 {what}. {title}", "project-start-warning.title": "启动这个项目?", + "project.collaborators.add.owner_only_setting": "仅当启用仅限所有者管理时,项目所有者才能添加协作成员。", + "project.collaborators.demote.label": "降级为合作者", + "project.collaborators.demote.tooltip": "{isLastOwner, select, true {无法降级最后一个所有者} other {将此所有者降级为协作者}}", + "project.collaborators.non_owner_note": "只有项目所有者可以管理用户角色。", + "project.collaborators.promote.label": "提升为所有者", + "project.collaborators.promote.tooltip": "将此协作者提升为所有者,给予他们完整的项目控制权", "project.explorer.action-bar.check_all.button": "{checked, select, true {取消全选} other {全选}}", "project.explorer.action-bar.currently_selected.info": "单击文件左侧的复选框以复制、下载等", "project.explorer.action-bar.currently_selected.items": "已选择 {total} 个中的 {checked} 个 {items}", @@ -1298,6 +1317,7 @@ "project.explorer.misc-side-buttons.open_dir.tooltip": "在此项目中运行的 {name} 服务器实例中打开当前目录", "project.explorer.search-bar.placeholder": "筛选文件或输入“/”进入终端...", "project.explorer.start_project.warning": "要查看此目录中的文件,您必须启动此项目。", + "project.history.log-entry.change_collaborator_type": "将 {target} 从 {old_group} 更改为 {new_group}", "project.history.log-entry.invited_user": "受邀用户", "project.history.log-entry.invited_user_via": "通过邀请新用户", "project.history.log-entry.miniterm": "执行了迷你终端命令 {cmd}", @@ -1390,6 +1410,7 @@ "project.settings.about-box.name.label": "姓名(可选)", "project.settings.about-box.starred.help": "可以通过单击项目列表中的加星过滤按钮来筛选加星项目。", "project.settings.about-box.title.label": "标题", + "project.settings.collaborators.title": "协作者管理", "project.settings.compute-image-selector.button.save-restart": "保存并重启", "project.settings.compute-image-selector.doubt": "{default, select, true {这是默认选择} other {注意:如有疑问,选择\"{default_title}\"}}", "project.settings.compute-image-selector.software-env-info": "软件环境提供所有软件,该项目可以使用。如果您需要额外的软件,可以在项目中安装或联系支持。了解安装 Python 包Python Jupyter 内核R 包Julia 包。", @@ -1413,6 +1434,9 @@ "project.settings.hide-delete-box.hide.label": "{hidden, select, true {取消隐藏项目} other {隐藏项目}}", "project.settings.hide-delete-box.hide.switch": "{hidden, select, true {隐藏} other {可见}}", "project.settings.hide-delete-box.title": "隐藏或删除项目", + "project.settings.manage_users_owner_only": "仅允许所有者管理协作者。", + "project.settings.manage_users_owner_only.note": "此设置只能由项目所有者更改。", + "project.settings.manage_users_owner_only.site_enforced": "该设置由网站管理员强制应用于所有项目。", "project.settings.restart-project.button.label": "{is_running, select, true {重启} other {启动}}", "project.settings.site-license.body.info": "有关附加许可证的信息。单击一行以展开详细信息。", "project.settings.site-license.button.label": "使用许可证密钥升级...", diff --git a/src/packages/frontend/project/history/log-entry.tsx b/src/packages/frontend/project/history/log-entry.tsx index 710a7ddce56..988682361f7 100644 --- a/src/packages/frontend/project/history/log-entry.tsx +++ b/src/packages/frontend/project/history/log-entry.tsx @@ -62,6 +62,10 @@ import type { import { isUnknownEvent } from "./types"; const TRUNC = 90; +type CollaboratorInviteOrRemoveEvent = Extract< + CollaboratorEvent, + { event: "invite_user" | "invite_nonuser" | "remove_collaborator" } +>; // eslint-disable-next-line @typescript-eslint/no-var-requires const { User } = require("@cocalc/frontend/users"); @@ -752,7 +756,9 @@ export const LogEntry: React.FC = React.memo( ); } - function render_invite_user(event: CollaboratorEvent): React.JSX.Element { + function render_invite_user( + event: CollaboratorInviteOrRemoveEvent, + ): React.JSX.Element { return ( = React.memo( } function render_invite_nonuser( - event: CollaboratorEvent, + event: CollaboratorInviteOrRemoveEvent, ): React.JSX.Element { return ( @@ -779,11 +785,10 @@ export const LogEntry: React.FC = React.memo( } function render_remove_collaborator( - event: CollaboratorEvent, + event: CollaboratorInviteOrRemoveEvent, ): React.JSX.Element { return ( - {" "} = React.memo( ); } + function render_change_collaborator_type( + event: Extract, + ): React.JSX.Element { + const groupLabel = (group: "owner" | "collaborator") => + intl.formatMessage({ + id: `project.history.log-entry.group.${group}`, + defaultMessage: group, + }); + + const target = + event.target_name != null ? ( + event.target_name + ) : ( + + ); + + return ( + + + + ); + } + function render_desc(): Rendered | Rendered[] { if (typeof event === "string") { return {event}; @@ -835,11 +871,27 @@ export const LogEntry: React.FC = React.memo( case "pay-as-you-go-upgrade": return render_pay_as_you_go(event); case "invite_user": - return render_invite_user(event); + return render_invite_user( + event as Extract, + ); case "invite_nonuser": - return render_invite_nonuser(event); + return render_invite_nonuser( + event as Extract, + ); case "remove_collaborator": - return render_remove_collaborator(event); + return render_remove_collaborator( + event as Extract< + CollaboratorEvent, + { event: "remove_collaborator" } + >, + ); + case "change_collaborator_type": + return render_change_collaborator_type( + event as Extract< + CollaboratorEvent, + { event: "change_collaborator_type" } + >, + ); case "open_project": // not used anymore??? return opened this project; case "library": @@ -917,6 +969,7 @@ export const LogEntry: React.FC = React.memo( case "invite_user": case "invite_nonuser": case "remove_collaborator": + case "change_collaborator_type": return "user"; case "software_environment": return SOFTWARE_ENVIRONMENT_ICON; @@ -940,7 +993,7 @@ export const LogEntry: React.FC = React.memo( } function renderExtra() { - // flyout mode only: if colum is wider, add timestamp + // flyout mode only: if column is wider, add timestamp if (mode === "flyout" && flyoutExtra) { return ( diff --git a/src/packages/frontend/project/history/types.ts b/src/packages/frontend/project/history/types.ts index 32cbf50df4b..9fffda5a428 100644 --- a/src/packages/frontend/project/history/types.ts +++ b/src/packages/frontend/project/history/types.ts @@ -95,12 +95,20 @@ export type X11Event = { path: string; }; -export type CollaboratorEvent = { - event: "invite_user" | "invite_nonuser" | "remove_collaborator"; - invitee_account_id?: string; - invitee_email?: string; - removed_name?: string; -}; +export type CollaboratorEvent = + | { + event: "invite_user" | "invite_nonuser" | "remove_collaborator"; + invitee_account_id?: string; + invitee_email?: string; + removed_name?: string; + } + | { + event: "change_collaborator_type"; + target_account_id: string; + target_name?: string; + old_group: "owner" | "collaborator"; + new_group: "owner" | "collaborator"; + }; export type UpgradeEvent = { event: "upgrade"; diff --git a/src/packages/frontend/projects/actions.ts b/src/packages/frontend/projects/actions.ts index bfd2959e37b..8d0c6ba2b24 100644 --- a/src/packages/frontend/projects/actions.ts +++ b/src/packages/frontend/projects/actions.ts @@ -41,6 +41,8 @@ import type { } from "@cocalc/util/db-schema/projects"; export type { Datastore, EnvVars, EnvVarsRecord }; +// cSpell:ignore replyto collabs noncloud Payg + // Define projects actions export class ProjectsActions extends Actions { private getProjectTable = async () => { @@ -426,7 +428,7 @@ export class ProjectsActions extends Actions { license: undefined, }); if (!opts2.image) { - // make falseish same as not specified. + // make false-ish same as not specified. delete opts2.image; } @@ -638,19 +640,50 @@ export class ProjectsActions extends Actions { ): Promise { const removed_name = redux.getStore("users").get_name(account_id); try { - await this.redux - .getProjectActions(project_id) - .async_log({ event: "remove_collaborator", removed_name }); await webapp_client.project_collaborators.remove({ project_id, account_id, }); + // Log AFTER successful removal + await this.redux + .getProjectActions(project_id) + .async_log({ event: "remove_collaborator", removed_name }); } catch (err) { const message = `Error removing ${removed_name} from project ${project_id} -- ${err}`; alert_message({ type: "error", message }); } } + public async change_user_type( + project_id: string, + target_account_id: string, + new_group: "owner" | "collaborator", + ): Promise { + const old_group = store + .getIn(["project_map", project_id, "users", target_account_id, "group"]) + ?.toString() as "owner" | "collaborator" | undefined; + const target_name = redux.getStore("users").get_name(target_account_id); + try { + await webapp_client.project_collaborators.change_user_type({ + project_id, + target_account_id, + new_group, + }); + // Log AFTER successful change + await this.redux.getProjectActions(project_id).async_log({ + event: "change_collaborator_type", + target_account_id, + target_name, + old_group: (old_group ?? new_group) as "owner" | "collaborator", + new_group, + }); + } catch (err) { + const message = `Error changing ${target_name} to ${new_group} in project ${project_id} -- ${err}`; + alert_message({ type: "error", message }); + throw err; + } + } + // this is for inviting existing users, the email is only known by the back-end public async invite_collaborator( project_id: string, @@ -661,11 +694,6 @@ export class ProjectsActions extends Actions { replyto?: string, replyto_name?: string, ): Promise { - await this.redux.getProjectActions(project_id).async_log({ - event: "invite_user", - invitee_account_id: account_id, - }); - const title = store.get_title(project_id); const link2proj = `https://${window.location.hostname}/projects/${project_id}/`; // convert body from markdown to html, which is what the backend expects @@ -682,6 +710,11 @@ export class ProjectsActions extends Actions { email, subject, }); + // Log AFTER successful invite + await this.redux.getProjectActions(project_id).async_log({ + event: "invite_user", + invitee_account_id: account_id, + }); } catch (err) { if (!silent) { const message = `Error inviting collaborator ${account_id} from ${project_id} -- ${err}`; @@ -700,11 +733,6 @@ export class ProjectsActions extends Actions { replyto: string | undefined, replyto_name: string | undefined, ): Promise { - await this.redux.getProjectActions(project_id).async_log({ - event: "invite_nonuser", - invitee_email: to, - }); - const title = store.get_title(project_id); if (body == null) { const name = this.redux.getStore("account").get_fullname(); @@ -724,6 +752,11 @@ export class ProjectsActions extends Actions { email, subject, }); + // Log AFTER successful invite + await this.redux.getProjectActions(project_id).async_log({ + event: "invite_nonuser", + invitee_email: to, + }); if (!silent) { alert_message({ message: `Invited ${to} to collaborate on project.`, @@ -1050,7 +1083,7 @@ export class ProjectsActions extends Actions { await this.start_project(project_id, options); }; - // Explcitly set whether or not project is hidden for the given account + // Explicitly set whether or not project is hidden for the given account // (hide=true means hidden) public async set_project_hide( account_id: string, diff --git a/src/packages/next/lib/api/schema/projects/collaborators/change-user-type.ts b/src/packages/next/lib/api/schema/projects/collaborators/change-user-type.ts new file mode 100644 index 00000000000..2a357e07eb2 --- /dev/null +++ b/src/packages/next/lib/api/schema/projects/collaborators/change-user-type.ts @@ -0,0 +1,36 @@ +import { z } from "../../../framework"; + +import { FailedAPIOperationSchema, OkAPIOperationSchema } from "../../common"; + +import { ProjectIdSchema } from "../common"; +import { AccountIdSchema } from "../../accounts/common"; + +export const UserGroupSchema = z + .enum(["owner", "collaborator"]) + .describe("Project user role (owner or collaborator)."); + +export const ChangeProjectUserTypeInputSchema = z + .object({ + project_id: ProjectIdSchema, + target_account_id: AccountIdSchema.describe( + "Account id of the user whose role will be changed.", + ), + new_group: UserGroupSchema.describe( + "New role to assign; must be owner or collaborator.", + ), + }) + .describe( + "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).", + ); + +export const ChangeProjectUserTypeOutputSchema = z.union([ + FailedAPIOperationSchema, + OkAPIOperationSchema, +]); + +export type ChangeProjectUserTypeInput = z.infer< + typeof ChangeProjectUserTypeInputSchema +>; +export type ChangeProjectUserTypeOutput = z.infer< + typeof ChangeProjectUserTypeOutputSchema +>; diff --git a/src/packages/next/lib/project/get-owner.ts b/src/packages/next/lib/project/get-owner.ts index ce548576bde..3e54a12b618 100644 --- a/src/packages/next/lib/project/get-owner.ts +++ b/src/packages/next/lib/project/get-owner.ts @@ -1,24 +1,42 @@ import getPool from "@cocalc/database/pool"; -// Returns account_id or organization_id of the owner of this project. -export default async function getOwner(project_id: string): Promise { - const pool = getPool("minutes"); // we don't even have a way to change the owner ever in cocalc. +type Users = { [account_id: string]: { group?: string } }; - // TODO: this seems *really* stupid/inefficient in general, e.g., what if - // there are 1000 users? I don't know JSONB PostgreSQL enough to come up - // with a better query... +async function getProjectUsers(project_id: string): Promise { + const pool = getPool("minutes"); const result = await pool.query( "SELECT users FROM projects WHERE project_id=$1", - [project_id] + [project_id], ); - if (result.rows.length == 0) { + if (result.rows.length === 0) { throw Error(`no project with id ${project_id}`); } const { users } = result.rows[0] ?? {}; + return users ?? {}; +} + +function collectOwnerIds(users: Users): string[] { + const owners: string[] = []; for (const account_id in users) { - if (users[account_id].group == "owner") { - return account_id; + if (users[account_id]?.group === "owner") { + owners.push(account_id); } } - throw Error(`project ${project_id} has no owner`); + return owners; +} + +// Returns account_id or organization_id of the first owner found for this project. +// NOTE: Projects may have multiple owners; use getOwners() to get all owners. +export default async function getOwner(project_id: string): Promise { + const owners = await getOwners(project_id); + return owners[0]; +} + +// Returns all account_ids of owners for this project. +export async function getOwners(project_id: string): Promise { + const owners = collectOwnerIds(await getProjectUsers(project_id)); + if (owners.length === 0) { + throw Error(`project ${project_id} has no owner`); + } + return owners; } diff --git a/src/packages/next/pages/api/v2/projects/collaborators/change-user-type.test.ts b/src/packages/next/pages/api/v2/projects/collaborators/change-user-type.test.ts new file mode 100644 index 00000000000..3303f015018 --- /dev/null +++ b/src/packages/next/pages/api/v2/projects/collaborators/change-user-type.test.ts @@ -0,0 +1,76 @@ +/** @jest-environment node */ + +import { createMocks } from "lib/api/test-framework"; +import handler from "./change-user-type"; + +jest.mock("@cocalc/server/projects/collaborators", () => ({ + changeUserType: jest.fn(), +})); +jest.mock("lib/account/get-account", () => jest.fn()); + +describe("/api/v2/projects/collaborators/change-user-type", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("unauthenticated request returns error", async () => { + const getAccountId = require("lib/account/get-account"); + getAccountId.mockResolvedValue(undefined); + + const { req, res } = createMocks({ + method: "POST", + url: "/api/v2/projects/collaborators/change-user-type", + body: { + project_id: "00000000-0000-0000-0000-000000000000", + target_account_id: "11111111-1111-1111-1111-111111111111", + new_group: "owner", + }, + }); + + await expect(handler(req, res)).resolves.not.toThrow(); + + const collaborators = require("@cocalc/server/projects/collaborators"); + expect(collaborators.changeUserType).not.toHaveBeenCalled(); + + const data = res._getJSONData(); + expect(data).toHaveProperty("error"); + expect(data.error).toContain("signed in"); + }); + + test("authenticated request calls changeUserType", async () => { + const mockAccountId = "22222222-2222-2222-2222-222222222222"; + const mockProjectId = "00000000-0000-0000-0000-000000000000"; + const mockTargetAccountId = "11111111-1111-1111-1111-111111111111"; + + const getAccountId = require("lib/account/get-account"); + getAccountId.mockResolvedValue(mockAccountId); + + const collaborators = require("@cocalc/server/projects/collaborators"); + collaborators.changeUserType.mockResolvedValue(undefined); + + const { req, res } = createMocks({ + method: "POST", + url: "/api/v2/projects/collaborators/change-user-type", + body: { + project_id: mockProjectId, + target_account_id: mockTargetAccountId, + new_group: "collaborator", + }, + }); + + await handler(req, res); + + expect(collaborators.changeUserType).toHaveBeenCalledWith({ + account_id: mockAccountId, + opts: { + project_id: mockProjectId, + target_account_id: mockTargetAccountId, + new_group: "collaborator", + }, + }); + + const data = res._getJSONData(); + expect(data).toHaveProperty("status"); + expect(data.status).toBe("ok"); + }); +}); diff --git a/src/packages/next/pages/api/v2/projects/collaborators/change-user-type.ts b/src/packages/next/pages/api/v2/projects/collaborators/change-user-type.ts new file mode 100644 index 00000000000..d8f2166bcfc --- /dev/null +++ b/src/packages/next/pages/api/v2/projects/collaborators/change-user-type.ts @@ -0,0 +1,58 @@ +/* +API endpoint to change a user's collaborator type on an existing project. + +Permissions checks are performed by the underlying API call and are NOT +executed at this stage. + +*/ +import { changeUserType } from "@cocalc/server/projects/collaborators"; + +import getAccountId from "lib/account/get-account"; +import getParams from "lib/api/get-params"; +import { apiRoute, apiRouteOperation } from "lib/api"; +import { OkStatus } from "lib/api/status"; +import { + ChangeProjectUserTypeInputSchema, + ChangeProjectUserTypeOutputSchema, +} from "lib/api/schema/projects/collaborators/change-user-type"; + +async function handle(req, res) { + const { project_id, target_account_id, new_group } = getParams(req); + const client_account_id = await getAccountId(req); + + try { + if (!client_account_id) { + throw Error("must be signed in"); + } + + await changeUserType({ + account_id: client_account_id, + opts: { project_id, target_account_id, new_group }, + }); + + res.json(OkStatus); + } catch (err) { + res.json({ error: err.message }); + } +} + +export default apiRoute({ + changeProjectUserType: apiRouteOperation({ + method: "POST", + openApiOperation: { + tags: ["Projects", "Admin"], + }, + }) + .input({ + contentType: "application/json", + body: ChangeProjectUserTypeInputSchema, + }) + .outputs([ + { + status: 200, + contentType: "application/json", + body: ChangeProjectUserTypeOutputSchema, + }, + ]) + .handler(handle), +}); diff --git a/src/packages/server/compute/control.test.ts b/src/packages/server/compute/control.test.ts index 82449e24e06..3ebec52bf0f 100644 --- a/src/packages/server/compute/control.test.ts +++ b/src/packages/server/compute/control.test.ts @@ -1,10 +1,12 @@ +import { delay } from "awaiting"; + import getPool, { initEphemeralDatabase } from "@cocalc/database/pool"; import { uuid } from "@cocalc/util/misc"; import createAccount from "@cocalc/server/accounts/create-account"; import createProject from "@cocalc/server/projects/create"; import createServer from "./create-server"; import * as control from "./control"; -import { delay } from "awaiting"; +import { waitToAvoidTestFailure } from "@cocalc/server/test-utils"; beforeAll(async () => { await initEphemeralDatabase(); @@ -25,12 +27,15 @@ describe("creates account, project and a test compute server, then control it", firstName: "User", lastName: "One", account_id, + noFirstProject: true, }); // Only User One: project_id = await createProject({ account_id, title: "My First Project", + start: false, }); + await waitToAvoidTestFailure(); }); let id; @@ -45,6 +50,7 @@ describe("creates account, project and a test compute server, then control it", project_id, ...s, }); + await waitToAvoidTestFailure(); }); it("start the server", async () => { diff --git a/src/packages/server/compute/create-server.test.ts b/src/packages/server/compute/create-server.test.ts index 86870f86059..580fa4075d6 100644 --- a/src/packages/server/compute/create-server.test.ts +++ b/src/packages/server/compute/create-server.test.ts @@ -5,6 +5,7 @@ import createAccount from "@cocalc/server/accounts/create-account"; import createProject from "@cocalc/server/projects/create"; import createServer from "./create-server"; import { CLOUDS_BY_NAME } from "@cocalc/util/db-schema/compute-servers"; +import { waitToAvoidTestFailure } from "@cocalc/server/test-utils"; beforeAll(async () => { await initEphemeralDatabase(); @@ -25,12 +26,15 @@ describe("creates account, project and then compute servers in various ways", () firstName: "User", lastName: "One", account_id, + noFirstProject: true, }); // Only User One: project_id = await createProject({ account_id, title: "My First Project", + start: false, }); + await waitToAvoidTestFailure(); }); it("creates a compute server for project one and gets it", async () => { @@ -38,6 +42,7 @@ describe("creates account, project and then compute servers in various ways", () account_id, project_id, }); + await waitToAvoidTestFailure(); expect( await getServers({ @@ -80,6 +85,7 @@ describe("creates account, project and then compute servers in various ways", () project_id, ...s, }); + await waitToAvoidTestFailure(); expect( await getServers({ account_id, diff --git a/src/packages/server/compute/get-servers.test.ts b/src/packages/server/compute/get-servers.test.ts index a4b60a217c9..77ac79d4cb8 100644 --- a/src/packages/server/compute/get-servers.test.ts +++ b/src/packages/server/compute/get-servers.test.ts @@ -5,6 +5,7 @@ import createAccount from "@cocalc/server/accounts/create-account"; import createProject from "@cocalc/server/projects/create"; import addUserToProject from "@cocalc/server/projects/add-user-to-project"; import createServer from "./create-server"; +import { waitToAvoidTestFailure } from "@cocalc/server/test-utils"; beforeAll(async () => { await initEphemeralDatabase(); @@ -60,6 +61,7 @@ describe("creates accounts, projects, compute servers, and tests querying", () = firstName: "User", lastName: "One", account_id: account_id1, + noFirstProject: true, }); await createAccount({ email: "", @@ -67,17 +69,22 @@ describe("creates accounts, projects, compute servers, and tests querying", () = firstName: "User", lastName: "Two", account_id: account_id2, + noFirstProject: true, }); // Only User One: project_id1 = await createProject({ account_id: account_id1, title: "My First Project", + start: false, }); + await waitToAvoidTestFailure(); // Both users project_id2 = await createProject({ account_id: account_id2, title: "My Second Project", + start: false, }); + await waitToAvoidTestFailure(); await addUserToProject({ account_id: account_id1, project_id: project_id2, @@ -105,6 +112,7 @@ describe("creates accounts, projects, compute servers, and tests querying", () = account_id: account_id1, project_id: project_id1, }); + await waitToAvoidTestFailure(); expect( await getServers({ @@ -182,6 +190,7 @@ describe("creates accounts, projects, compute servers, and tests querying", () = account_id: account_id2, project_id: project_id2, }); + await waitToAvoidTestFailure(); expect( await getServers({ diff --git a/src/packages/server/compute/maintenance/purchases/close.test.ts b/src/packages/server/compute/maintenance/purchases/close.test.ts index b413b1b852f..3e2cd5b67fd 100644 --- a/src/packages/server/compute/maintenance/purchases/close.test.ts +++ b/src/packages/server/compute/maintenance/purchases/close.test.ts @@ -17,6 +17,7 @@ import { closePurchase, } from "./close"; import { getPurchase } from "./util"; +import { waitToAvoidTestFailure } from "@cocalc/server/test-utils"; beforeAll(async () => { await initEphemeralDatabase(); @@ -39,12 +40,15 @@ describe("creates account, project, test compute server, and purchase, then clos firstName: "User", lastName: "One", account_id, + noFirstProject: true, }); // Only User One: project_id = await createProject({ account_id, title: "My First Project", + start: false, }); + await waitToAvoidTestFailure(); }); it("creates compute server on the 'test' cloud", async () => { @@ -58,6 +62,7 @@ describe("creates account, project, test compute server, and purchase, then clos project_id, ...s, }); + await waitToAvoidTestFailure(); }); it("creates a purchase", async () => { diff --git a/src/packages/server/compute/maintenance/purchases/manage-purchases.test.ts b/src/packages/server/compute/maintenance/purchases/manage-purchases.test.ts index 5259ea5bc80..acd2a03c6be 100644 --- a/src/packages/server/compute/maintenance/purchases/manage-purchases.test.ts +++ b/src/packages/server/compute/maintenance/purchases/manage-purchases.test.ts @@ -2,6 +2,8 @@ Test managing purchases */ +import { delay } from "awaiting"; + import getPool, { initEphemeralDatabase } from "@cocalc/database/pool"; import createAccount from "@cocalc/server/accounts/create-account"; import { setTestNetworkUsage } from "@cocalc/server/compute/control"; @@ -13,7 +15,7 @@ import createPurchase from "@cocalc/server/purchases/create-purchase"; import { setPurchaseQuota } from "@cocalc/server/purchases/purchase-quotas"; import { ComputeServer } from "@cocalc/util/db-schema/purchases"; import { uuid } from "@cocalc/util/misc"; -import { delay } from "awaiting"; +import { waitToAvoidTestFailure } from "@cocalc/server/test-utils"; import managePurchases, { MAX_NETWORK_USAGE_UPDATE_INTERVAL_MS, MAX_PURCHASE_LENGTH_MS, @@ -60,11 +62,14 @@ describe("confirm managing of purchases works", () => { firstName: "User", lastName: "One", account_id, + noFirstProject: true, }); project_id = await createProject({ account_id, title: "My First Project", + start: false, }); + await waitToAvoidTestFailure(); const s = { title: "myserver", idle_timeout: 15, @@ -75,6 +80,7 @@ describe("confirm managing of purchases works", () => { project_id, ...s, }); + await waitToAvoidTestFailure(); // give user $10. await createPurchase({ account_id, diff --git a/src/packages/server/compute/maintenance/purchases/ongoing-purchases.test.ts b/src/packages/server/compute/maintenance/purchases/ongoing-purchases.test.ts index 360cb22a0e3..d8952f00ba3 100644 --- a/src/packages/server/compute/maintenance/purchases/ongoing-purchases.test.ts +++ b/src/packages/server/compute/maintenance/purchases/ongoing-purchases.test.ts @@ -20,6 +20,7 @@ import { MAX_PURCHASE_LENGTH_MS, } from "./manage-purchases"; import createPurchase from "@cocalc/server/purchases/create-purchase"; +import { waitToAvoidTestFailure } from "@cocalc/server/test-utils"; beforeAll(async () => { await initEphemeralDatabase(); @@ -49,12 +50,15 @@ describe("creates account, project, test compute server, and purchase", () => { firstName: "User", lastName: "One", account_id, + noFirstProject: true, }); // Only User One: project_id = await createProject({ account_id, title: "My First Project", + start: false, }); + await waitToAvoidTestFailure(); }); let id; @@ -69,6 +73,7 @@ describe("creates account, project, test compute server, and purchase", () => { project_id, ...s, }); + await waitToAvoidTestFailure(); }); it("runs ongoingPurchases and confirms that our new server did NOT get flagged", async () => { diff --git a/src/packages/server/compute/maintenance/purchases/util.test.ts b/src/packages/server/compute/maintenance/purchases/util.test.ts index f0545790350..294ac1b9581 100644 --- a/src/packages/server/compute/maintenance/purchases/util.test.ts +++ b/src/packages/server/compute/maintenance/purchases/util.test.ts @@ -5,6 +5,7 @@ import createProject from "@cocalc/server/projects/create"; import createServer from "@cocalc/server/compute/create-server"; import { getServer } from "@cocalc/server/compute/get-servers"; import { setPurchaseId } from "./util"; +import { waitToAvoidTestFailure } from "@cocalc/server/test-utils"; beforeAll(async () => { await initEphemeralDatabase(); @@ -26,12 +27,15 @@ describe("creates compute server then sets the purchase id and confirms it", () firstName: "User", lastName: "One", account_id, + noFirstProject: true, }); // Only User One: project_id = await createProject({ account_id, title: "My First Project", + start: false, }); + await waitToAvoidTestFailure(); const s = { title: "myserver", idle_timeout: 15, @@ -42,6 +46,7 @@ describe("creates compute server then sets the purchase id and confirms it", () project_id, ...s, }); + await waitToAvoidTestFailure(); }); it("set purchase id and verify it", async () => { diff --git a/src/packages/server/licenses/add-to-project.test.ts b/src/packages/server/licenses/add-to-project.test.ts index 1c19b76764b..1691ff117ce 100644 --- a/src/packages/server/licenses/add-to-project.test.ts +++ b/src/packages/server/licenses/add-to-project.test.ts @@ -24,12 +24,13 @@ describe("test various cases of adding a license to a project", () => { project_id = await createProject({ account_id: uuid(), title: "My First Project", + start: false, }); await addLicenseToProject({ project_id, license_id }); const pool = getPool(); const { rows } = await pool.query( "SELECT site_license FROM projects WHERE project_id=$1", - [project_id] + [project_id], ); expect(rows[0].site_license).toEqual({ [license_id]: {} }); }); @@ -39,7 +40,7 @@ describe("test various cases of adding a license to a project", () => { const pool = getPool(); const { rows } = await pool.query( "SELECT site_license FROM projects WHERE project_id=$1", - [project_id] + [project_id], ); expect(rows[0].site_license).toEqual({ [license_id]: {} }); }); @@ -50,7 +51,7 @@ describe("test various cases of adding a license to a project", () => { const pool = getPool(); const { rows } = await pool.query( "SELECT site_license FROM projects WHERE project_id=$1", - [project_id] + [project_id], ); expect(rows[0].site_license).toEqual({ [license_id]: {}, @@ -62,12 +63,12 @@ describe("test various cases of adding a license to a project", () => { const pool = getPool(); await pool.query( "UPDATE projects SET site_license='{}' WHERE project_id=$1", - [project_id] + [project_id], ); await addLicenseToProject({ project_id, license_id }); const { rows } = await pool.query( "SELECT site_license FROM projects WHERE project_id=$1", - [project_id] + [project_id], ); expect(rows[0].site_license).toEqual({ [license_id]: {} }); }); diff --git a/src/packages/server/llm/test/models.test.ts b/src/packages/server/llm/test/models.test.ts index f59700a0d70..8827c4c3ee1 100644 --- a/src/packages/server/llm/test/models.test.ts +++ b/src/packages/server/llm/test/models.test.ts @@ -315,6 +315,7 @@ describe("User-defined LLMs", () => { firstName: "Test", lastName: "User", account_id, + noFirstProject: true, }); accountCreated = true; } diff --git a/src/packages/server/projects/collab.ts b/src/packages/server/projects/collab.ts index c638f2f9ec1..d7d4e1e6932 100644 --- a/src/packages/server/projects/collab.ts +++ b/src/packages/server/projects/collab.ts @@ -1,5 +1,5 @@ /* - * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. + * This file is part of CoCalc: Copyright © 2020 - 2025 Sagemath, Inc. * License: MS-RSL – see LICENSE.md for details */ @@ -8,6 +8,10 @@ import { is_array, is_valid_uuid_string } from "@cocalc/util/misc"; import { callback2 } from "@cocalc/util/async-utils"; import isSandbox from "@cocalc/server/projects/is-sandbox"; import idleSandboxUsers from "@cocalc/server/projects/idle-sandbox-users"; +import { + ensureCanManageCollaborators, + ensureCanRemoveUser, +} from "./ownership-checks"; const GROUPS = ["owner", "collaborator"] as const; @@ -40,6 +44,18 @@ export async function add_collaborators_to_projects( Also, the input is uuid's, which typescript can't check. */ verify_types(account_id, accounts, projects); + // Check strict_collaborator_management setting before database changes + // Only check if not using tokens (tokens have their own permission system) + if (!tokens) { + for (const project_id of new Set(projects)) { + if (!project_id) continue; // skip empty strings + await ensureCanManageCollaborators({ + project_id, + account_id, + }); + } + } + // We now know that account_id is allowed to add users to all of the projects, // *OR* at that there are valid tokens to permit adding users. @@ -71,7 +87,7 @@ export async function remove_collaborators_from_projects( db: PostgreSQL, account_id: string, accounts: string[], - projects: string[], // can be empty strings if tokens specified (since they determine project_id) + projects: string[], ): Promise { try { // Ensure user is allowed to modify project(s) @@ -92,6 +108,31 @@ export async function remove_collaborators_from_projects( Also, the input is uuid's, which typescript can't check. */ verify_types(account_id, accounts, projects); + // Check strict_collaborator_management setting before database changes + // Skip check if the user is only removing themselves + const is_self_remove_only = + accounts.length === 1 && accounts[0] === account_id; + if (!is_self_remove_only) { + for (const project_id of new Set(projects)) { + if (!project_id) continue; // skip empty strings + await ensureCanManageCollaborators({ + project_id, + account_id, + }); + } + } + + // CRITICAL: Verify that no target users are owners + // Owners must be demoted to collaborator first, then removed + for (const i in projects) { + const project_id: string = projects[i]; + const target_account_id: string = accounts[i]; + await ensureCanRemoveUser({ + project_id, + target_account_id, + }); + } + // Remove users from projects // for (const i in projects) { diff --git a/src/packages/server/projects/collaborators-ownership.test.ts b/src/packages/server/projects/collaborators-ownership.test.ts new file mode 100644 index 00000000000..9101b1d94b7 --- /dev/null +++ b/src/packages/server/projects/collaborators-ownership.test.ts @@ -0,0 +1,572 @@ +/* + * This file is part of CoCalc: Copyright © 2025 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import { callback2 } from "@cocalc/util/async-utils"; +import { uuid } from "@cocalc/util/misc"; +import getPool, { initEphemeralDatabase } from "@cocalc/database/pool"; +import { db } from "@cocalc/database"; +import createAccount from "@cocalc/server/accounts/create-account"; +import createProject from "@cocalc/server/projects/create"; +import { + addCollaborator, + changeUserType, + inviteCollaborator, + inviteCollaboratorWithoutAccount, + removeCollaborator, +} from "@cocalc/server/projects/collaborators"; +import { + add_collaborators_to_projects, + remove_collaborators_from_projects, +} from "@cocalc/server/projects/collab"; +import { + resetServerSettingsCache, + getServerSettings, +} from "@cocalc/database/settings/server-settings"; +import { OwnershipErrorCode } from "@cocalc/util/project-ownership"; + +async function setSiteStrictCollab(value: "yes" | "no") { + await callback2(db().set_server_setting, { + name: "strict_collaborator_management", + value, + readonly: false, + }); + resetServerSettingsCache(); + await getServerSettings(); // warm cache +} + +beforeAll(async () => { + await initEphemeralDatabase(); +}, 15000); + +afterAll(async () => { + await getPool().end(); +}); + +afterEach(async () => { + await setSiteStrictCollab("no"); +}); + +async function createUser(emailPrefix: string): Promise { + const account_id = uuid(); + await createAccount({ + email: `${emailPrefix}-${account_id}@example.com`, + password: "pass", + firstName: "Test", + lastName: "User", + account_id, + noFirstProject: true, + }); + return account_id; +} + +async function createProjectWithOwner(): Promise<{ + ownerId: string; + projectId: string; +}> { + const ownerId = await createUser("owner"); + const projectId = await createProject({ + account_id: ownerId, + title: "Ownership test project", + start: false, + }); + return { ownerId, projectId }; +} + +describe("changeUserType validations", () => { + test("owner can promote collaborator to owner", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorId }, + }); + + await expect( + changeUserType({ + account_id: ownerId, + opts: { + project_id: projectId, + target_account_id: collaboratorId, + new_group: "owner", + }, + }), + ).resolves.toBeUndefined(); + + const { rows } = await getPool().query( + "SELECT users FROM projects WHERE project_id=$1", + [projectId], + ); + expect(rows[0].users[collaboratorId].group).toBe("owner"); + }); + + test("owner can demote another owner when not last owner", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const secondOwnerId = await createUser("owner"); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: secondOwnerId }, + }); + await changeUserType({ + account_id: ownerId, + opts: { + project_id: projectId, + target_account_id: secondOwnerId, + new_group: "owner", + }, + }); + + await expect( + changeUserType({ + account_id: ownerId, + opts: { + project_id: projectId, + target_account_id: secondOwnerId, + new_group: "collaborator", + }, + }), + ).resolves.toBeUndefined(); + + const { rows } = await getPool().query( + "SELECT users FROM projects WHERE project_id=$1", + [projectId], + ); + expect(rows[0].users[secondOwnerId].group).toBe("collaborator"); + }); + + test("cannot demote last owner", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + await expect( + changeUserType({ + account_id: ownerId, + opts: { + project_id: projectId, + target_account_id: ownerId, + new_group: "collaborator", + }, + }), + ).rejects.toMatchObject({ code: OwnershipErrorCode.LAST_OWNER }); + }); + + test("collaborator cannot change user types", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorId }, + }); + + await expect( + changeUserType({ + account_id: collaboratorId, + opts: { + project_id: projectId, + target_account_id: ownerId, + new_group: "collaborator", + }, + }), + ).rejects.toMatchObject({ code: OwnershipErrorCode.NOT_OWNER }); + }); + + test("project not found throws invalid project state", async () => { + const accountId = await createUser("owner"); + await expect( + changeUserType({ + account_id: accountId, + opts: { + project_id: uuid(), + target_account_id: uuid(), + new_group: "owner", + }, + }), + ).rejects.toMatchObject({ + code: OwnershipErrorCode.INVALID_REQUESTING_USER, + }); + }); + + test("target not in project throws invalid user", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + await expect( + changeUserType({ + account_id: ownerId, + opts: { + project_id: projectId, + target_account_id: uuid(), + new_group: "owner", + }, + }), + ).rejects.toMatchObject({ code: OwnershipErrorCode.INVALID_USER }); + }); +}); + +describe("removal rules", () => { + test("self removal allowed when manage_users_owner_only is true", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorId }, + }); + await getPool().query( + "UPDATE projects SET manage_users_owner_only=$1 WHERE project_id=$2", + [true, projectId], + ); + + await expect( + removeCollaborator({ + account_id: collaboratorId, + opts: { project_id: projectId, account_id: collaboratorId }, + }), + ).resolves.toBeUndefined(); + }); + + test("cannot remove an owner", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + await expect( + removeCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: ownerId }, + }), + ).rejects.toMatchObject({ code: OwnershipErrorCode.CANNOT_REMOVE_OWNER }); + }); +}); + +describe("site setting override", () => { + test("site strict setting overrides project flag", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + const otherCollabId = await createUser("collab"); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorId }, + }); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: otherCollabId }, + }); + await getPool().query( + "UPDATE projects SET manage_users_owner_only=$1 WHERE project_id=$2", + [false, projectId], + ); + await setSiteStrictCollab("yes"); + + await expect( + removeCollaborator({ + account_id: collaboratorId, + opts: { project_id: projectId, account_id: otherCollabId }, + }), + ).rejects.toMatchObject({ code: OwnershipErrorCode.NOT_OWNER }); + }); +}); + +describe("invite permissions", () => { + test("owner can invite when manage_users_owner_only is true", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const inviteeId = await createUser("invitee"); + await getPool().query( + "UPDATE projects SET manage_users_owner_only=$1 WHERE project_id=$2", + [true, projectId], + ); + + await expect( + inviteCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: inviteeId }, + }), + ).resolves.toBeUndefined(); + }); + + test("collaborator cannot invite when manage_users_owner_only is true", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + const inviteeId = await createUser("invitee"); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorId }, + }); + await getPool().query( + "UPDATE projects SET manage_users_owner_only=$1 WHERE project_id=$2", + [true, projectId], + ); + + await expect( + inviteCollaborator({ + account_id: collaboratorId, + opts: { project_id: projectId, account_id: inviteeId }, + }), + ).rejects.toMatchObject({ code: OwnershipErrorCode.NOT_OWNER }); + }); + + test("collaborator cannot invite non-user when manage_users_owner_only is true", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorId }, + }); + await getPool().query( + "UPDATE projects SET manage_users_owner_only=$1 WHERE project_id=$2", + [true, projectId], + ); + + await expect( + inviteCollaboratorWithoutAccount({ + account_id: collaboratorId, + opts: { + project_id: projectId, + email: "newuser@example.com", + title: "Invite", + link2proj: "", + to: "newuser@example.com", + }, + }), + ).rejects.toMatchObject({ code: OwnershipErrorCode.NOT_OWNER }); + }); +}); + +describe("REST API level protection (collab.ts functions)", () => { + test("add_collaborators_to_projects bypasses check when using tokens", async () => { + const { projectId } = await createProjectWithOwner(); + const outsideUserId = await createUser("outsider"); + + // Create a project invite token + const { rows } = await getPool().query( + "INSERT INTO project_invite_tokens (token, project_id, usage_limit) VALUES ($1, $2, $3) RETURNING token", + ["test-token-123", projectId, 10], + ); + const token = rows[0].token; + + // Enable site-wide strict collaborator management + await setSiteStrictCollab("yes"); + + // Outside user should be able to add themselves using a token + // even though they're not a collaborator or owner + await expect( + add_collaborators_to_projects( + db(), + outsideUserId, + [outsideUserId], + [""], // empty project_id because token determines the project + [token], + ), + ).resolves.toBeUndefined(); + + // Verify the user was actually added + const { rows: projectRows } = await getPool().query( + "SELECT users FROM projects WHERE project_id=$1", + [projectId], + ); + expect(projectRows[0].users[outsideUserId]).toBeDefined(); + }); + + test("add_collaborators_to_projects enforces strict_collaborator_management site setting", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + const newCollabId = await createUser("newcollab"); + + // Add first collaborator as owner + await add_collaborators_to_projects( + db(), + ownerId, + [collaboratorId], + [projectId], + ); + + // Enable site-wide strict collaborator management + await setSiteStrictCollab("yes"); + + // Collaborator should not be able to add another user + await expect( + add_collaborators_to_projects( + db(), + collaboratorId, + [newCollabId], + [projectId], + ), + ).rejects.toThrow("Only owners can manage collaborators"); + }); + + test("add_collaborators_to_projects enforces project-level manage_users_owner_only", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + const newCollabId = await createUser("newcollab"); + + // Add first collaborator as owner + await add_collaborators_to_projects( + db(), + ownerId, + [collaboratorId], + [projectId], + ); + + // Enable project-level strict management + await getPool().query( + "UPDATE projects SET manage_users_owner_only=$1 WHERE project_id=$2", + [true, projectId], + ); + + // Collaborator should not be able to add another user + await expect( + add_collaborators_to_projects( + db(), + collaboratorId, + [newCollabId], + [projectId], + ), + ).rejects.toThrow("Only owners can manage collaborators"); + }); + + test("remove_collaborators_from_projects enforces strict_collaborator_management site setting", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaborator1Id = await createUser("collab1"); + const collaborator2Id = await createUser("collab2"); + + // Add collaborators as owner + await add_collaborators_to_projects( + db(), + ownerId, + [collaborator1Id, collaborator2Id], + [projectId, projectId], + ); + + // Enable site-wide strict collaborator management + await setSiteStrictCollab("yes"); + + // Collaborator should not be able to remove another collaborator + await expect( + remove_collaborators_from_projects( + db(), + collaborator1Id, + [collaborator2Id], + [projectId], + ), + ).rejects.toThrow("Only owners can manage collaborators"); + }); + + test("remove_collaborators_from_projects allows self-removal even with strict_collaborator_management", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + + // Add collaborator as owner + await add_collaborators_to_projects( + db(), + ownerId, + [collaboratorId], + [projectId], + ); + + // Enable site-wide strict collaborator management + await setSiteStrictCollab("yes"); + + // Collaborator should be able to remove themselves + await expect( + remove_collaborators_from_projects( + db(), + collaboratorId, + [collaboratorId], + [projectId], + ), + ).resolves.toBeUndefined(); + }); + + test("remove_collaborators_from_projects enforces project-level manage_users_owner_only", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaborator1Id = await createUser("collab1"); + const collaborator2Id = await createUser("collab2"); + + // Add collaborators as owner + await add_collaborators_to_projects( + db(), + ownerId, + [collaborator1Id, collaborator2Id], + [projectId, projectId], + ); + + // Enable project-level strict management + await getPool().query( + "UPDATE projects SET manage_users_owner_only=$1 WHERE project_id=$2", + [true, projectId], + ); + + // Collaborator should not be able to remove another collaborator + await expect( + remove_collaborators_from_projects( + db(), + collaborator1Id, + [collaborator2Id], + [projectId], + ), + ).rejects.toThrow("Only owners can manage collaborators"); + }); + + test("add_collaborators_to_projects allows owners to add when strict management is enabled", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const newCollabId = await createUser("newcollab"); + + // Enable site-wide strict collaborator management + await setSiteStrictCollab("yes"); + + // Owner should be able to add a collaborator + await expect( + add_collaborators_to_projects(db(), ownerId, [newCollabId], [projectId]), + ).resolves.toBeUndefined(); + }); + + test("remove_collaborators_from_projects allows owners to remove when strict management is enabled", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const collaboratorId = await createUser("collab"); + + // Add collaborator + await add_collaborators_to_projects( + db(), + ownerId, + [collaboratorId], + [projectId], + ); + + // Enable site-wide strict collaborator management + await setSiteStrictCollab("yes"); + + // Owner should be able to remove a collaborator + await expect( + remove_collaborators_from_projects( + db(), + ownerId, + [collaboratorId], + [projectId], + ), + ).resolves.toBeUndefined(); + }); + + test("remove_collaborators_from_projects blocks removing owners", async () => { + const { ownerId, projectId } = await createProjectWithOwner(); + const secondOwnerId = await createUser("owner2"); + + // Add second owner + await add_collaborators_to_projects( + db(), + ownerId, + [secondOwnerId], + [projectId], + ); + await changeUserType({ + account_id: ownerId, + opts: { + project_id: projectId, + target_account_id: secondOwnerId, + new_group: "owner", + }, + }); + + // Even the first owner should not be able to directly remove the second owner -- owners have to demote to collaborator first + await expect( + remove_collaborators_from_projects( + db(), + ownerId, + [secondOwnerId], + [projectId], + ), + ).rejects.toThrow("Cannot remove an owner"); + }); +}); diff --git a/src/packages/server/projects/collaborators.ts b/src/packages/server/projects/collaborators.ts index 2cd644e486f..ef3422d9aa8 100644 --- a/src/packages/server/projects/collaborators.ts +++ b/src/packages/server/projects/collaborators.ts @@ -19,23 +19,52 @@ import getEmailAddress from "@cocalc/server/accounts/get-email-address"; import { is_paying_customer } from "@cocalc/database/postgres/account-queries"; import { project_has_network_access } from "@cocalc/database/postgres/project-queries"; import { RESEND_INVITE_INTERVAL_DAYS } from "@cocalc/util/consts/invites"; +import { + type UserGroup, + validateUserTypeChange, + OwnershipErrorCode, +} from "@cocalc/util/project-ownership"; +import { query } from "@cocalc/database/postgres/query"; +import { + ensureCanManageCollaborators, + ensureCanRemoveUser, + OwnershipError, +} from "./ownership-checks"; const logger = getLogger("project:collaborators"); +export { OwnershipError } from "./ownership-checks"; + export async function removeCollaborator({ account_id, opts, }: { account_id: string; opts: { - account_id; - project_id; + account_id: string; + project_id: string; }; }): Promise { if (!(await isCollaborator({ account_id, project_id: opts.project_id }))) { throw Error("user must be a collaborator"); } - // @ts-ignore + + // CRITICAL: Block ALL owner removals (anyone trying to remove an owner) + // Owners must be demoted to collaborator first, THEN removed + await ensureCanRemoveUser({ + project_id: opts.project_id, + target_account_id: opts.account_id, + }); + + // Check manage_users_owner_only setting (unless removing self) + const is_self_remove = account_id === opts.account_id; + if (!is_self_remove) { + await ensureCanManageCollaborators({ + project_id: opts.project_id, + account_id, + }); + } + await callback2(db().remove_collaborator_from_project, opts); } @@ -66,6 +95,15 @@ export async function addCollaborator({ accounts = [accounts]; } + // Check manage_users_owner_only setting for each project + // Only check if not using tokens (tokens have their own permission system) + if (!tokens && projects && projects.length > 0) { + for (const project_id of projects as string[]) { + if (!project_id) continue; // skip empty strings + await ensureCanManageCollaborators({ project_id, account_id }); + } + } + await add_collaborators_to_projects( db(), account_id, @@ -75,7 +113,7 @@ export async function addCollaborator({ ); // Tokens determine the projects, and it may be useful to the client to know what // project they just got added to! - let project_id; + let project_id: string | string[] | undefined; if (is_single_token) { project_id = projects[0]; } else { @@ -84,6 +122,89 @@ export async function addCollaborator({ return { project_id }; } +export async function changeUserType({ + account_id, + opts, +}: { + account_id: string; + opts: { + project_id: string; + target_account_id: string; + new_group: UserGroup; + }; +}): Promise { + const { project_id, target_account_id, new_group } = opts; + + // 1. Verify requester is a project member + if (!(await isCollaborator({ account_id, project_id }))) { + throw new OwnershipError( + "Not a project member", + OwnershipErrorCode.INVALID_REQUESTING_USER, + ); + } + + // 2. Get fresh project data (users field only) + const project = await query({ + db: db(), + table: "projects", + select: ["users"], + where: { project_id }, + one: true, + }); + + if (!project) { + throw new OwnershipError( + "Project not found", + OwnershipErrorCode.INVALID_PROJECT_STATE, + ); + } + + const users = project.users; + + // 3. Get requesting user's group + const requesting_user_group = users?.[account_id]?.group as + | UserGroup + | undefined; + + // 4. Get target user's current group + const target_current_group = users?.[target_account_id]?.group as + | UserGroup + | undefined; + + // 5. Validate the change + const validation = validateUserTypeChange({ + requesting_account_id: account_id, + requesting_user_group, + target_account_id, + target_current_group, + target_new_group: new_group, + all_users: users, + }); + + if (!validation.valid) { + throw new OwnershipError( + validation.error ?? "Invalid user type change", + validation.errorCode ?? OwnershipErrorCode.INVALID_USER, + ); + } + + // 6. Perform the change via add_user_to_project + await callback2(db().add_user_to_project, { + project_id, + account_id: target_account_id, + group: new_group, + }); + + // 7. Log the change for audit trail + logger.info("changeUserType", { + project_id, + actor_account_id: account_id, + target_account_id, + old_group: target_current_group, + new_group, + }); +} + async function allowUrlsInEmails({ project_id, account_id, @@ -116,9 +237,15 @@ export async function inviteCollaborator({ if (!(await isCollaborator({ account_id, project_id: opts.project_id }))) { throw Error("user must be a collaborator"); } - const dbg = (...args) => logger.debug("inviteCollaborator", ...args); + const dbg = (...args: any[]) => logger.debug("inviteCollaborator", ...args); const database = db(); + // Check manage_users_owner_only setting before inviting + await ensureCanManageCollaborators({ + project_id: opts.project_id, + account_id, + }); + // Actually add user to project await callback2(database.add_user_to_project, { project_id: opts.project_id, @@ -209,10 +336,16 @@ export async function inviteCollaboratorWithoutAccount({ if (!(await isCollaborator({ account_id, project_id: opts.project_id }))) { throw Error("user must be a collaborator"); } - const dbg = (...args) => + const dbg = (...args: any[]) => logger.debug("inviteCollaboratorWithoutAccount", ...args); const database = db(); + // Check manage_users_owner_only setting before inviting + await ensureCanManageCollaborators({ + project_id: opts.project_id, + account_id, + }); + if (opts.to.length > 1024) { throw Error( "Specify less recipients when adding collaborators to project.", diff --git a/src/packages/server/projects/manage-users-owner-only.test.ts b/src/packages/server/projects/manage-users-owner-only.test.ts new file mode 100644 index 00000000000..8de31547609 --- /dev/null +++ b/src/packages/server/projects/manage-users-owner-only.test.ts @@ -0,0 +1,333 @@ +/* + * This file is part of CoCalc: Copyright © 2025 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import { db } from "@cocalc/database"; +import getPool, { initEphemeralDatabase } from "@cocalc/database/pool"; +import { resetServerSettingsCache } from "@cocalc/database/settings/server-settings"; +import userQuery from "@cocalc/database/user-query"; +import createAccount from "@cocalc/server/accounts/create-account"; +import { + addCollaborator, + changeUserType, + removeCollaborator, +} from "@cocalc/server/projects/collaborators"; +import createProject from "@cocalc/server/projects/create"; +import { callback2 } from "@cocalc/util/async-utils"; +import { uuid } from "@cocalc/util/misc"; + +async function setSiteStrictCollab(value: "yes" | "no") { + await callback2(db().set_server_setting, { + name: "strict_collaborator_management", + value, + readonly: false, + }); + resetServerSettingsCache(); +} + +beforeAll(async () => { + await initEphemeralDatabase(); +}, 15000); + +afterAll(async () => { + await getPool().end(); +}); + +describe("without site enforcement (default)", () => { + const ownerId = uuid(); + const collaboratorId = uuid(); + const collaboratorToRemove = uuid(); + const newCollaboratorId = uuid(); + const ownerCollaboratorId = uuid(); + let projectId: string; + + beforeAll(async () => { + await setSiteStrictCollab("no"); + + await createAccount({ + email: `owner-${ownerId}@example.com`, + password: "pass", + firstName: "Owner", + lastName: "One", + account_id: ownerId, + noFirstProject: true, + }); + await createAccount({ + email: `collab-${collaboratorId}@example.com`, + password: "pass", + firstName: "Collab", + lastName: "Two", + account_id: collaboratorId, + noFirstProject: true, + }); + await createAccount({ + email: `collab-${collaboratorToRemove}@example.com`, + password: "pass", + firstName: "Collab", + lastName: "Three", + account_id: collaboratorToRemove, + noFirstProject: true, + }); + await createAccount({ + email: `collab-${newCollaboratorId}@example.com`, + password: "pass", + firstName: "Collab", + lastName: "Four", + account_id: newCollaboratorId, + noFirstProject: true, + }); + await createAccount({ + email: `owner-collab-${ownerCollaboratorId}@example.com`, + password: "pass", + firstName: "Owner", + lastName: "Collab", + account_id: ownerCollaboratorId, + noFirstProject: true, + }); + + projectId = await createProject({ + account_id: ownerId, + title: "Default setting test", + start: false, + }); + + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorId }, + }); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorToRemove }, + }); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: ownerCollaboratorId }, + }); + + // Promote ownerCollaboratorId to owner + await changeUserType({ + account_id: ownerId, + opts: { + project_id: projectId, + target_account_id: ownerCollaboratorId, + new_group: "owner", + }, + }); + + // Verify they are actually an owner + const { rows } = await getPool().query( + "SELECT users FROM projects WHERE project_id=$1", + [projectId], + ); + expect(rows[0].users[ownerCollaboratorId]).toEqual({ group: "owner" }); + }); + + test("collaborator can add collaborators when site enforcement is off", async () => { + await expect( + addCollaborator({ + account_id: collaboratorId, + opts: { project_id: projectId, account_id: newCollaboratorId }, + }), + ).resolves.toBeDefined(); + + const { rows } = await getPool().query( + "SELECT users FROM projects WHERE project_id=$1", + [projectId], + ); + expect(rows[0].users[newCollaboratorId]).toEqual({ group: "collaborator" }); + }); + + test("collaborator can remove another collaborator when site enforcement is off", async () => { + await expect( + removeCollaborator({ + account_id: collaboratorId, + opts: { project_id: projectId, account_id: collaboratorToRemove }, + }), + ).resolves.toBeUndefined(); + + const { rows } = await getPool().query( + "SELECT users FROM projects WHERE project_id=$1", + [projectId], + ); + expect(rows[0].users[collaboratorToRemove]).toBeUndefined(); + }); + + test("collaborator cannot remove another owner collaborator when site enforcement is off", async () => { + await expect( + removeCollaborator({ + account_id: collaboratorId, + opts: { project_id: projectId, account_id: ownerCollaboratorId }, + }), + ).rejects.toThrow("Cannot remove an owner. Demote to collaborator first."); + }); +}); + +describe("strict collaborator management site setting", () => { + const ownerId = uuid(); + const collaboratorId = uuid(); + const otherCollaboratorId = uuid(); + const newCollaboratorId = uuid(); + const ownerCollaboratorId = uuid(); + const collaboratorToRemoveByOwner = uuid(); + let projectId: string; + + beforeAll(async () => { + await setSiteStrictCollab("yes"); + + await createAccount({ + email: `owner-${ownerId}@example.com`, + password: "pass", + firstName: "Owner", + lastName: "One", + account_id: ownerId, + noFirstProject: true, + }); + await createAccount({ + email: `collab-${collaboratorId}@example.com`, + password: "pass", + firstName: "Collab", + lastName: "Two", + account_id: collaboratorId, + noFirstProject: true, + }); + await createAccount({ + email: `collab-${otherCollaboratorId}@example.com`, + password: "pass", + firstName: "Collab", + lastName: "Three", + account_id: otherCollaboratorId, + noFirstProject: true, + }); + await createAccount({ + email: `owner-collab-${ownerCollaboratorId}@example.com`, + password: "pass", + firstName: "Owner", + lastName: "Collab", + account_id: ownerCollaboratorId, + noFirstProject: true, + }); + await createAccount({ + email: `collab-remove-${collaboratorToRemoveByOwner}@example.com`, + password: "pass", + firstName: "Collab", + lastName: "ToRemove", + account_id: collaboratorToRemoveByOwner, + noFirstProject: true, + }); + + projectId = await createProject({ + account_id: ownerId, + title: "Strict setting test", + start: false, + }); + + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorId }, + }); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: otherCollaboratorId }, + }); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: ownerCollaboratorId }, + }); + await addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: collaboratorToRemoveByOwner }, + }); + + // Promote ownerCollaboratorId to owner + await changeUserType({ + account_id: ownerId, + opts: { + project_id: projectId, + target_account_id: ownerCollaboratorId, + new_group: "owner", + }, + }); + + // Verify they are actually an owner + const { rows } = await getPool().query( + "SELECT users FROM projects WHERE project_id=$1", + [projectId], + ); + expect(rows[0].users[ownerCollaboratorId]).toEqual({ group: "owner" }); + }); + + test("owner can still add collaborators when site enforcement is on", async () => { + await expect( + addCollaborator({ + account_id: ownerId, + opts: { project_id: projectId, account_id: newCollaboratorId }, + }), + ).resolves.toBeDefined(); + }); + + test("collaborator cannot add collaborators when site enforcement is on", async () => { + await expect( + addCollaborator({ + account_id: collaboratorId, + opts: { project_id: projectId, account_id: uuid() }, + }), + ).rejects.toThrow( + "Only owners can manage collaborators when this setting is enabled", + ); + }); + + test("collaborator cannot remove another collaborator when site enforcement is on", async () => { + await expect( + removeCollaborator({ + account_id: collaboratorId, + opts: { project_id: projectId, account_id: otherCollaboratorId }, + }), + ).rejects.toThrow( + "Only owners can manage collaborators when this setting is enabled", + ); + }); + + test("owner cannot disable manage_users_owner_only when site enforcement is on", async () => { + await expect( + userQuery({ + account_id: ownerId, + query: { + projects: { + project_id: projectId, + manage_users_owner_only: false, + }, + }, + }), + ).rejects.toMatch( + "Collaborator management is enforced by the site administrator and cannot be disabled.", + ); + }); + + test("owner can remove non-owner collaborators when site enforcement is on", async () => { + await expect( + removeCollaborator({ + account_id: ownerId, + opts: { + project_id: projectId, + account_id: collaboratorToRemoveByOwner, + }, + }), + ).resolves.toBeUndefined(); + + const { rows } = await getPool().query( + "SELECT users FROM projects WHERE project_id=$1", + [projectId], + ); + expect(rows[0].users[collaboratorToRemoveByOwner]).toBeUndefined(); + }); + + test("collaborator cannot remove owner collaborator when site enforcement is on", async () => { + await expect( + removeCollaborator({ + account_id: collaboratorId, + opts: { project_id: projectId, account_id: ownerCollaboratorId }, + }), + ).rejects.toThrow("Cannot remove an owner. Demote to collaborator first."); + }); +}); diff --git a/src/packages/server/projects/ownership-checks.ts b/src/packages/server/projects/ownership-checks.ts new file mode 100644 index 00000000000..ab0e822df1c --- /dev/null +++ b/src/packages/server/projects/ownership-checks.ts @@ -0,0 +1,112 @@ +/* + * This file is part of CoCalc: Copyright © 2020 - 2025 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +/* +Helper functions for checking project ownership and collaborator management permissions. +*/ + +import { db as getDb } from "@cocalc/database"; +import { query } from "@cocalc/database/postgres/query"; +import { getServerSettings } from "@cocalc/database/settings/server-settings"; +import { is_valid_uuid_string } from "@cocalc/util/misc"; +import { + type UserGroup, + OwnershipErrorCode, +} from "@cocalc/util/project-ownership"; + +export class OwnershipError extends Error { + code: OwnershipErrorCode; + constructor(message: string, code: OwnershipErrorCode) { + super(message); + this.name = "OwnershipError"; + this.code = code; + } +} + +function validateUUIDs(project_id: string, account_id: string): void { + if (!is_valid_uuid_string(project_id)) { + throw Error(`Invalid project_id: ${project_id}`); + } + if (!is_valid_uuid_string(account_id)) { + throw Error(`Invalid account_id: ${account_id}`); + } +} + +/** + * Ensures that a user can be removed from a project. + * Prevents removing owners - they must be demoted to collaborator first. + * + * @throws {OwnershipError} If the target user is an owner + */ +export async function ensureCanRemoveUser(opts: { + project_id: string; + target_account_id: string; +}): Promise { + const { project_id, target_account_id } = opts; + + // Validate UUIDs to prevent SQL injection + validateUUIDs(project_id, target_account_id); + + const db = getDb(); + const result = await db.async_query({ + query: `SELECT users#>'{${target_account_id},group}' AS group FROM projects WHERE project_id=$1`, + params: [project_id], + }); + + if (result.rows.length === 0) { + throw Error(`Project not found: ${project_id}`); + } + + const target_user_group = result.rows[0]?.group; + if (target_user_group === "owner") { + throw new OwnershipError( + "Cannot remove an owner. Demote to collaborator first.", + OwnershipErrorCode.CANNOT_REMOVE_OWNER, + ); + } +} + +export async function ensureCanManageCollaborators(opts: { + project_id: string; + account_id: string; +}): Promise { + const { project_id, account_id } = opts; + + // Validate UUIDs to prevent SQL injection + validateUUIDs(project_id, account_id); + + const serverSettings = await getServerSettings(); + const siteEnforced = !!serverSettings.strict_collaborator_management; + + const db = getDb(); + const project = await query({ + db, + table: "projects", + select: ["users", "manage_users_owner_only"], + where: { project_id }, + one: true, + }); + + if (!project) { + throw new OwnershipError( + "Project not found", + OwnershipErrorCode.INVALID_PROJECT_STATE, + ); + } + + const manage_users_owner_only = project.manage_users_owner_only ?? false; + const requesting_user_group = project.users?.[account_id]?.group as + | UserGroup + | undefined; + + const restrictToOwners = siteEnforced || manage_users_owner_only; + + if (restrictToOwners && requesting_user_group !== "owner") { + throw new OwnershipError( + "Only owners can manage collaborators when this setting is enabled", + OwnershipErrorCode.NOT_OWNER, + ); + } +} diff --git a/src/packages/server/purchases/closing-date.test.ts b/src/packages/server/purchases/closing-date.test.ts index cb88f3e42f7..e1d4533b00f 100644 --- a/src/packages/server/purchases/closing-date.test.ts +++ b/src/packages/server/purchases/closing-date.test.ts @@ -37,6 +37,7 @@ describe("basic consistency checks for closing date functions", () => { firstName: "Test", lastName: "User", account_id, + noFirstProject: true, }); const d = await getClosingDay(account_id); expect(d).toBeGreaterThanOrEqual(1); diff --git a/src/packages/server/purchases/edit-license.test.ts b/src/packages/server/purchases/edit-license.test.ts index 4e1a9183a7b..5eed95ea0ff 100644 --- a/src/packages/server/purchases/edit-license.test.ts +++ b/src/packages/server/purchases/edit-license.test.ts @@ -42,6 +42,7 @@ describe("create a license and then edit it in various ways", () => { firstName: "Test", lastName: "User", account_id, + noFirstProject: true, }); const info = getPurchaseInfo(license0); x.license_id = await createLicense(account_id, info); @@ -190,6 +191,7 @@ describe("create a subscription license and edit it and confirm the subscription firstName: "Test", lastName: "User", account_id: item.account_id, + noFirstProject: true, }); const client = await getPoolClient(); await purchaseShoppingCartItem(item as any, client); @@ -246,6 +248,7 @@ describe("testing changing the owner of a license", () => { firstName: "Test", lastName: "User", account_id, + noFirstProject: true, }); const info = getPurchaseInfo(license0); x.license_id = await createLicense(account_id, info); diff --git a/src/packages/server/purchases/get-purchases.test.ts b/src/packages/server/purchases/get-purchases.test.ts index e63317cdd0c..421a41e430f 100644 --- a/src/packages/server/purchases/get-purchases.test.ts +++ b/src/packages/server/purchases/get-purchases.test.ts @@ -33,6 +33,7 @@ describe("creates and get purchases using various options", () => { firstName: "Test", lastName: "User", account_id, + noFirstProject: true, }); await createPurchase({ account_id, diff --git a/src/packages/server/purchases/get-service-cost.test.ts b/src/packages/server/purchases/get-service-cost.test.ts index 23039b80ee4..9d0467c6f77 100644 --- a/src/packages/server/purchases/get-service-cost.test.ts +++ b/src/packages/server/purchases/get-service-cost.test.ts @@ -74,7 +74,7 @@ describe("get some service costs", () => { it("throws error on invalid service", async () => { await expect( - async () => await getServiceCost("nonsense" as any) + async () => await getServiceCost("nonsense" as any), ).rejects.toThrow(); }); }); diff --git a/src/packages/server/purchases/get-spend-rate.test.ts b/src/packages/server/purchases/get-spend-rate.test.ts index 66aac5ffe51..1461294dcba 100644 --- a/src/packages/server/purchases/get-spend-rate.test.ts +++ b/src/packages/server/purchases/get-spend-rate.test.ts @@ -26,6 +26,7 @@ describe("get the spend rate of a user under various circumstances", () => { firstName: "Test", lastName: "User", account_id, + noFirstProject: true, }); expect(await getSpendRate(account_id)).toBe(0); }); @@ -82,7 +83,7 @@ describe("get the spend rate of a user under various circumstances", () => { }, }); expect(await getSpendRate(account_id, "")).toBe( - cost_per_hour1 + cost_per_hour2 + cost_per_hour1 + cost_per_hour2, ); }); diff --git a/src/packages/server/purchases/is-purchase-allowed.test.ts b/src/packages/server/purchases/is-purchase-allowed.test.ts index cea57fad2af..1da663f3042 100644 --- a/src/packages/server/purchases/is-purchase-allowed.test.ts +++ b/src/packages/server/purchases/is-purchase-allowed.test.ts @@ -33,6 +33,7 @@ describe("test checking whether or not purchase is allowed under various conditi firstName: "Test", lastName: "User", account_id, + noFirstProject: true, }); const { allowed, chargeAmount } = await isPurchaseAllowed({ diff --git a/src/packages/server/purchases/purchase-shopping-cart-item.test.ts b/src/packages/server/purchases/purchase-shopping-cart-item.test.ts index 4df853c77ef..011be41528c 100644 --- a/src/packages/server/purchases/purchase-shopping-cart-item.test.ts +++ b/src/packages/server/purchases/purchase-shopping-cart-item.test.ts @@ -58,6 +58,7 @@ describe("create a subscription license and edit it and confirm the subscription firstName: "Test", lastName: "User", account_id: item.account_id, + noFirstProject: true, }); // set min balance so this all works below @@ -137,6 +138,7 @@ describe("create a subscription license and edit it and confirm the subscription firstName: "Test", lastName: "User", account_id: item.account_id, + noFirstProject: true, }); // set min balance so this all works below diff --git a/src/packages/server/purchases/shift-subscription.2.test.ts b/src/packages/server/purchases/shift-subscription.2.test.ts index 02240d2d455..05235dd89cf 100644 --- a/src/packages/server/purchases/shift-subscription.2.test.ts +++ b/src/packages/server/purchases/shift-subscription.2.test.ts @@ -31,9 +31,8 @@ describe("test shiftSubscriptionToEndOnDay -- involves actual subscriptions", () await createTestAccount(account_id); await setClosingDay(account_id, 3); - ({ subscription_id, license_id } = await createTestSubscription( - account_id - )); + ({ subscription_id, license_id } = + await createTestSubscription(account_id)); }); // it("confirms that the newly created subscription has a current period end day of 3", async () => { diff --git a/src/packages/server/purchases/student-pay.test.ts b/src/packages/server/purchases/student-pay.test.ts index 729ef2d1238..ebbbb279f35 100644 --- a/src/packages/server/purchases/student-pay.test.ts +++ b/src/packages/server/purchases/student-pay.test.ts @@ -5,7 +5,7 @@ import studentPay from "./student-pay"; import createProject from "@cocalc/server/projects/create"; import createCredit from "./create-credit"; import dayjs from "dayjs"; -import { delay } from "awaiting"; +import { waitToAvoidTestFailure } from "@cocalc/server/test-utils"; beforeAll(async () => { await initEphemeralDatabase({}); @@ -32,10 +32,9 @@ describe("test studentPay behaves at it should in various scenarios", () => { project_id = await createProject({ account_id, title: "My First Project", + start: false, }); - // sometimes above isn't noticed below, which is weird, so we put in slight delay. - // TODO: it's surely because of using a connection pool instead of a single connection. - await delay(300); + await waitToAvoidTestFailure(); }); it("fails because student pay not configured yet", async () => { @@ -90,7 +89,7 @@ describe("test studentPay behaves at it should in various scenarios", () => { } }); - let purchase_id_from_student_pay : undefined | number = 0; + let purchase_id_from_student_pay: undefined | number = 0; it("add a lot of money, so it finally works -- check that the license is applied to the project", async () => { await createCredit({ account_id, amount: 1000 }); const { purchase_id } = await studentPay({ account_id, project_id }); diff --git a/src/packages/server/test-utils.ts b/src/packages/server/test-utils.ts new file mode 100644 index 00000000000..bfe3745f312 --- /dev/null +++ b/src/packages/server/test-utils.ts @@ -0,0 +1,20 @@ +/* + * This file is part of CoCalc: Copyright © 2025 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import { delay } from "awaiting"; + +/** + * Wait to avoid test failures due to connection pool timing issues. + * + * Sometimes database writes (e.g., creating accounts, projects, or compute servers) + * aren't immediately visible to subsequent reads when using a connection pool instead + * of a single connection. This function adds a small delay to ensure consistency. + * + * TODO: This is a workaround. Ideally we should use a single connection for tests + * or implement proper transaction/consistency guarantees. + */ +export async function waitToAvoidTestFailure(): Promise { + await delay(50); +} diff --git a/src/packages/util/db-schema/client-db.ts b/src/packages/util/db-schema/client-db.ts index d26f0f1bcd5..bb3fc94f054 100644 --- a/src/packages/util/db-schema/client-db.ts +++ b/src/packages/util/db-schema/client-db.ts @@ -17,6 +17,8 @@ class ClientDB { this.sha1 = this.sha1.bind(this); this._user_set_query_project_users = this._user_set_query_project_users.bind(this); + this._user_set_query_project_manage_users_owner_only = + this._user_set_query_project_manage_users_owner_only.bind(this); this._user_set_query_project_change_after = this._user_set_query_project_change_after.bind(this); this._user_set_query_project_change_before = @@ -45,6 +47,10 @@ class ClientDB { return obj.users; } + _user_set_query_project_manage_users_owner_only(obj) { + return obj.manage_users_owner_only; + } + _user_set_query_project_change_after(_obj, _old_val, _new_val, cb) { cb(); } diff --git a/src/packages/util/db-schema/projects.ts b/src/packages/util/db-schema/projects.ts index d29cf66451f..c1b89001966 100644 --- a/src/packages/util/db-schema/projects.ts +++ b/src/packages/util/db-schema/projects.ts @@ -13,10 +13,12 @@ import { } from "@cocalc/util/types/execute-code"; import { type RegistrationTokenCustomize } from "@cocalc/util/types/registration-token"; import { DEFAULT_QUOTAS } from "@cocalc/util/upgrade-spec"; +import { isUserGroup } from "@cocalc/util/project-ownership"; import { NOTES } from "./crm"; import { FALLBACK_COMPUTE_IMAGE } from "./defaults"; import { SCHEMA as schema } from "./index"; +import { callback2 } from "@cocalc/util/async-utils"; import { Table } from "./types"; export const MAX_FILENAME_SEARCH_RESULTS = 100; @@ -76,6 +78,7 @@ Table({ run_quota: null, site_license: null, status: null, + manage_users_owner_only: null, // security model is anybody with access to the project should be allowed to know this token. secret_token: null, state: null, @@ -109,6 +112,12 @@ Table({ users(obj, db, account_id) { return db._user_set_query_project_users(obj, account_id); }, + manage_users_owner_only(obj, db, account_id) { + return db._user_set_query_project_manage_users_owner_only( + obj, + account_id, + ); + }, action_request: true, // used to request that an action be performed, e.g., "save"; handled by before_change compute_image: true, site_license: true, @@ -121,6 +130,46 @@ Table({ required_fields: { project_id: true, }, + async check_hook(db, obj, account_id, _project_id, cb) { + // Validate manage_users_owner_only permission if it's being changed + if (obj.manage_users_owner_only !== undefined) { + try { + // Require actor identity before hitting the database + if (!account_id) { + throw Error( + "account_id is required to change manage_users_owner_only", + ); + } + + const siteSettings = + (await callback2(db.get_server_settings_cached, {})) ?? {}; + const siteEnforced = !!siteSettings.strict_collaborator_management; + if (siteEnforced && obj.manage_users_owner_only !== true) { + throw Error( + "Collaborator management is enforced by the site administrator and cannot be disabled.", + ); + } + + const { rows } = await db.async_query({ + query: "SELECT users FROM projects WHERE project_id = $1", + params: [obj.project_id], + }); + const users = rows?.[0]?.users ?? {}; + + // Check that the user making the change is an owner + const group = users?.[account_id]?.group; + if (!isUserGroup(group) || group !== "owner") { + throw Error( + "Only project owners can change collaborator management settings", + ); + } + } catch (err) { + cb(err.toString()); + return; + } + } + cb(); + }, before_change(database, old_val, new_val, account_id, cb) { database._user_set_query_project_change_before( old_val, @@ -192,6 +241,11 @@ Table({ desc: "This is a map from account_id's to {hide:bool, group:'owner'|'collaborator', upgrades:{memory:1000, ...}, ssh:{...}}.", render: { type: "usersmap", editable: true }, }, + manage_users_owner_only: { + type: "boolean", + desc: "If true, only project owners can add or remove collaborators. Collaborators can still remove themselves. Disabled by default (undefined or false means current behavior where collaborators can manage other collaborators).", + render: { type: "boolean", editable: true }, + }, invite: { type: "map", desc: "Map from email addresses to {time:when invite sent, error:error message if there was one}", diff --git a/src/packages/util/db-schema/site-defaults.ts b/src/packages/util/db-schema/site-defaults.ts index 93ae9926e57..9d39455c285 100644 --- a/src/packages/util/db-schema/site-defaults.ts +++ b/src/packages/util/db-schema/site-defaults.ts @@ -7,6 +7,7 @@ import jsonic from "jsonic"; import { isEqual } from "lodash"; + import { LOCALE } from "@cocalc/util/consts/locale"; import { is_valid_email_address } from "@cocalc/util/misc"; import { @@ -112,6 +113,7 @@ export type SiteSettingsKeys = | "anonymous_signup" | "anonymous_signup_licensed_shares" | "share_server" + | "strict_collaborator_management" | "landing_pages" | "sandbox_projects_enabled" | "sandbox_project_id" @@ -778,6 +780,13 @@ export const site_settings_conf: SiteSettings = { valid: only_booleans, to_val: to_bool, }, + strict_collaborator_management: { + name: "Only project owners can manage collaborators", + desc: "Force collaborator management to owners only across all projects. When enabled, per-project controls are disabled and only owners can add, remove, or change collaborator roles.", + default: "no", + valid: only_booleans, + to_val: to_bool, + }, landing_pages: { name: "Landing pages", desc: "Host landing pages about the functionality of CoCalc.", diff --git a/src/packages/util/project-ownership.test.ts b/src/packages/util/project-ownership.test.ts new file mode 100644 index 00000000000..c5933ab94b1 --- /dev/null +++ b/src/packages/util/project-ownership.test.ts @@ -0,0 +1,576 @@ +/* + * This file is part of CoCalc: Copyright © 2025 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +/* +## Project Ownership Transfer - Test Coverage + +This test suite validates the ownership transfer and collaborator management rules. + +### Scenario Matrix + +| Actor Type | Action | Target User | Current State | Allowed? | Validation Rule | Test Coverage | +| ------------ | ---------------------- | ------------------ | ---------------- | -------- | --------------------------------- | ------------- | +| Owner | Elevate to owner | Collaborator | Has other owners | ✅ Yes | Owner privilege | ✅ Tested | +| Owner | Demote to collaborator | Owner (self) | Has other owners | ✅ Yes | Can step down if not last owner | ✅ Tested | +| Owner | Demote to collaborator | Owner (other) | Has other owners | ✅ Yes | Owner privilege | ✅ Tested | +| Owner | Demote to collaborator | Owner (any) | Last owner | ❌ No | Must maintain ≥1 owner | ✅ Tested | +| Owner | Add collaborator | N/A | Any | ✅ Yes | Current functionality | ✅ Tested | +| Owner | Remove collaborator | Collaborator | Any | ✅ Yes | Current functionality | ✅ Tested | +| Owner | Remove collaborator | Owner (self) | Any | ❌ No | Must demote to collaborator first | ✅ Tested | +| Owner | Remove collaborator | Owner (other) | Any | ❌ No | Must demote to collaborator first | ✅ Tested | +| Collaborator | Elevate to owner | Self | Any | ❌ No | No self-elevation | ✅ Tested | +| Collaborator | Elevate to owner | Other collaborator | Any | ❌ No | No user type changes | ✅ Tested | +| Collaborator | Elevate to owner | Owner | Any | ❌ No | No user type changes | ✅ Tested | +| Collaborator | Demote to collaborator | Owner | Any | ❌ No | Cannot change owner status | ✅ Tested | +| Collaborator | Add collaborator | N/A | Setting disabled | ✅ Yes | Current behavior | ✅ Tested | +| Collaborator | Add collaborator | N/A | Setting enabled | ❌ No | New constraint | ✅ Tested | +| Collaborator | Remove collaborator | Other collaborator | Setting disabled | ✅ Yes | Current behavior | ✅ Tested | +| Collaborator | Remove collaborator | Other collaborator | Setting enabled | ❌ No | New constraint | ✅ Tested | +| Collaborator | Remove collaborator | Self | Any | ✅ Yes | Can always leave | ✅ Tested | +| Collaborator | Remove collaborator | Owner | Any | ❌ No | Cannot remove owners | ✅ Tested | + +### Core Validation Rules (All Tested) + +1. **Minimum Owner Rule:** Projects must have at least 1 owner at all times +2. **Owner Privilege Rule:** Only owners can change user types (owner ↔ collaborator) +3. **Self-Step-Down Rule:** Owners can demote themselves only if other owners exist +4. **No Self-Elevation Rule:** Collaborators cannot elevate themselves or others +5. **Collaborator Management Rule:** When setting enabled, only owners can add/remove collaborators +6. **Self-Remove Rule:** Users can always remove themselves (except owners - must demote first) +7. **No Direct Owner Removal:** Owners cannot be removed directly. Must demote to collaborator first. + +### Error Codes Tested + +All error codes defined in `OwnershipErrorCode` enum (see project-ownership.ts): + +- `LAST_OWNER` - Cannot demote the last owner +- `NOT_OWNER` - Only owners can perform this action +- `INVALID_TARGET` - Target user is invalid or not in project +- `INVALID_USER` - User not found in project +- `CANNOT_REMOVE_OWNER` - Cannot remove owners directly +- `INVALID_REQUESTING_USER` - Requesting user is not a valid member +- `INVALID_PROJECT_STATE` - Project data is missing or invalid +*/ + +import { + canManageCollaborators, + countOwners, + isLastOwner, + isUserGroup, + OwnershipErrorCode, + validateAddCollaborator, + validateRemoveCollaborator, + validateUserTypeChange, +} from "./project-ownership"; + +describe("isUserGroup", () => { + test("returns true for owner", () => { + expect(isUserGroup("owner")).toBe(true); + }); + + test("returns true for collaborator", () => { + expect(isUserGroup("collaborator")).toBe(true); + }); + + test("returns false for invalid values", () => { + expect(isUserGroup("admin")).toBe(false); + expect(isUserGroup("public")).toBe(false); + expect(isUserGroup(undefined)).toBe(false); + expect(isUserGroup(null)).toBe(false); + expect(isUserGroup("")).toBe(false); + }); +}); + +describe("countOwners", () => { + test("counts owners correctly", () => { + const users = { + user1: { group: "owner" }, + user2: { group: "collaborator" }, + user3: { group: "owner" }, + }; + expect(countOwners(users)).toBe(2); + }); + + test("returns 0 for no owners", () => { + const users = { + user1: { group: "collaborator" }, + user2: { group: "collaborator" }, + }; + expect(countOwners(users)).toBe(0); + }); + + test("handles null users", () => { + expect(countOwners(null)).toBe(0); + }); + + test("handles undefined users", () => { + expect(countOwners(undefined)).toBe(0); + }); + + test("handles empty object", () => { + expect(countOwners({})).toBe(0); + }); +}); + +describe("isLastOwner", () => { + test("returns true when user is the only owner", () => { + const users = { + user1: { group: "owner" }, + user2: { group: "collaborator" }, + }; + expect(isLastOwner("user1", users)).toBe(true); + }); + + test("returns false when there are multiple owners", () => { + const users = { + user1: { group: "owner" }, + user2: { group: "owner" }, + user3: { group: "collaborator" }, + }; + expect(isLastOwner("user1", users)).toBe(false); + }); + + test("returns false when user is not an owner", () => { + const users = { + user1: { group: "owner" }, + user2: { group: "collaborator" }, + }; + expect(isLastOwner("user2", users)).toBe(false); + }); + + test("returns false for null users", () => { + expect(isLastOwner("user1", null)).toBe(false); + }); + + test("returns false for undefined users", () => { + expect(isLastOwner("user1", undefined)).toBe(false); + }); +}); + +describe("validateUserTypeChange", () => { + const mockUsers = { + owner1: { group: "owner" }, + owner2: { group: "owner" }, + collab1: { group: "collaborator" }, + collab2: { group: "collaborator" }, + }; + + test("owner can promote collaborator to owner", () => { + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "collab1", + target_current_group: "collaborator", + target_new_group: "owner", + all_users: mockUsers, + }); + expect(result.valid).toBe(true); + }); + + test("owner can demote another owner when multiple owners exist", () => { + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "owner2", + target_current_group: "owner", + target_new_group: "collaborator", + all_users: mockUsers, + }); + expect(result.valid).toBe(true); + }); + + test("owner changing collaborator to same group is allowed (no-op)", () => { + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "collab1", + target_current_group: "collaborator", + target_new_group: "collaborator", + all_users: mockUsers, + }); + expect(result.valid).toBe(true); + }); + + test("owner can demote self when other owners exist", () => { + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "owner1", + target_current_group: "owner", + target_new_group: "collaborator", + all_users: mockUsers, + }); + expect(result.valid).toBe(true); + }); + + test("cannot demote last owner", () => { + const usersWithOneOwner = { + owner1: { group: "owner" }, + collab1: { group: "collaborator" }, + }; + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "owner1", + target_current_group: "owner", + target_new_group: "collaborator", + all_users: usersWithOneOwner, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.LAST_OWNER); + }); + + test("collaborator cannot promote self", () => { + const result = validateUserTypeChange({ + requesting_account_id: "collab1", + requesting_user_group: "collaborator", + target_account_id: "collab1", + target_current_group: "collaborator", + target_new_group: "owner", + all_users: mockUsers, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.NOT_OWNER); + }); + + test("collaborator cannot promote others", () => { + const result = validateUserTypeChange({ + requesting_account_id: "collab1", + requesting_user_group: "collaborator", + target_account_id: "collab2", + target_current_group: "collaborator", + target_new_group: "owner", + all_users: mockUsers, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.NOT_OWNER); + }); + + test("collaborator cannot demote owners", () => { + const result = validateUserTypeChange({ + requesting_account_id: "collab1", + requesting_user_group: "collaborator", + target_account_id: "owner1", + target_current_group: "owner", + target_new_group: "collaborator", + all_users: mockUsers, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.NOT_OWNER); + }); + + test("fails when target user not in project", () => { + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "nonexistent", + target_current_group: "collaborator", + target_new_group: "owner", + all_users: mockUsers, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_USER); + }); + + test("fails when requesting user is invalid", () => { + const result = validateUserTypeChange({ + requesting_account_id: "someone", + requesting_user_group: undefined, + target_account_id: "collab1", + target_current_group: "collaborator", + target_new_group: "owner", + all_users: mockUsers, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_REQUESTING_USER); + }); + + test("fails when target has invalid group", () => { + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "collab1", + target_current_group: undefined, + target_new_group: "owner", + all_users: mockUsers, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_TARGET); + }); + + test("fails when new group is invalid", () => { + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "collab1", + target_current_group: "collaborator", + target_new_group: "admin" as any, + all_users: mockUsers, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_TARGET); + }); + + test("fails when all_users is null", () => { + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "collab1", + target_current_group: "collaborator", + target_new_group: "owner", + all_users: null, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_PROJECT_STATE); + }); + + test("fails when all_users is undefined", () => { + const result = validateUserTypeChange({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "collab1", + target_current_group: "collaborator", + target_new_group: "owner", + all_users: undefined, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_PROJECT_STATE); + }); +}); + +describe("canManageCollaborators", () => { + test("owner can manage when setting disabled", () => { + const result = canManageCollaborators({ + user_group: "owner", + manage_users_owner_only: false, + }); + expect(result).toBe(true); + }); + + test("collaborator can manage when setting disabled", () => { + const result = canManageCollaborators({ + user_group: "collaborator", + manage_users_owner_only: false, + }); + expect(result).toBe(true); + }); + + test("owner can manage when setting enabled", () => { + const result = canManageCollaborators({ + user_group: "owner", + manage_users_owner_only: true, + }); + expect(result).toBe(true); + }); + + test("collaborator cannot manage when setting enabled", () => { + const result = canManageCollaborators({ + user_group: "collaborator", + manage_users_owner_only: true, + }); + expect(result).toBe(false); + }); + + test("invalid user cannot manage", () => { + const result = canManageCollaborators({ + user_group: undefined, + manage_users_owner_only: false, + }); + expect(result).toBe(false); + }); + + test("invalid user cannot manage when setting enabled", () => { + const result = canManageCollaborators({ + user_group: undefined, + manage_users_owner_only: true, + }); + expect(result).toBe(false); + }); +}); + +describe("validateRemoveCollaborator", () => { + test("owner can remove collaborator", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "collab1", + target_user_group: "collaborator", + manage_users_owner_only: false, + }); + expect(result.valid).toBe(true); + }); + + test("owner can remove collaborator when setting enabled", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "collab1", + target_user_group: "collaborator", + manage_users_owner_only: true, + }); + expect(result.valid).toBe(true); + }); + + test("collaborator can remove another collaborator when setting disabled", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "collab1", + requesting_user_group: "collaborator", + target_account_id: "collab2", + target_user_group: "collaborator", + manage_users_owner_only: false, + }); + expect(result.valid).toBe(true); + }); + + test("collaborator can remove self when setting enabled", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "collab1", + requesting_user_group: "collaborator", + target_account_id: "collab1", + target_user_group: "collaborator", + manage_users_owner_only: true, + }); + expect(result.valid).toBe(true); + }); + + test("collaborator can remove self when setting disabled", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "collab1", + requesting_user_group: "collaborator", + target_account_id: "collab1", + target_user_group: "collaborator", + manage_users_owner_only: false, + }); + expect(result.valid).toBe(true); + }); + + test("collaborator cannot remove another collaborator when setting enabled", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "collab1", + requesting_user_group: "collaborator", + target_account_id: "collab2", + target_user_group: "collaborator", + manage_users_owner_only: true, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.NOT_OWNER); + }); + + test("cannot remove owner directly", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "owner2", + target_user_group: "owner", + manage_users_owner_only: false, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.CANNOT_REMOVE_OWNER); + }); + + test("owner cannot remove self (must demote first)", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "owner1", + target_user_group: "owner", + manage_users_owner_only: false, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.CANNOT_REMOVE_OWNER); + }); + + test("collaborator cannot remove owner", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "collab1", + requesting_user_group: "collaborator", + target_account_id: "owner1", + target_user_group: "owner", + manage_users_owner_only: false, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.CANNOT_REMOVE_OWNER); + }); + + test("fails when requesting user is invalid", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "someone", + requesting_user_group: undefined, + target_account_id: "collab1", + target_user_group: "collaborator", + manage_users_owner_only: false, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_REQUESTING_USER); + }); + + test("fails when target user not in project (undefined group)", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "nonexistent", + target_user_group: undefined, + manage_users_owner_only: false, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_TARGET); + }); + + test("fails when target has invalid group", () => { + const result = validateRemoveCollaborator({ + requesting_account_id: "owner1", + requesting_user_group: "owner", + target_account_id: "someone", + target_user_group: "admin" as any, + manage_users_owner_only: false, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_TARGET); + }); +}); + +describe("validateAddCollaborator", () => { + test("owner can add when setting disabled", () => { + const result = validateAddCollaborator({ + user_group: "owner", + manage_users_owner_only: false, + }); + expect(result.valid).toBe(true); + }); + + test("collaborator can add when setting disabled", () => { + const result = validateAddCollaborator({ + user_group: "collaborator", + manage_users_owner_only: false, + }); + expect(result.valid).toBe(true); + }); + + test("owner can add when setting enabled", () => { + const result = validateAddCollaborator({ + user_group: "owner", + manage_users_owner_only: true, + }); + expect(result.valid).toBe(true); + }); + + test("collaborator cannot add when setting enabled", () => { + const result = validateAddCollaborator({ + user_group: "collaborator", + manage_users_owner_only: true, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.NOT_OWNER); + }); + + test("fails when user is invalid", () => { + const result = validateAddCollaborator({ + user_group: undefined, + manage_users_owner_only: false, + }); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(OwnershipErrorCode.INVALID_REQUESTING_USER); + }); +}); diff --git a/src/packages/util/project-ownership.ts b/src/packages/util/project-ownership.ts new file mode 100644 index 00000000000..3666aef6d5e --- /dev/null +++ b/src/packages/util/project-ownership.ts @@ -0,0 +1,354 @@ +/* + * This file is part of CoCalc: Copyright © 2025 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +/* +Project ownership transfer validation logic. + +This module provides shared validation functions used by both frontend (for UI) +and backend (for enforcement) to ensure consistent ownership transfer rules. +*/ + +export type UserGroup = "owner" | "collaborator"; + +/** + * Error codes for ownership validation failures. + * + * These codes are machine-friendly identifiers that can be mapped to + * user-friendly error messages in the frontend (see packages/frontend/i18n/common.ts). + * + * All error codes are tested in project-ownership.test.ts with comprehensive + * scenario coverage (see test file header for full scenario matrix). + */ +export enum OwnershipErrorCode { + LAST_OWNER = "LAST_OWNER", // Cannot demote the last owner + NOT_OWNER = "NOT_OWNER", // Only owners can perform this action + INVALID_TARGET = "INVALID_TARGET", // Target user is invalid or not in project + INVALID_USER = "INVALID_USER", // User not found in project + CANNOT_REMOVE_OWNER = "CANNOT_REMOVE_OWNER", // Cannot remove owners directly + INVALID_REQUESTING_USER = "INVALID_REQUESTING_USER", // Requesting user is not a valid member + INVALID_PROJECT_STATE = "INVALID_PROJECT_STATE", // Project data is missing or invalid +} + +export interface ValidationResult { + valid: boolean; + errorCode?: OwnershipErrorCode; + error?: string; +} + +/** + * Type guard to check if a value is a valid UserGroup. + * + * @param value - Value to check + * @returns true if value is "owner" or "collaborator" + */ +export function isUserGroup(value: any): value is UserGroup { + return value === "owner" || value === "collaborator"; +} + +/** + * Count the number of owners in a project. + * + * @param users - Map of account_id to user data with group field + * @returns Number of owners (users with group === "owner") + */ +export function countOwners( + users: { [account_id: string]: { group?: string } } | null | undefined, +): number { + if (!users) { + return 0; + } + let count = 0; + for (const account_id in users) { + if (users[account_id]?.group === "owner") { + count++; + } + } + return count; +} + +/** + * Check if a specific user is the last owner in a project. + * + * @param account_id - The account ID to check + * @param users - Map of all project users + * @returns true if the user is an owner AND is the only owner + */ +export function isLastOwner( + account_id: string, + users: { [account_id: string]: { group?: string } } | null | undefined, +): boolean { + if (!users) { + return false; + } + const userGroup = users[account_id]?.group; + if (userGroup !== "owner") { + return false; + } + return countOwners(users) === 1; +} + +/** + * Validate whether a user type change (promotion/demotion) is allowed. + * + * Validation rules: + * 1. all_users must be provided + * 2. Requesting user must be a valid owner or collaborator + * 3. Only owners can change user types + * 4. Target must be a current project member + * 5. Target must currently be owner or collaborator + * 6. Target new group must be valid (owner or collaborator) + * 7. Cannot demote the last owner (must maintain ≥1 owner) + * + * @param opts.requesting_account_id - Account ID of user making the request + * @param opts.requesting_user_group - Group of the user requesting the change (owner or collaborator) + * @param opts.target_account_id - Account ID of user being changed + * @param opts.target_current_group - Current group of target user + * @param opts.target_new_group - New group to assign to target + * @param opts.all_users - All users in the project + * @returns Validation result with valid flag and optional error details + */ +export function validateUserTypeChange(opts: { + requesting_account_id: string; + requesting_user_group: UserGroup | undefined; + target_account_id: string; + target_current_group: UserGroup | undefined; + target_new_group: UserGroup; + all_users: + | { [account_id: string]: { group?: UserGroup | string } } + | null + | undefined; +}): ValidationResult { + const { + requesting_user_group, + target_account_id, + target_current_group, + target_new_group, + all_users, + } = opts; + + // Rule 1: all_users must be provided (missing = invalid project state) + if (!all_users) { + return { + valid: false, + errorCode: OwnershipErrorCode.INVALID_PROJECT_STATE, + error: "Project users data is required", + }; + } + + // Rule 2: Requesting user must be a valid member (owner or collaborator) + if (!isUserGroup(requesting_user_group)) { + return { + valid: false, + errorCode: OwnershipErrorCode.INVALID_REQUESTING_USER, + error: "Requesting user is not a valid project member", + }; + } + + // Rule 3: Only owners can change user types + if (requesting_user_group !== "owner") { + return { + valid: false, + errorCode: OwnershipErrorCode.NOT_OWNER, + error: "Only project owners can change user types", + }; + } + + // Rule 4: Target must exist in project + if (!all_users[target_account_id]) { + return { + valid: false, + errorCode: OwnershipErrorCode.INVALID_USER, + error: "Target user is not a member of this project", + }; + } + + // Rule 5: Target must currently be owner or collaborator + if (!isUserGroup(target_current_group)) { + return { + valid: false, + errorCode: OwnershipErrorCode.INVALID_TARGET, + error: + "Target user does not have a valid group (must be owner or collaborator)", + }; + } + + // Rule 6: New group must be valid + if (!isUserGroup(target_new_group)) { + return { + valid: false, + errorCode: OwnershipErrorCode.INVALID_TARGET, + error: "New group must be either 'owner' or 'collaborator'", + }; + } + + // Rule 7: If demoting an owner, ensure at least one owner remains + if (target_current_group === "owner" && target_new_group === "collaborator") { + const ownerCount = countOwners(all_users); + if (ownerCount <= 1) { + return { + valid: false, + errorCode: OwnershipErrorCode.LAST_OWNER, + error: + "Cannot change the last owner to collaborator. At least one owner is required.", + }; + } + } + + // All validation passed + return { valid: true }; +} + +/** + * Check if a user can manage collaborators based on their role and project settings. + * + * @param opts.user_group - The user's group (owner or collaborator) + * @param opts.manage_users_owner_only - Project setting for restricting management (database field name) + * @returns true if user can manage collaborators + */ +export function canManageCollaborators(opts: { + user_group: UserGroup | undefined; + manage_users_owner_only: boolean; +}): boolean { + const { user_group, manage_users_owner_only } = opts; + + // User must be a valid member + if (!isUserGroup(user_group)) { + return false; + } + + // If setting is disabled, both owners and collaborators can manage + if (!manage_users_owner_only) { + return true; + } + + // If setting is enabled, only owners can manage + return user_group === "owner"; +} + +/** + * Validate whether removing a collaborator is allowed. + * + * IMPORTANT: Callers MUST provide target_user_group derived from actual project data + * (not from user input). Pass undefined if the target is not in the project. + * This ensures INVALID_TARGET is returned for users not in the project. + * + * Validation rules: + * 1. Requesting user must be a valid member + * 2. Target user must exist in project (undefined target_user_group = not in project) + * 3. Target user must have a valid group + * 4. Cannot remove owners directly (must demote to collaborator first) + * 5. Users can always remove themselves (except owners - they must demote first) + * 6. When onlyOwnersManageCollaborators is enabled, only owners can remove others + * + * @param opts.requesting_account_id - Account ID of user making the request + * @param opts.requesting_user_group - Group of user making the request + * @param opts.target_account_id - Account ID of user being removed + * @param opts.target_user_group - Group of user being removed (undefined if not in project) + * @param opts.manage_users_owner_only - Project setting (database field name) + * @returns Validation result + */ +export function validateRemoveCollaborator(opts: { + requesting_account_id: string; + requesting_user_group: UserGroup | undefined; + target_account_id: string; + target_user_group: UserGroup | undefined; + manage_users_owner_only: boolean; +}): ValidationResult { + const { + requesting_account_id, + requesting_user_group, + target_account_id, + target_user_group, + manage_users_owner_only, + } = opts; + + // Rule 1: Requesting user must be a valid member + if (!isUserGroup(requesting_user_group)) { + return { + valid: false, + errorCode: OwnershipErrorCode.INVALID_REQUESTING_USER, + error: "Requesting user is not a valid project member", + }; + } + + // Rule 2 & 3: Target user must exist and have a valid group + // undefined target_user_group means the target is not in the project + if (!target_user_group) { + return { + valid: false, + errorCode: OwnershipErrorCode.INVALID_TARGET, + error: "Target user is not a member of this project", + }; + } + + if (!isUserGroup(target_user_group)) { + return { + valid: false, + errorCode: OwnershipErrorCode.INVALID_TARGET, + error: "Target user does not have a valid group", + }; + } + + // Rule 4: Cannot remove owners directly (enforces two-step process) + if (target_user_group === "owner") { + return { + valid: false, + errorCode: OwnershipErrorCode.CANNOT_REMOVE_OWNER, + error: "Cannot remove an owner. Demote to collaborator first.", + }; + } + + // Rule 5: Self-removal is always allowed for collaborators + const is_self_remove = requesting_account_id === target_account_id; + if (is_self_remove) { + return { valid: true }; + } + + // Rule 6: Check manage_users_owner_only setting + if (manage_users_owner_only && requesting_user_group !== "owner") { + return { + valid: false, + errorCode: OwnershipErrorCode.NOT_OWNER, + error: + "Only owners can remove collaborators when this setting is enabled", + }; + } + + return { valid: true }; +} + +/** + * Validate whether adding a collaborator is allowed. + * + * @param opts.user_group - Group of user making the request + * @param opts.manage_users_owner_only - Project setting (database field name) + * @returns Validation result + */ +export function validateAddCollaborator(opts: { + user_group: UserGroup | undefined; + manage_users_owner_only: boolean; +}): ValidationResult { + const { user_group, manage_users_owner_only } = opts; + + // Requesting user must be a valid member + if (!isUserGroup(user_group)) { + return { + valid: false, + errorCode: OwnershipErrorCode.INVALID_REQUESTING_USER, + error: "Requesting user is not a valid project member", + }; + } + + // When setting is enabled, only owners can add + if (manage_users_owner_only && user_group !== "owner") { + return { + valid: false, + errorCode: OwnershipErrorCode.NOT_OWNER, + error: "Only owners can add collaborators when this setting is enabled", + }; + } + + return { valid: true }; +}