diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index de81676a..e353f68a 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -283,6 +283,8 @@ def unset_role(self, user_id: int) -> None: def get_member(self, user_id: int) -> Optional[ProjectMember]: """Get project member""" + from .permissions import ProjectPermissions + member = self._member(user_id) if member: return ProjectMember( @@ -291,6 +293,7 @@ def get_member(self, user_id: int) -> Optional[ProjectMember]: email=member.user.email, project_role=ProjectRole(member.role), workspace_role=self.workspace.get_user_role(member.user), + role=ProjectPermissions.get_user_project_role(self, member.user), ) def members_by_role(self, role: ProjectRole) -> List[int]: @@ -350,6 +353,7 @@ class ProjectMember: username: str workspace_role: WorkspaceRole project_role: Optional[ProjectRole] + role: ProjectRole @dataclass @@ -359,7 +363,8 @@ class ProjectAccessDetail: role: str username: str name: Optional[str] - project_permission: str + workspace_role: str + project_role: Optional[ProjectRole] type: str diff --git a/server/mergin/sync/private_api.yaml b/server/mergin/sync/private_api.yaml index 4160ed07..5db24675 100644 --- a/server/mergin/sync/private_api.yaml +++ b/server/mergin/sync/private_api.yaml @@ -329,6 +329,7 @@ paths: required: true schema: type: string + # // Kept for EE (collaborators + invitation) access, TODO: remove when a separate invitation endpoint is implemented get: tags: - project @@ -350,34 +351,29 @@ paths: "404": $ref: "#/components/responses/NotFoundResp" x-openapi-router-controller: mergin.sync.private_api_controller + /project/{id}/public: + parameters: + - name: id + in: path + description: Project uuid + required: true + schema: + type: string patch: - summary: Update direct project access (sharing) - operationId: update_project_access + summary: Update public project flag + operationId: update_project_public_flag requestBody: - description: Request data required: true content: application/json: schema: type: object properties: - user_id: - type: integer public: type: boolean - nullable: true - role: - type: string - enum: - - owner - - writer - - editor - - reader - - none - example: writer responses: - "200": - $ref: "#/components/schemas/ProjectAccessUpdated" + "204": + description: OK "400": $ref: "#/components/responses/BadStatusResp" "401": @@ -551,8 +547,7 @@ components: - id - type - email - - project_permission - - role + - workspace_role properties: id: description: User/Invitation (uu)id @@ -569,16 +564,9 @@ components: type: string format: email example: john.doe@example.com - role: + workspace_role: description: Workspace role - type: string - enum: - - owner - - admin - - writer - - editor - - reader - - guest + $ref: "#/components/schemas/WorkspaceRole" username: description: Present only for type `member` type: string @@ -587,13 +575,13 @@ components: description: Present only for type `member` type: string example: John Doe - project_permission: - type: string - enum: - - owner - - writer - - editor - - reader + role: + description: Project role defined as combination of project and workspace roles + $ref: "#/components/schemas/ProjectRole" + project_role: + nullable: true + description: Project role defined in database, not calculated version + $ref: "#/components/schemas/ProjectRole" invitation: description: Present only for type `invitation` type: object @@ -658,3 +646,19 @@ components: items: type: integer example: [1] + WorkspaceRole: + type: string + enum: + - owner + - admin + - writer + - editor + - reader + - guest + ProjectRole: + type: string + enum: + - owner + - writer + - editor + - reader diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 616ac69d..00d0a13d 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -26,7 +26,6 @@ AdminProjectSchema, ProjectAccessSchema, ProjectAccessDetailSchema, - ProjectVersionListSchema, ) from .permissions import ( require_project_by_uuid, @@ -304,28 +303,16 @@ def unsubscribe_project(id): # pylint: disable=W0612 @auth_required -def update_project_access(id: str): - """Modify shared project access +def update_project_public_flag(id: str): + """Modify the project's public flag :param id: Project uuid """ project = require_project_by_uuid(id, ProjectPermissions.Update) - if "public" in request.json: - project.public = request.json["public"] - - if "user_id" in request.json and "role" in request.json: - user = User.query.filter_by( - id=request.json["user_id"], active=True - ).first_or_404("User does not exist") - - if request.json["role"] == "none": - project.unset_role(user.id) - else: - project.set_role(user.id, ProjectRole(request.json["role"])) - project_access_granted.send(project, user_id=user.id) + project.public = request.json.get("public", False) db.session.commit() - return ProjectAccessSchema().dump(project), 200 + return NoContent, 204 @auth_required diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index a167cb26..bd4b1c0c 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -265,6 +265,11 @@ components: - reader - guest example: writer + Role: + allOf: + - $ref: '#/components/schemas/ProjectRole' + nullable: false + description: combination of workspace role and project role ProjectMember: type: object properties: @@ -282,3 +287,5 @@ components: $ref: '#/components/schemas/WorkspaceRole' project_role: $ref: '#/components/schemas/ProjectRole' + role: + $ref: '#/components/schemas/Role' diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index d2076910..30b2bcb8 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -81,6 +81,7 @@ def get_project_collaborators(id): email=user.email, project_role=project_role, workspace_role=workspace_role, + role=ProjectPermissions.get_user_project_role(project, user), ) ) diff --git a/server/mergin/sync/schemas.py b/server/mergin/sync/schemas.py index c782c5ca..0d8a11d5 100644 --- a/server/mergin/sync/schemas.py +++ b/server/mergin/sync/schemas.py @@ -358,10 +358,11 @@ def _deserialize(self, value, attr, data, **kwargs): class ProjectAccessDetailSchema(Schema): id = StrOrInt() email = fields.String() - role = fields.String() + role = fields.Enum(enum=ProjectRole, by_value=True) username = fields.String() name = fields.String() - project_permission = fields.String() + project_role = fields.Enum(enum=ProjectRole, by_value=True) + workspace_role = fields.Enum(enum=WorkspaceRole, by_value=True) type = fields.String() invitation = fields.Nested(ProjectInvitationAccessSchema()) @@ -404,3 +405,4 @@ class ProjectMemberSchema(Schema): email = fields.Email() project_role = fields.Enum(enum=ProjectRole, by_value=True) workspace_role = fields.Enum(enum=WorkspaceRole, by_value=True) + role = fields.Enum(enum=ProjectRole, by_value=True) diff --git a/server/mergin/sync/workspace.py b/server/mergin/sync/workspace.py index 8cc0a27a..79aef2b9 100644 --- a/server/mergin/sync/workspace.py +++ b/server/mergin/sync/workspace.py @@ -307,6 +307,7 @@ def access_requests_query(): """Project access base query""" return AccessRequest.query.join(Project) + # not used in CE, TODO: remove together with EE when it's replaced there def project_access(self, project: Project) -> List[ProjectAccessDetail]: """ Project access users overview @@ -323,17 +324,19 @@ def project_access(self, project: Project) -> List[ProjectAccessDetail]: direct_members_ids = [u.user_id for u in project.project_users] users = User.query.filter(User.active.is_(True)).order_by(User.email) - direct_members = users.filter(User.id.in_(direct_members_ids)).all() + direct_members: list[User] = users.filter(User.id.in_(direct_members_ids)).all() for dm in direct_members: - project_role = ProjectPermissions.get_user_project_role(project, dm) + project_permission = ProjectPermissions.get_user_project_role(project, dm) + project_role = project.get_role(dm.id) member = ProjectAccessDetail( id=dm.id, username=dm.username, - role=ws.get_user_role(dm).value, + workspace_role=ws.get_user_role(dm).value, name=dm.profile.name(), email=dm.email, - project_permission=project_role and project_role.value, + role=project_permission and project_permission.value, + project_role=project_role.value if project_role else None, type="member", ) result.append(member) @@ -345,8 +348,9 @@ def project_access(self, project: Project) -> List[ProjectAccessDetail]: username=gm.username, name=gm.profile.name(), email=gm.email, + workspace_role=global_role, role=global_role, - project_permission=global_role, + project_role=None, type="member", ) result.append(member) diff --git a/server/mergin/tests/test_private_project_api.py b/server/mergin/tests/test_private_project_api.py index c8aabd9e..5d5ec518 100644 --- a/server/mergin/tests/test_private_project_api.py +++ b/server/mergin/tests/test_private_project_api.py @@ -333,72 +333,16 @@ def test_template_projects(client): def test_update_project_access(client, diff_project): - url = f"/app/project/{diff_project.id}/access" + url = f"/app/project/{diff_project.id}/public" original_creator_id = diff_project.creator.id - # create user and grant him write access - user = add_user("reader", "reader") - assert not diff_project.get_role(user.id) - - data = {"user_id": user.id, "role": "none"} - # nothing happens - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert not diff_project.get_role(user.id) - - # grant read access - data["role"] = "reader" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert diff_project.get_role(user.id) is ProjectRole.READER - - # grant editor access - data["role"] = "editor" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert diff_project.get_role(user.id) is ProjectRole.EDITOR - - # change to write access - data["role"] = "writer" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert diff_project.get_role(user.id) is ProjectRole.WRITER - - # downgrade to read access - data["role"] = "reader" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert diff_project.get_role(user.id) is ProjectRole.READER - - # remove access - data["role"] = "none" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert not diff_project.get_role(user.id) + data = {} # update public parameter => public: True data["public"] = True resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 + assert resp.status_code == 204 assert diff_project.public == True - # access of project creator can be removed - data["user_id"] = diff_project.creator_id - resp = client.patch( - f"/app/project/{diff_project.id}/access", - headers=json_headers, - data=json.dumps(data), - ) - assert resp.status_code == 200 - db.session.rollback() - assert not diff_project.get_role(user.id) - assert diff_project.creator_id == original_creator_id - - # try to grant access to inaccessible user - data = {"user_id": 100, "role": "reader"} - # nothing happens - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 404 - def test_restore_project(client, diff_project): """Test delete project by user and restore by admin""" @@ -474,61 +418,3 @@ def test_admin_project_list(client): p.delete() resp = client.get("/app/admin/projects?page=1&per_page=15&like=mergin") assert len(resp.json["items"]) == 14 - - -def test_get_project_access(client): - workspace = create_workspace() - user = User.query.filter(User.username == "mergin").first() - project = create_project("test-project", workspace, user) - url = f"/app/project/{project.id}/access" - users = [] - for i in range(5): - users.append(add_user(str(i), str(i))) - Configuration.GLOBAL_ADMIN = False - Configuration.GLOBAL_WRITE = False - Configuration.GLOBAL_READ = False - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 1 - assert resp.json[0]["project_permission"] == "owner" - project.set_role(users[0].id, ProjectRole.OWNER) - project.set_role(users[1].id, ProjectRole.WRITER) - project.set_role(users[2].id, ProjectRole.READER) - db.session.commit() - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 4 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 2 - assert sum(map(lambda x: int(x["project_permission"] == "writer"), resp.json)) == 1 - assert sum(map(lambda x: int(x["project_permission"] == "reader"), resp.json)) == 1 - # user3 does not have access to the project - assert not any(users[3].email == access["email"] for access in resp.json) - assert any(users[2].email == access["email"] for access in resp.json) - Configuration.GLOBAL_READ = True - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 6 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 2 - assert sum(map(lambda x: int(x["project_permission"] == "writer"), resp.json)) == 1 - assert sum(map(lambda x: int(x["project_permission"] == "reader"), resp.json)) == 3 - Configuration.GLOBAL_WRITE = True - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 6 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 2 - assert sum(map(lambda x: int(x["project_permission"] == "writer"), resp.json)) == 4 - assert sum(map(lambda x: int(x["project_permission"] == "reader"), resp.json)) == 0 - Configuration.GLOBAL_ADMIN = True - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 6 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 6 - assert sum(map(lambda x: int(x["project_permission"] == "writer"), resp.json)) == 0 - assert sum(map(lambda x: int(x["project_permission"] == "reader"), resp.json)) == 0 - # pretend a user was deleted to test that api can handle it - users[3].inactivate() - users[3].anonymize() - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 5 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 5 diff --git a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue index 86b9cd2b..9032e85b 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue @@ -215,7 +215,8 @@ const changeStatusDialog = () => { await adminStore.updateUser({ username: user.value.username, data: { - active: !user.value.active + active: !user.value.active, + is_admin: user.value.is_admin } }) } diff --git a/web-app/packages/lib/src/common/permission_utils.ts b/web-app/packages/lib/src/common/permission_utils.ts index e810b2db..4bba95b5 100644 --- a/web-app/packages/lib/src/common/permission_utils.ts +++ b/web-app/packages/lib/src/common/permission_utils.ts @@ -16,7 +16,6 @@ export enum WorkspaceRole { } export enum ProjectRole { - none, reader, editor, writer, @@ -37,9 +36,10 @@ export type WorkspaceRoleName = | 'admin' | 'owner' -export type ProjectRoleName = - | Extract - | 'none' +export type ProjectRoleName = Extract< + WorkspaceRoleName, + 'reader' | 'editor' | 'writer' | 'owner' +> export type ProjectPermissionName = 'owner' | 'write' | 'edit' | 'read' @@ -63,7 +63,6 @@ export const USER_ROLE_BY_NAME: Record = { } export const PROJECT_ROLE_NAME_BY_ROLE: Record = { - [ProjectRole.none]: 'none', [ProjectRole.reader]: 'reader', [ProjectRole.editor]: 'editor', [ProjectRole.writer]: 'writer', @@ -71,7 +70,6 @@ export const PROJECT_ROLE_NAME_BY_ROLE: Record = { } export const PROJECT_ROLE_BY_NAME: Record = { - none: ProjectRole.none, reader: ProjectRole.reader, editor: ProjectRole.editor, writer: ProjectRole.writer, @@ -130,8 +128,7 @@ export function isAtLeastGlobalRole( roleName: ProjectRoleName, globalRole: GlobalRole ): boolean { - // We have also none role, so we need to add 1 to the global role - return PROJECT_ROLE_BY_NAME[roleName] >= globalRole + 1 + return PROJECT_ROLE_BY_NAME[roleName] >= globalRole } export function getProjectRoleNameValues(): DropdownOption[] { @@ -191,8 +188,7 @@ export function getProjectAccessKeyByRoleName( owner: 'ownersnames', writer: 'writersnames', editor: 'editorsnames', - reader: 'readersnames', - none: undefined + reader: 'readersnames' } return mapper[roleName] } @@ -200,12 +196,11 @@ export function getProjectAccessKeyByRoleName( export function getProjectPermissionByRoleName( roleName: ProjectRoleName ): ProjectPermissionName { - const mapper: Record = { + const mapper: Record = { owner: 'owner', writer: 'write', editor: 'edit', - reader: 'read', - none: undefined + reader: 'read' } return mapper[roleName] } diff --git a/web-app/packages/lib/src/modules/project/components/AccessRequestTableTemplate.vue b/web-app/packages/lib/src/modules/project/components/AccessRequestTableTemplate.vue index 91eb6f22..e0e813c2 100644 --- a/web-app/packages/lib/src/modules/project/components/AccessRequestTableTemplate.vue +++ b/web-app/packages/lib/src/modules/project/components/AccessRequestTableTemplate.vue @@ -187,7 +187,7 @@ export default defineComponent({ await this.acceptProjectAccessRequest({ data, itemId: request.id, - namespace: this.namespace + workspace: this.namespace }) await this.updatePaginationOrFetch() } catch (err) { @@ -200,7 +200,7 @@ export default defineComponent({ async cancelRequest(request) { await this.cancelProjectAccessRequest({ itemId: request.id, - namespace: this.namespace + workspace: this.namespace }) await this.updatePaginationOrFetch() }, diff --git a/web-app/packages/lib/src/modules/project/components/ProjectAccessRequests.vue b/web-app/packages/lib/src/modules/project/components/ProjectAccessRequests.vue index 80d0eb3e..bc7af479 100644 --- a/web-app/packages/lib/src/modules/project/components/ProjectAccessRequests.vue +++ b/web-app/packages/lib/src/modules/project/components/ProjectAccessRequests.vue @@ -158,7 +158,7 @@ export default defineComponent({ await this.acceptProjectAccessRequest({ data, itemId: request.id, - namespace: this.project.namespace + workspace: this.project.namespace }) await this.updatePaginationOrFetch() } catch (err) { @@ -173,7 +173,7 @@ export default defineComponent({ async cancelRequest(request) { await this.cancelProjectAccessRequest({ itemId: request.id, - namespace: this.project.namespace + workspace: this.project.namespace }) await this.updatePaginationOrFetch() }, diff --git a/web-app/packages/lib/src/modules/project/components/ProjectMembersTable.vue b/web-app/packages/lib/src/modules/project/components/ProjectMembersTable.vue index f96f9e6d..e7e01062 100644 --- a/web-app/packages/lib/src/modules/project/components/ProjectMembersTable.vue +++ b/web-app/packages/lib/src/modules/project/components/ProjectMembersTable.vue @@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial ]" > (), { allowRemove: true }) - const itemsPerPage = ref(10) const columns = ref([ { @@ -134,6 +130,8 @@ const columns = ref([ fixed: true } ]) + +// TODO: do not bother with GlobalRole but use workspace_role attribute const roles = computed(() => projectStore.availableRoles.map((item) => ({ ...item, @@ -145,34 +143,37 @@ const roles = computed(() => ) const loggedUser = computed(() => userStore.loggedUser) const searchedItems = computed(() => - projectStore.access.filter((item) => { + projectStore.collaborators.filter((item) => { return [item.username, item.email].some( (v) => v && v.toString().toLowerCase().includes(projectStore.accessSearch) ) }) ) -function canRemoveMember(item: ProjectAccessDetail) { - return props.allowRemove && item.id !== loggedUser.value?.id +function canRemoveMember(item: ProjectCollaborator) { + return ( + item.workspace_role === USER_ROLE_NAME_BY_ROLE[WorkspaceRole.guest] && + item.id !== loggedUser.value?.id + ) } -function removeMember(item: ProjectAccessDetail) { +function removeMember(item: ProjectCollaborator) { projectStore.removeProjectAccess(item) } -function roleUpdate(item: ProjectAccessDetail, value: ProjectRoleName) { - projectStore.updateProjectAccess({ +function roleUpdate(item: ProjectCollaborator, value: ProjectRoleName) { + projectStore.updateProjectCollaborators({ projectId: projectStore.project.id, - userId: item.id, + collaborator: item, data: { role: value } }) } onUnmounted(() => { - projectStore.access = [] + projectStore.collaborators = [] }) -projectStore.getProjectAccess(projectStore.project?.id) +projectStore.getProjectCollaborators(projectStore.project?.id) diff --git a/web-app/packages/lib/src/modules/project/components/ProjectsTableDataLoader.vue b/web-app/packages/lib/src/modules/project/components/ProjectsTableDataLoader.vue index 04229c27..9e79484b 100644 --- a/web-app/packages/lib/src/modules/project/components/ProjectsTableDataLoader.vue +++ b/web-app/packages/lib/src/modules/project/components/ProjectsTableDataLoader.vue @@ -60,10 +60,6 @@ export default defineComponent({ default: false }, namespace: String, - asAdmin: { - type: Boolean, - default: false - }, public: { type: Boolean, default: true @@ -136,9 +132,6 @@ export default defineComponent({ if (projectGridState.namespace) { params.only_namespace = projectGridState.namespace } - if (this.asAdmin) { - params.as_admin = true - } if (!this.public) { params.public = false } diff --git a/web-app/packages/lib/src/modules/project/projectApi.ts b/web-app/packages/lib/src/modules/project/projectApi.ts index fbd77fdd..7dd04ba7 100644 --- a/web-app/packages/lib/src/modules/project/projectApi.ts +++ b/web-app/packages/lib/src/modules/project/projectApi.ts @@ -25,8 +25,10 @@ import { ProjectAccessDetail, ProjectAccess, ProjectVersionFileChange, - UpdateProjectPayload, - UpdatePublicFlagParams + UpdateProjectCollaboratorPayload, + UpdatePublicFlagParams, + ProjectCollaborator, + AddProjectCollaboratorPayload } from '@/modules/project/types' export const ProjectApi = { @@ -174,17 +176,29 @@ export const ProjectApi = { ) }, - async updateProjectAccess( + async addProjectCollaborator( + id: string, + data: AddProjectCollaboratorPayload + ): Promise> { + return ProjectModule.httpService.post( + `/v2/projects/${id}/collaborators`, + data, + { + validateStatus + } + ) + }, + + async updateProjectCollaborator( id: string, userId: number, - data: UpdateProjectPayload, - withRetry?: boolean - ): Promise> { + data: UpdateProjectCollaboratorPayload + ): Promise> { return ProjectModule.httpService.patch( `/v2/projects/${id}/collaborators/${userId}`, data, { - ...(withRetry ? getDefaultRetryOptions() : {}) + validateStatus } ) }, @@ -199,15 +213,14 @@ export const ProjectApi = { }) }, - async removeProjectAccess( + async removeProjectCollaborator( id: string, - userId: number, - withRetry?: boolean - ): Promise> { + userId: number + ): Promise> { return ProjectModule.httpService.delete( `/v2/projects/${id}/collaborators/${userId}`, { - ...(withRetry ? getDefaultRetryOptions() : {}) + validateStatus } ) }, @@ -302,6 +315,7 @@ export const ProjectApi = { return ProjectModule.httpService.get(url, { responseType: 'blob' }) }, + // Kept for EE (collaborators + invitation) access, TODO: remove when a separate invitation endpoint is implemented async getProjectAccess( projectId: string ): Promise> { @@ -317,5 +331,13 @@ export const ProjectApi = { return ProjectModule.httpService.get( `/v1/resource/changesets/${workspace}/${projectName}/${versionId}?path=${path}` ) + }, + + async getProjectCollaborators( + projectId: string + ): Promise> { + return ProjectModule.httpService.get( + `/v2/projects/${projectId}/collaborators` + ) } } diff --git a/web-app/packages/lib/src/modules/project/store.ts b/web-app/packages/lib/src/modules/project/store.ts index 363d2ec7..e9b1f4e4 100644 --- a/web-app/packages/lib/src/modules/project/store.ts +++ b/web-app/packages/lib/src/modules/project/store.ts @@ -41,11 +41,11 @@ import { SaveProjectSettings, ErrorCodes, ProjectAccessDetail, - UpdateProjectAccessParams, ProjectVersionFileChange, ProjectVersionListItem, - UpdateProjectPayload, - UpdatePublicFlagParams + UpdateProjectCollaboratorPayload, + UpdatePublicFlagParams, + ProjectCollaborator } from '@/modules/project/types' import { useUserStore } from '@/modules/user/store' @@ -76,6 +76,7 @@ export interface ProjectState { availablePermissions: DropdownOption[] availableRoles: DropdownOption[] versionsChangesetLoading: boolean + collaborators: ProjectCollaborator[] } export const useProjectStore = defineStore('projectModule', { @@ -100,7 +101,8 @@ export const useProjectStore = defineStore('projectModule', { accessSorting: undefined, availablePermissions: permissionUtils.getProjectPermissionsValues(), availableRoles: permissionUtils.getProjectRoleNameValues(), - versionsChangesetLoading: false + versionsChangesetLoading: false, + collaborators: [] }), getters: { @@ -749,6 +751,22 @@ export const useProjectStore = defineStore('projectModule', { } }, + async getProjectCollaborators(projectId: string) { + const notificationStore = useNotificationStore() + + try { + this.accessLoading = true + const response = await ProjectApi.getProjectCollaborators(projectId) + this.collaborators = response.data + } catch { + notificationStore.error({ + text: 'Failed to get project collaborators' + }) + } finally { + this.accessLoading = false + } + }, + /** * Removes the given user's access to the current project. * @@ -757,15 +775,17 @@ export const useProjectStore = defineStore('projectModule', { async removeProjectAccess( item: Pick ) { - const notificationStore = useNotificationStore() this.accessLoading = true + const notificationStore = useNotificationStore() try { - const response = await ProjectApi.removeProjectAccess( + await ProjectApi.removeProjectCollaborator( this.project.id, - item.id + Number(item.id) ) this.access = this.access.filter((access) => access.id !== item.id) - this.project.access = response.data + this.collaborators = this.collaborators.filter( + (collaborators) => collaborators.id !== item.id + ) } catch { notificationStore.error({ text: `Failed to update project access for user ${item.username}` @@ -783,33 +803,80 @@ export const useProjectStore = defineStore('projectModule', { */ async updateProjectAccess(payload: { projectId: string - userId: number - data: UpdateProjectPayload + access: ProjectAccessDetail + data: UpdateProjectCollaboratorPayload }) { - const notificationStore = useNotificationStore() this.accessLoading = true try { - const response = await ProjectApi.updateProjectAccess( - payload.projectId, - payload.userId, - payload.data - ) + if (!payload.access.project_role) { + await ProjectApi.addProjectCollaborator(payload.projectId, { + ...payload.data, + username: payload.access.username + }) + } else { + await ProjectApi.updateProjectCollaborator( + payload.projectId, + Number(payload.access.id), + payload.data + ) + } this.access = this.access.map((access) => { - if (access.id === payload.userId) { - access.project_permission = payload.data.role + if (access.id === payload.access.id) { + access.role = payload.data.role + access.project_role = payload.data.role } return access }) - this.project.access = response.data - } catch { - notificationStore.error({ - text: `Failed to update project access` + } catch (err) { + this.handleProjectAccessError(err, 'Failed to update project access') + } finally { + this.accessLoading = false + } + }, + + async updateProjectCollaborators(payload: { + projectId: string + collaborator: ProjectCollaborator + data: UpdateProjectCollaboratorPayload + }) { + this.accessLoading = true + try { + if (!payload.collaborator.project_role) { + await ProjectApi.addProjectCollaborator(payload.projectId, { + ...payload.data, + username: payload.collaborator.username + }) + } else { + await ProjectApi.updateProjectCollaborator( + payload.projectId, + Number(payload.collaborator.id), + payload.data + ) + } + this.collaborators = this.collaborators.map((collaborator) => { + if (collaborator.id === payload.collaborator.id) { + collaborator.role = payload.data.role + collaborator.project_role = payload.data.role + } + return collaborator }) + } catch (err) { + this.handleProjectAccessError( + err, + 'Failed to update project collaborator' + ) } finally { this.accessLoading = false } }, + handleProjectAccessError(err: unknown, defaultMessage: string) { + const notificationStore = useNotificationStore() + notificationStore.error({ + text: getErrorMessage(err, defaultMessage) + }) + }, + async updatePublicFlag(payload: { projectId: string data: UpdatePublicFlagParams diff --git a/web-app/packages/lib/src/modules/project/types.ts b/web-app/packages/lib/src/modules/project/types.ts index e8c39aaa..be6dc0dc 100644 --- a/web-app/packages/lib/src/modules/project/types.ts +++ b/web-app/packages/lib/src/modules/project/types.ts @@ -5,7 +5,8 @@ /* eslint-disable camelcase */ import { ProjectRoleName, - ProjectPermissionName + ProjectPermissionName, + WorkspaceRoleName } from '@/common/permission_utils' import { PaginatedRequestParamsApi, @@ -184,12 +185,12 @@ export interface GetAccessRequestsPayload extends GetUserAccessRequestsPayload { export interface AcceptProjectAccessRequestPayload { itemId: number data: AcceptProjectAccessRequestData - namespace?: string + workspace?: string } export interface CancelProjectAccessRequestPayload { itemId: number - namespace: string + workspace: string } export interface CreateProjectParams { @@ -292,7 +293,12 @@ export type EnhancedProjectDetail = ProjectDetail & { path: string } -export interface UpdateProjectPayload { +export interface AddProjectCollaboratorPayload { + role: ProjectRoleName + username: string +} + +export interface UpdateProjectCollaboratorPayload { role: ProjectRoleName } @@ -341,11 +347,31 @@ export interface ProjectVersionFileChange { export type ErrorCodes = 'UpdateProjectAccessError' +export type ProjectAccessDetailType = 'invitation' | 'member' + export interface ProjectAccessDetail { + id: number | string + type: ProjectAccessDetailType + workspace_role: WorkspaceRoleName + name?: string + email: string + username?: string + role?: ProjectRoleName + project_role?: ProjectRoleName | null + invitation?: { + expires_at: string + projects?: { + ids: string[] + permissions: ProjectPermissionName + } + } +} + +export interface ProjectCollaborator { id: number - type: 'member' email: string username: string - project_permission: ProjectRoleName - name: string + workspace_role: WorkspaceRoleName + project_role: ProjectRoleName | null + role: ProjectRoleName }