From 396a8a7645bece65009a464eb9dc0bfa043963d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 24 Jun 2026 20:46:38 +0200 Subject: [PATCH 1/8] feat: add public project toggle Add a `public` flag to projects, flippable only by the organization owner (or a server admin) via a dedicated endpoint (PUT /v2/projects/{id}/publishing). Surfaced as a confirmation-guarded switch in advanced project settings. First sub-task of Community Translation v1.0 (#3763). The public-projects discovery page, community permission, and the public/private indicator (pending design) follow as separate sub-tasks merged into this branch. --- .../project/ProjectsPublishingController.kt | 54 ++++++++ .../io/tolgee/hateoas/project/ProjectModel.kt | 2 + .../hateoas/project/ProjectModelAssembler.kt | 1 + .../ProjectsControllerPublishingTest.kt | 121 ++++++++++++++++++ .../service/ProjectPublishingCachingTest.kt | 63 +++++++++ .../data/ProjectPublishingTestData.kt | 32 +++++ .../io/tolgee/dtos/cacheable/ProjectDto.kt | 2 + .../project/SetProjectPublicRequest.kt | 8 ++ .../main/kotlin/io/tolgee/model/Project.kt | 5 + .../io/tolgee/model/views/ProjectView.kt | 1 + .../model/views/ProjectWithLanguagesView.kt | 2 + .../model/views/ProjectWithStatsView.kt | 1 + .../io/tolgee/repository/ProjectRepository.kt | 1 + .../tolgee/service/project/ProjectService.kt | 16 ++- .../main/resources/db/changelog/schema.xml | 15 +++ e2e/cypress/common/shared.ts | 4 + .../e2e/projects/settings.public.cy.ts | 69 ++++++++++ e2e/cypress/support/dataCyType.d.ts | 1 + webapp/src/service/apiSchema.generated.ts | 55 ++++++++ .../src/service/apiSchemaTypes.generated.ts | 1 + .../project/ProjectSettingsAdvanced.tsx | 54 +++++++- 21 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsPublishingController.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerPublishingTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/service/ProjectPublishingCachingTest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectPublishingTestData.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/project/SetProjectPublicRequest.kt create mode 100644 e2e/cypress/e2e/projects/settings.public.cy.ts diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsPublishingController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsPublishingController.kt new file mode 100644 index 00000000000..e4e6b0f3385 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsPublishingController.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020. Tolgee + */ + +package io.tolgee.api.v2.controllers.project + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.activity.RequestActivity +import io.tolgee.activity.data.ActivityType +import io.tolgee.dtos.request.project.SetProjectPublicRequest +import io.tolgee.hateoas.project.ProjectModel +import io.tolgee.hateoas.project.ProjectModelAssembler +import io.tolgee.model.enums.Scope +import io.tolgee.security.ProjectHolder +import io.tolgee.security.authentication.RequiresSuperAuthentication +import io.tolgee.security.authorization.RequiresProjectPermissions +import io.tolgee.service.organization.OrganizationRoleService +import io.tolgee.service.project.ProjectService +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Suppress(names = ["MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection"]) +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping(value = ["/v2/projects"]) +@Tag(name = "Project Publishing", description = "Marks a project as public or private (organization owner only)") +class ProjectsPublishingController( + private val projectService: ProjectService, + private val projectHolder: ProjectHolder, + private val organizationRoleService: OrganizationRoleService, + private val projectModelAssembler: ProjectModelAssembler, +) { + @PutMapping(value = ["/{projectId:[0-9]+}/publishing"]) + @Operation( + summary = "Set project publishing state", + description = "Marks the project as public or private. Only the organization owner can change this.", + ) + @RequestActivity(ActivityType.EDIT_PROJECT) + @RequiresProjectPermissions([Scope.PROJECT_EDIT]) + @RequiresSuperAuthentication + fun setProjectPublic( + @RequestBody @Valid + dto: SetProjectPublicRequest, + ): ProjectModel { + organizationRoleService.checkUserIsOwnerOrServerAdmin(projectHolder.project.organizationOwnerId) + val project = projectService.setPublic(projectHolder.project.id, dto.public) + return projectModelAssembler.toModel(projectService.getView(project.id)) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt index 49a8ee04534..1a8ccd534a3 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt @@ -26,6 +26,8 @@ open class ProjectModel( val useNamespaces: Boolean, val useBranching: Boolean, val useQaChecks: Boolean, + @Schema(description = "Whether the project is public — discoverable and open to community suggestions") + val public: Boolean, val defaultNamespace: NamespaceModel?, val organizationRole: OrganizationRoleType?, @Schema(description = "Current user's direct permission", example = "MANAGE") diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt index c60a710fcdb..8c070ccb8cd 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt @@ -57,6 +57,7 @@ class ProjectModelAssembler( useNamespaces = view.useNamespaces, useBranching = view.useBranching, useQaChecks = view.useQaChecks, + public = view.public, defaultNamespace = defaultNamespace, directPermission = view.directPermission?.let { permissionModelAssembler.toModel(it) }, computedPermission = computedPermissionModelAssembler.toModel(computedPermissions), diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerPublishingTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerPublishingTest.kt new file mode 100644 index 00000000000..74e1fc3ab99 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerPublishingTest.kt @@ -0,0 +1,121 @@ +package io.tolgee.api.v2.controllers.v2ProjectsController + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.activity.data.ActivityType +import io.tolgee.activity.data.PropertyModification +import io.tolgee.constants.Message +import io.tolgee.development.testDataBuilder.data.ProjectPublishingTestData +import io.tolgee.dtos.request.project.SetProjectPublicRequest +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andHasErrorMessage +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +@AutoConfigureMockMvc +class ProjectsControllerPublishingTest : ProjectAuthControllerTest("/v2/projects/") { + private lateinit var testData: ProjectPublishingTestData + + @BeforeEach + fun setup() { + testData = ProjectPublishingTestData() + testDataService.saveTestData(testData.root) + projectSupplier = { testData.project } + } + + @Test + @ProjectJWTAuthTestMethod + fun `owner makes the project public`() { + userAccount = testData.owner + performProjectAuthPut("/publishing", SetProjectPublicRequest(public = true)).andIsOk.andAssertThatJson { + node("public").isEqualTo(true) + node("useQaChecks").isEqualTo(false) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `owner makes the project private again`() { + userAccount = testData.owner + performProjectAuthPut("/publishing", SetProjectPublicRequest(public = true)).andIsOk + performProjectAuthPut("/publishing", SetProjectPublicRequest(public = false)).andIsOk.andAssertThatJson { + node("public").isEqualTo(false) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `no-op flip from default keeps it private`() { + userAccount = testData.owner + performProjectAuthPut("/publishing", SetProjectPublicRequest(public = false)).andIsOk.andAssertThatJson { + node("public").isEqualTo(false) + } + publicModifications().assert.isEmpty() + } + + @Test + @ProjectJWTAuthTestMethod + fun `no-op flip from public keeps it public`() { + userAccount = testData.owner + performProjectAuthPut("/publishing", SetProjectPublicRequest(public = true)).andIsOk + performProjectAuthPut("/publishing", SetProjectPublicRequest(public = true)).andIsOk.andAssertThatJson { + node("public").isEqualTo(true) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `non-owner with MANAGE cannot publish`() { + userAccount = testData.manager + performProjectAuthPut("/publishing", SetProjectPublicRequest(public = true)) + .andIsForbidden + .andHasErrorMessage(Message.USER_IS_NOT_OWNER_OF_ORGANIZATION) + } + + @Test + @ProjectJWTAuthTestMethod + fun `server admin can publish`() { + userAccount = testData.serverAdmin + performProjectAuthPut("/publishing", SetProjectPublicRequest(public = true)).andIsOk.andAssertThatJson { + node("public").isEqualTo(true) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `records an activity revision for the flip`() { + userAccount = testData.owner + performProjectAuthPut("/publishing", SetProjectPublicRequest(public = true)).andIsOk + assertPublicActivityRecorded() + } + + private fun assertPublicActivityRecorded() { + val publicModifications = publicModifications() + publicModifications.assert.hasSize(1) + val publicModification = publicModifications.single()["public"]!! + publicModification.old.assert.isEqualTo(false) + publicModification.new.assert.isEqualTo(true) + } + + @Suppress("UNCHECKED_CAST") + private fun publicModifications(): List> { + val result = + entityManager + .createQuery( + """select ame.modifications from ActivityRevision ar + |join ar.modifiedEntities ame + |where ar.type = :type and ar.projectId = :projectId + """.trimMargin(), + ).setParameter("type", ActivityType.EDIT_PROJECT) + .setParameter("projectId", testData.project.id) + .resultList as List> + return result.filter { it.containsKey("public") } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/service/ProjectPublishingCachingTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/ProjectPublishingCachingTest.kt new file mode 100644 index 00000000000..4c9bf049598 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/service/ProjectPublishingCachingTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2020. Tolgee + */ + +package io.tolgee.service + +import io.tolgee.AbstractSpringTest +import io.tolgee.constants.Caches +import io.tolgee.development.testDataBuilder.data.BaseTestData +import io.tolgee.testing.assert +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest( + properties = [ + "tolgee.cache.enabled=true", + ], +) +class ProjectPublishingCachingTest : AbstractSpringTest() { + private lateinit var testData: BaseTestData + + @BeforeEach + fun setup() { + testData = BaseTestData() + testDataService.saveTestData(testData.root) + clearCaches() + } + + @AfterEach + fun cleanup() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `it evicts the project cache when publishing state changes`() { + populateCache() + executeInNewTransaction { + projectService.setPublic(testData.project.id, true) + } + assertCacheEvicted() + } + + private fun populateCache() { + projectService.findDto(testData.project.id) + cacheManager + .getCache(Caches.PROJECTS)!! + .get(testData.project.id) + ?.get() + .assert + .isNotNull + } + + private fun assertCacheEvicted() { + cacheManager + .getCache(Caches.PROJECTS)!! + .get(testData.project.id) + ?.get() + .assert + .isNull() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectPublishingTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectPublishingTestData.kt new file mode 100644 index 00000000000..4736fd2fdbf --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectPublishingTestData.kt @@ -0,0 +1,32 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ProjectPermissionType + +class ProjectPublishingTestData : BaseTestData() { + val owner: UserAccount get() = user + lateinit var manager: UserAccount + lateinit var serverAdmin: UserAccount + + init { + root.apply { + addUserAccount { + username = "project_manager" + name = "Project Manager" + manager = this + } + addUserAccount { + username = "server_admin" + name = "Server Admin" + role = UserAccount.Role.ADMIN + serverAdmin = this + } + } + projectBuilder.build { + addPermission { + user = this@ProjectPublishingTestData.manager + type = ProjectPermissionType.MANAGE + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt index d646ecb3f98..736b8b9191e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt @@ -18,6 +18,7 @@ data class ProjectDto( var useNamespaces: Boolean, var useBranching: Boolean, var useQaChecks: Boolean, + var public: Boolean, var suggestionsMode: SuggestionsMode, var translationProtection: TranslationProtection, ) : Serializable, @@ -36,6 +37,7 @@ data class ProjectDto( useNamespaces = entity.useNamespaces, useBranching = entity.useBranching, useQaChecks = entity.useQaChecks, + public = entity.public, suggestionsMode = entity.suggestionsMode, translationProtection = entity.translationProtection, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/SetProjectPublicRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/SetProjectPublicRequest.kt new file mode 100644 index 00000000000..32f3945cbdc --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/SetProjectPublicRequest.kt @@ -0,0 +1,8 @@ +package io.tolgee.dtos.request.project + +import io.swagger.v3.oas.annotations.media.Schema + +data class SetProjectPublicRequest( + @Schema(description = "Whether the project should be public (discoverable and open to community suggestions)") + var public: Boolean = false, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt index fe1e9e0f90e..6b691a0ffd9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt @@ -169,6 +169,11 @@ class Project( @ActivityLoggedProp var useQaChecks: Boolean = false + @ColumnDefault("false") + @Column(name = "is_public") + @ActivityLoggedProp + var public: Boolean = false + @ColumnDefault("DISABLED") @ActivityLoggedProp @Enumerated(EnumType.STRING) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt index 99dd1fe8a23..c87ddf1c00b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt @@ -16,6 +16,7 @@ interface ProjectView { val useNamespaces: Boolean val useBranching: Boolean val useQaChecks: Boolean + val public: Boolean val defaultNamespace: Namespace? val organizationOwner: Organization val organizationRole: OrganizationRoleType? diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt index 7e0da939b23..73665cd97ec 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt @@ -16,6 +16,7 @@ open class ProjectWithLanguagesView( override val useNamespaces: Boolean, override val useBranching: Boolean, override val useQaChecks: Boolean, + override val public: Boolean, override val defaultNamespace: Namespace?, override val organizationOwner: Organization, override val organizationRole: OrganizationRoleType?, @@ -39,6 +40,7 @@ open class ProjectWithLanguagesView( useNamespaces = view.useNamespaces, useBranching = view.useBranching, useQaChecks = view.useQaChecks, + public = view.public, defaultNamespace = view.defaultNamespace, organizationOwner = view.organizationOwner, organizationRole = view.organizationRole, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt index c2bb144753e..a0d8553d5e7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt @@ -16,6 +16,7 @@ class ProjectWithStatsView( view.useNamespaces, view.useBranching, view.useQaChecks, + view.public, view.defaultNamespace, view.organizationOwner, view.organizationRole, diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt index eea8aa5d92a..6d0406a7829 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt @@ -21,6 +21,7 @@ interface ProjectRepository : JpaRepository { r.useNamespaces as useNamespaces, r.useBranching as useBranching, r.useQaChecks as useQaChecks, + r.public as public, r.suggestionsMode as suggestionsMode, r.translationProtection as translationProtection, dn as defaultNamespace, o as organizationOwner, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index 6a1f377862f..a9bc0314339 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -170,10 +170,7 @@ class ProjectService( id: Long, dto: EditProjectRequest, ): Project { - val project = - projectRepository - .findById(id) - .orElseThrow { NotFoundException() }!! + val project = get(id) val wasBranchingEnabled = project.useBranching val oldBaseLanguageId = project.baseLanguage?.id val oldName = project.name @@ -243,6 +240,17 @@ class ProjectService( return project } + @Transactional + @CacheEvict(cacheNames = [Caches.PROJECTS], key = "#result.id") + fun setPublic( + id: Long, + public: Boolean, + ): Project { + val project = get(id) + project.public = public + return projectRepository.save(project) + } + /** * Shared TMs declare a source language; changing the project base to a different language * would leave them misaligned. Without [unassignConflictingTms] this rejects the change and diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 4afede661f2..4a9d13ab97b 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -5775,4 +5775,19 @@ ]]> + + + + + + + + + + CREATE INDEX IF NOT EXISTS project_is_public ON project (id) WHERE is_public = true AND deleted_at IS NULL; + + + DROP INDEX IF EXISTS project_is_public; + + diff --git a/e2e/cypress/common/shared.ts b/e2e/cypress/common/shared.ts index 13041316173..3ac2eb18c91 100644 --- a/e2e/cypress/common/shared.ts +++ b/e2e/cypress/common/shared.ts @@ -193,6 +193,10 @@ export const visitProjectSettings = (projectId: number) => { return cy.visit(`${HOST}/projects/${projectId}/manage/edit`); }; +export const visitProjectSettingsAdvanced = (projectId: number) => { + return cy.visit(`${HOST}/projects/${projectId}/manage/edit/advanced`); +}; + export const visitProjectLanguages = (projectId: number) => { return cy.visit(`${HOST}/projects/${projectId}/languages`); }; diff --git a/e2e/cypress/e2e/projects/settings.public.cy.ts b/e2e/cypress/e2e/projects/settings.public.cy.ts new file mode 100644 index 00000000000..5e88fe88a30 --- /dev/null +++ b/e2e/cypress/e2e/projects/settings.public.cy.ts @@ -0,0 +1,69 @@ +import { + createProject, + deleteProject, + login, +} from '../../common/apiCalls/common'; +import { visitProjectSettingsAdvanced } from '../../common/shared'; + +const PROJECT_NAME = 'Public Toggle E2E'; + +describe('Project public toggle', () => { + let projectId: number; + + beforeEach(() => { + login().then(() => + createProject({ + name: PROJECT_NAME, + languages: [ + { + tag: 'en', + name: 'English', + originalName: 'English', + flagEmoji: '🇬🇧', + }, + ], + }).then((r) => { + projectId = r.body.id; + }) + ); + }); + + afterEach(() => { + deleteProject(projectId); + }); + + const visitAdvanced = () => visitProjectSettingsAdvanced(projectId); + + const publicSwitchInput = () => + cy.gcy('project-settings-public-switch').find('input'); + + it('starts private', () => { + visitAdvanced(); + publicSwitchInput().should('not.be.checked'); + }); + + it('cancelling the confirmation leaves the project private', () => { + visitAdvanced(); + cy.gcy('project-settings-public-switch').click(); + cy.gcy('global-confirmation-cancel').click(); + publicSwitchInput().should('not.be.checked'); + cy.reload(); + publicSwitchInput().should('not.be.checked'); + }); + + it('publishes then unpublishes', () => { + visitAdvanced(); + + cy.gcy('project-settings-public-switch').click(); + cy.gcy('global-confirmation-confirm').click(); + publicSwitchInput().should('be.checked'); + cy.reload(); + publicSwitchInput().should('be.checked'); + + cy.gcy('project-settings-public-switch').click(); + cy.gcy('global-confirmation-confirm').click(); + publicSwitchInput().should('not.be.checked'); + cy.reload(); + publicSwitchInput().should('not.be.checked'); + }); +}); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 8e50b1eb8ae..fb234e05757 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -625,6 +625,7 @@ declare namespace DataCy { "project-settings-menu-labels": true; "project-settings-menu-qa": true; "project-settings-name": true; + "project-settings-public-switch": true; "project-settings-suggestions-mode-switch": true; "project-settings-tm-configure": true; "project-settings-tm-row": true; diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index b0d993f0163..0e70e080b45 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -877,6 +877,10 @@ export interface paths { put: operations["updatePrompt"]; delete: operations["deletePrompt"]; }; + "/v2/projects/{projectId}/publishing": { + /** Marks the project as public or private. Only the organization owner can change this. */ + put: operations["setProjectPublic"]; + }; "/v2/projects/{projectId}/qa-settings": { get: operations["getSettings"]; put: operations["updateSettings"]; @@ -5390,6 +5394,8 @@ export interface components { organizationOwner?: components["schemas"]["SimpleOrganizationModel"]; /** @enum {string} */ organizationRole?: "MEMBER" | "OWNER" | "MAINTAINER"; + /** @description Whether the project is public — discoverable and open to community suggestions */ + public: boolean; slug?: string; /** * @description Suggestions for translations @@ -6226,6 +6232,10 @@ export interface components { */ description?: string; }; + SetProjectPublicRequest: { + /** @description Whether the project should be public (discoverable and open to community suggestions) */ + public: boolean; + }; SetTranslationsResponseModel: { /** * Format: int64 @@ -19893,6 +19903,51 @@ export interface operations { }; }; }; + /** Marks the project as public or private. Only the organization owner can change this. */ + setProjectPublic: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["ProjectModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetProjectPublicRequest"]; + }; + }; + }; getSettings: { parameters: { path: { diff --git a/webapp/src/service/apiSchemaTypes.generated.ts b/webapp/src/service/apiSchemaTypes.generated.ts index e659c400ee0..b733756b495 100644 --- a/webapp/src/service/apiSchemaTypes.generated.ts +++ b/webapp/src/service/apiSchemaTypes.generated.ts @@ -324,6 +324,7 @@ export type SetLicenseKeyDto = components['schemas']['SetLicenseKeyDto']; export type SetMachineTranslationSettingsDto = components['schemas']['SetMachineTranslationSettingsDto']; export type SetOrganizationRoleDto = components['schemas']['SetOrganizationRoleDto']; export type SetProjectPromptCustomizationRequest = components['schemas']['SetProjectPromptCustomizationRequest']; +export type SetProjectPublicRequest = components['schemas']['SetProjectPublicRequest']; export type SetTranslationsResponseModel = components['schemas']['SetTranslationsResponseModel']; export type SetTranslationsStateStateRequest = components['schemas']['SetTranslationsStateStateRequest']; export type SetTranslationsWithKeyDto = components['schemas']['SetTranslationsWithKeyDto']; diff --git a/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx b/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx index 57602e5bd60..50312700fc6 100644 --- a/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx +++ b/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx @@ -18,7 +18,7 @@ import { useProjectNamespaces } from 'tg.hooks/useProjectNamespaces'; import { DefaultNamespaceSelect } from './components/DefaultNamespaceSelect'; import { LinkReadMore } from 'tg.component/LinkReadMore'; import { useReportEvent } from 'tg.hooks/useReportEvent'; -import { useEnabledFeatures } from 'tg.globalContext/helpers'; +import { useEnabledFeatures, useIsAdmin } from 'tg.globalContext/helpers'; import { ProjectSettingsTranslationMemory } from 'tg.ee'; type EditProjectRequest = components['schemas']['EditProjectRequest']; @@ -29,6 +29,7 @@ export const ProjectSettingsAdvanced = () => { const history = useHistory(); const reportEvent = useReportEvent(); const { isEnabled } = useEnabledFeatures(); + const isAdmin = useIsAdmin(); const { allNamespacesWithNone } = useProjectNamespaces(); const isBranchingEnabled = isEnabled('BRANCHING'); @@ -44,6 +45,44 @@ export const ProjectSettingsAdvanced = () => { invalidatePrefix: '/v2/projects', }); + const publishingLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/publishing', + method: 'put', + invalidatePrefix: '/v2/projects', + }); + + const canChangePublic = + project.organizationRole === 'OWNER' || + project.computedPermission.origin === 'SERVER_ADMIN' || + isAdmin; + + const handlePublicSwitch = () => { + const value = !project.public; + confirmation({ + title: value ? ( + + ) : ( + + ), + message: value ? ( + + ) : ( + + ), + onConfirm: () => + publishingLoadable.mutate({ + path: { projectId: project.id }, + content: { 'application/json': { public: value } }, + }), + }); + }; + const updateSettings = ( values: Partial, onSuccess?: () => void @@ -93,6 +132,19 @@ export const ProjectSettingsAdvanced = () => { return ( + {t('project_settings_public_section_title')} + + + + + {t('project_settings_advanced_translations')} From e72243180b5e75ae280a55a36adc6a3e50a426bb Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Tue, 30 Jun 2026 17:14:32 +0200 Subject: [PATCH 2/8] feat: community permissions for public projects (#3770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticated users on a public project get a computed community permission (read subset of VIEW + translations-suggest + translation-comments-add, all languages; origin COMMUNITY) without a stored Permission row — they contribute translations without taking a paid seat. Direct/org permissions and admin/supporter elevation layer on top, so no authenticated user is below a non-member; anonymous users get nothing. - Member emails and the project member count are redacted behind MEMBERS_VIEW across project-scoped REST surfaces; the realtime websocket never carries the author email. - Out-of-surface endpoints reachable by the community scope set are gated: MT credit balance -> ORGANIZATION_QUOTAS_VIEW, import-settings store -> KEYS_CREATE, branch-merge sessions -> BRANCH_MANAGEMENT, blocking-tasks -> task-view-or-assigned. BREAKING CHANGE: GET project machine-translation-credit-balance now requires ORGANIZATION_QUOTAS_VIEW; granular permissions and API keys without it get 403 and must be re-granted. Branch-merge session endpoints now require BRANCH_MANAGEMENT. --- .../v2/controllers/ProjectStatsController.kt | 9 +- .../dataImport/ImportSettingsController.kt | 4 +- .../machineTranslation/MtCreditsController.kt | 5 +- .../project/ProjectsPublishingController.kt | 9 +- .../activity/ProjectActivityModelAssembler.kt | 4 +- .../key/trash/TrashedKeyModelAssembler.kt | 4 +- ...rashedKeyWithTranslationsModelAssembler.kt | 4 +- .../hateoas/project/ProjectModelAssembler.kt | 1 + .../project/ProjectWithStatsModelAssembler.kt | 1 + .../TranslationHistoryModelAssembler.kt | 4 +- ...anslationSuggestionSimpleModelAssembler.kt | 4 +- .../SimpleUserAccountModelAssembler.kt | 6 +- .../UserAccountInProjectModelAssembler.kt | 2 + .../websocket/ActivityWebsocketListener.kt | 9 +- .../CommunityPermissionComputationTest.kt | 126 ++++++++ .../v2/controllers/CommunityPermissionTest.kt | 300 ++++++++++++++++++ .../controllers/ProjectStatsControllerTest.kt | 14 +- .../NotificationControllerTest.kt | 1 + .../tolgee/websocket/AbstractWebsocketTest.kt | 4 +- .../constants/ComputedPermissionOrigin.kt | 1 + .../data/ProjectPublishingTestData.kt | 52 +++ .../data/SuggestionsTestData.kt | 15 + .../io/tolgee/dtos/ComputedPermissionDto.kt | 36 +++ .../model/enums/ProjectPermissionType.kt | 4 + .../kotlin/io/tolgee/model/enums/Scope.kt | 2 + .../tolgee/service/project/ProjectService.kt | 1 + .../service/security/PermissionService.kt | 6 + .../service/security/SecurityService.kt | 37 ++- .../unit/CommunityPermissionScopesTest.kt | 75 +++++ .../io/tolgee/unit/MemberInfoMaskingTest.kt | 23 ++ .../ee/api/v2/controllers/TaskController.kt | 16 +- .../controllers/branching/BranchController.kt | 22 +- .../v2/controllers/CommunitySuggestionTest.kt | 202 ++++++++++++ .../controllers/SuggestionControllerTest.kt | 8 +- .../task/TaskControllerPermissionsTest.kt | 1 + .../v2/controllers/task/TaskControllerTest.kt | 19 ++ .../usePermissionsStructure.ts | 3 + .../useScopeTranslations.tsx | 1 + webapp/src/service/apiSchema.generated.ts | 15 +- .../projects/dashboard/ProjectTotals.tsx | 12 +- .../Suggestions/TranslationSuggestion.tsx | 5 +- 41 files changed, 1002 insertions(+), 65 deletions(-) create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionComputationTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/CommunityPermissionScopesTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/MemberInfoMaskingTest.kt create mode 100644 ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/CommunitySuggestionTest.kt diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt index 7d8cf1ed41b..ef607ad2d71 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt @@ -24,6 +24,7 @@ import io.tolgee.service.project.ProjectFeatureGuard import io.tolgee.service.project.ProjectService import io.tolgee.service.project.ProjectStatsService import io.tolgee.service.qa.TranslationQaIssueService +import io.tolgee.service.security.SecurityService import org.springframework.http.MediaType import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping @@ -47,6 +48,7 @@ class ProjectStatsController( private val branchService: BranchService, private val projectFeatureGuard: ProjectFeatureGuard, private val translationQaIssueService: TranslationQaIssueService, + private val securityService: SecurityService, ) { @Operation(summary = "Get project stats") @GetMapping("", produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -82,7 +84,7 @@ class ProjectStatsController( baseWordsCount = totals.baseWordsCount, translatedPercentage = totals.translatedPercent, reviewedPercentage = totals.reviewedPercent, - membersCount = projectStats.memberCount, + membersCount = visibleMembersCount(projectStats.memberCount), tagCount = projectStats.tagCount, languageStats = statsLanguagePairs.map { @@ -114,4 +116,9 @@ class ProjectStatsController( fun getProjectDailyActivity(): Map { return projectStatsService.getProjectDailyActivity(projectHolder.project.id) } + + private fun visibleMembersCount(memberCount: Long): Long { + if (!securityService.shouldExposeMemberInfo()) return 0 + return memberCount + } } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/ImportSettingsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/ImportSettingsController.kt index f3a1c572af1..b38250f0cea 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/ImportSettingsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/ImportSettingsController.kt @@ -8,9 +8,11 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.dtos.request.dataImport.ImportSettingsRequest import io.tolgee.hateoas.dataImport.ImportSettingsModel +import io.tolgee.model.enums.Scope import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.security.authorization.UseDefaultPermissions import io.tolgee.service.dataImport.ImportSettingsService import jakarta.validation.Valid @@ -57,7 +59,7 @@ class ImportSettingsController( description = "Stores import settings for the authenticated user and the project.", ) @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.TRANSLATIONS_EDIT]) fun store( @Valid @RequestBody dto: ImportSettingsRequest, ): ImportSettingsModel { diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/machineTranslation/MtCreditsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/machineTranslation/MtCreditsController.kt index 7cf0aaaf84b..d9509c32442 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/machineTranslation/MtCreditsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/machineTranslation/MtCreditsController.kt @@ -4,10 +4,11 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.dtos.MtCreditBalanceDto import io.tolgee.hateoas.machineTranslation.CreditBalanceModel +import io.tolgee.model.enums.Scope import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authorization.RequiresOrganizationRole -import io.tolgee.security.authorization.UseDefaultPermissions +import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.service.machineTranslation.mtCreditsConsumption.MtCreditsService import io.tolgee.service.organization.OrganizationService import org.springframework.web.bind.annotation.CrossOrigin @@ -30,7 +31,7 @@ class MtCreditsController( summary = "Get credit balance for project", description = "Returns machine translation credit balance for specified project", ) - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.ORGANIZATION_QUOTAS_VIEW]) @AllowApiAccess fun getProjectCredits( @PathVariable projectId: Long, diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsPublishingController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsPublishingController.kt index e4e6b0f3385..5d11a6b8d08 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsPublishingController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsPublishingController.kt @@ -28,7 +28,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @CrossOrigin(origins = ["*"]) @RequestMapping(value = ["/v2/projects"]) -@Tag(name = "Project Publishing", description = "Marks a project as public or private (organization owner only)") +@Tag( + name = "Project Publishing", + description = "Marks a project as public or private (organization owner or server admin)", +) class ProjectsPublishingController( private val projectService: ProjectService, private val projectHolder: ProjectHolder, @@ -38,7 +41,9 @@ class ProjectsPublishingController( @PutMapping(value = ["/{projectId:[0-9]+}/publishing"]) @Operation( summary = "Set project publishing state", - description = "Marks the project as public or private. Only the organization owner can change this.", + description = + "Marks the project as public or private. " + + "Only the organization owner or a server admin can change this.", ) @RequestActivity(ActivityType.EDIT_PROJECT) @RequiresProjectPermissions([Scope.PROJECT_EDIT]) diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ProjectActivityModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ProjectActivityModelAssembler.kt index f0dbfe42cef..9c15fec9d17 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ProjectActivityModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/activity/ProjectActivityModelAssembler.kt @@ -4,6 +4,7 @@ import io.tolgee.api.IProjectActivityModelAssembler import io.tolgee.api.v2.controllers.ApiKeyController import io.tolgee.model.views.activity.ProjectActivityView import io.tolgee.service.AvatarService +import io.tolgee.service.security.SecurityService import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component @@ -11,6 +12,7 @@ import org.springframework.stereotype.Component class ProjectActivityModelAssembler( private val avatarService: AvatarService, private val modifiedEntityModelAssembler: ModifiedEntityModelAssembler, + private val securityService: SecurityService, ) : RepresentationModelAssemblerSupport( ApiKeyController::class.java, ProjectActivityModel::class.java, @@ -26,7 +28,7 @@ class ProjectActivityModelAssembler( ProjectActivityAuthorModel( id = view.authorId!!, name = view.authorName, - username = view.authorUsername!!, + username = securityService.maskedMemberField(view.authorUsername), avatar = avatarService.getAvatarLinks(view.authorAvatarHash), deleted = view.authorDeleted, ) diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyModelAssembler.kt index 529235fa528..8a14b32f032 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyModelAssembler.kt @@ -5,6 +5,7 @@ import io.tolgee.hateoas.userAccount.SimpleUserAccountModel import io.tolgee.service.AvatarService import io.tolgee.service.key.KeySearchResultView import io.tolgee.service.key.KeyTrashPurgeScheduler +import io.tolgee.service.security.SecurityService import io.tolgee.util.addDays import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component @@ -12,6 +13,7 @@ import org.springframework.stereotype.Component @Component class TrashedKeyModelAssembler( private val avatarService: AvatarService, + private val securityService: SecurityService, ) : RepresentationModelAssemblerSupport( KeyTrashController::class.java, TrashedKeyModel::class.java, @@ -34,7 +36,7 @@ class TrashedKeyModelAssembler( return entity.deletedByUserId?.let { userId -> SimpleUserAccountModel( id = userId, - username = entity.deletedByUserUsername ?: "", + username = securityService.maskedMemberField(entity.deletedByUserUsername), name = entity.deletedByUserName, avatar = avatarService.getAvatarLinks(entity.deletedByUserAvatarHash), deleted = entity.deletedByUserDeleted ?: false, diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyWithTranslationsModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyWithTranslationsModelAssembler.kt index 98205e231b0..a1e1f9b541c 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyWithTranslationsModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/trash/TrashedKeyWithTranslationsModelAssembler.kt @@ -8,6 +8,7 @@ import io.tolgee.hateoas.userAccount.SimpleUserAccountModel import io.tolgee.model.views.KeyWithTranslationsView import io.tolgee.service.AvatarService import io.tolgee.service.key.KeyTrashPurgeScheduler +import io.tolgee.service.security.SecurityService import io.tolgee.util.addDays import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component @@ -16,6 +17,7 @@ import org.springframework.stereotype.Component class TrashedKeyWithTranslationsModelAssembler( private val avatarService: AvatarService, private val screenshotModelAssembler: ScreenshotModelAssembler, + private val securityService: SecurityService, ) : RepresentationModelAssemblerSupport( KeyTrashController::class.java, TrashedKeyWithTranslationsModel::class.java, @@ -62,7 +64,7 @@ class TrashedKeyWithTranslationsModelAssembler( return view.deletedByUserId?.let { userId -> SimpleUserAccountModel( id = userId, - username = view.deletedByUserUsername ?: "", + username = securityService.maskedMemberField(view.deletedByUserUsername), name = view.deletedByUserName, avatar = avatarService.getAvatarLinks(view.deletedByUserAvatarHash), deleted = view.deletedByUserDeletedAt != null, diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt index 8c070ccb8cd..c3cd49215a2 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt @@ -73,6 +73,7 @@ class ProjectModelAssembler( view.organizationOwner.basePermission, view.directPermission, authenticationFacade.authenticatedUserOrNull?.role, + isProjectPublic = view.public, ) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt index 24838e5ac8c..0adfe680088 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt @@ -41,6 +41,7 @@ class ProjectWithStatsModelAssembler( view.organizationOwner.basePermission, view.directPermission, authenticationFacade.authenticatedUserOrNull?.role, + isProjectPublic = view.public, ) return ProjectWithStatsModel( diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationHistoryModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationHistoryModelAssembler.kt index 0ad5b01ec81..37cecf76904 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationHistoryModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/TranslationHistoryModelAssembler.kt @@ -4,12 +4,14 @@ import io.tolgee.api.v2.controllers.translation.TranslationsController import io.tolgee.dtos.queryResults.TranslationHistoryView import io.tolgee.hateoas.userAccount.SimpleUserAccountModel import io.tolgee.service.AvatarService +import io.tolgee.service.security.SecurityService import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component @Component class TranslationHistoryModelAssembler( private val avatarService: AvatarService, + private val securityService: SecurityService, ) : RepresentationModelAssemblerSupport( TranslationsController::class.java, TranslationHistoryModel::class.java, @@ -24,7 +26,7 @@ class TranslationHistoryModelAssembler( SimpleUserAccountModel( id = it, name = view.authorName, - username = view.authorEmail ?: "", + username = securityService.maskedMemberField(view.authorEmail), avatar = avatar, deleted = view.authorDeletedAt != null, ) diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/suggestions/TranslationSuggestionSimpleModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/suggestions/TranslationSuggestionSimpleModelAssembler.kt index d2694c2e1b1..238953c56ff 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/suggestions/TranslationSuggestionSimpleModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/suggestions/TranslationSuggestionSimpleModelAssembler.kt @@ -4,12 +4,14 @@ import io.tolgee.api.v2.controllers.translation.TranslationsController import io.tolgee.hateoas.userAccount.SimpleUserAccountModel import io.tolgee.model.views.TranslationSuggestionView import io.tolgee.service.AvatarService +import io.tolgee.service.security.SecurityService import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component @Component class TranslationSuggestionSimpleModelAssembler( private val avatarService: AvatarService, + private val securityService: SecurityService, ) : RepresentationModelAssemblerSupport( TranslationsController::class.java, TranslationSuggestionSimpleModel::class.java, @@ -22,7 +24,7 @@ class TranslationSuggestionSimpleModelAssembler( SimpleUserAccountModel( id = entity.authorId, name = entity.authorName, - username = entity.authorUsername, + username = securityService.maskedMemberField(entity.authorUsername), avatar = avatarService.getAvatarLinks(entity.authorAvatarHash), deleted = entity.authorDeletedAt != null, ), diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.kt index f7c10b3a566..7c689e5044b 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/SimpleUserAccountModelAssembler.kt @@ -4,12 +4,14 @@ import io.tolgee.api.v2.controllers.V2UserController import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.model.UserAccount import io.tolgee.service.AvatarService +import io.tolgee.service.security.SecurityService import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component @Component class SimpleUserAccountModelAssembler( private val avatarService: AvatarService, + private val securityService: SecurityService, ) : RepresentationModelAssemblerSupport( V2UserController::class.java, SimpleUserAccountModel::class.java, @@ -19,7 +21,7 @@ class SimpleUserAccountModelAssembler( return SimpleUserAccountModel( id = entity.id, - username = entity.username, + username = securityService.maskedMemberField(entity.username), name = entity.name, avatar = avatar, deleted = entity.deletedAt != null, @@ -31,7 +33,7 @@ class SimpleUserAccountModelAssembler( return SimpleUserAccountModel( id = dto.id, - username = dto.username, + username = securityService.maskedMemberField(dto.username), name = dto.name, avatar = avatar, deleted = dto.deleted, diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/UserAccountInProjectModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/UserAccountInProjectModelAssembler.kt index 0fd1b3a5ee0..5a3026c7373 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/UserAccountInProjectModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/userAccount/UserAccountInProjectModelAssembler.kt @@ -23,6 +23,8 @@ class UserAccountInProjectModelAssembler( UserAccountInProjectModel::class.java, ) { override fun toModel(view: ExtendedUserAccountInProject): UserAccountInProjectModel { + // Deliberately ignores whether project is public: the members list shows each member's + // configured, editable permissions, not the project-wide community permissions. val computedPermissions = permissionService.computeProjectPermission( view.organizationRole, diff --git a/backend/api/src/main/kotlin/io/tolgee/websocket/ActivityWebsocketListener.kt b/backend/api/src/main/kotlin/io/tolgee/websocket/ActivityWebsocketListener.kt index 377fcfffe6f..4c37d39eadc 100644 --- a/backend/api/src/main/kotlin/io/tolgee/websocket/ActivityWebsocketListener.kt +++ b/backend/api/src/main/kotlin/io/tolgee/websocket/ActivityWebsocketListener.kt @@ -53,10 +53,11 @@ class ActivityWebsocketListener( fun getActorInfo(userId: Long?): ActorInfo { return userId?.let { val user = userAccountService.findDto(userId) ?: return@let null - ActorInfo( - type = ActorType.USER, - data = simpleUserAccountModelAssembler.toModel(user), - ) + // These events fan out to every project subscriber and a recipient's MEMBERS_VIEW can't be + // checked per-recipient at fan-out, so the realtime channel never carries the author email (the + // `username` field) — REST applies the per-caller members.view gate. + val data = simpleUserAccountModelAssembler.toModel(user).copy(username = "") + ActorInfo(type = ActorType.USER, data = data) } ?: ActorInfo(type = ActorType.UNKNOWN, data = null) } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionComputationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionComputationTest.kt new file mode 100644 index 00000000000..72d9842e5ce --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionComputationTest.kt @@ -0,0 +1,126 @@ +package io.tolgee.api.v2.controllers + +import io.tolgee.AbstractSpringTest +import io.tolgee.constants.ComputedPermissionOrigin +import io.tolgee.dtos.ComputedPermissionDto +import io.tolgee.dtos.cacheable.IPermission +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.Scope +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class CommunityPermissionComputationTest : AbstractSpringTest() { + @Test + fun `org member with a NONE base is floored to community on a public project`() { + val computed = + permissionService.computeProjectPermission( + organizationRole = OrganizationRoleType.MEMBER, + organizationBasePermission = ComputedPermissionDto.NONE, + directPermission = null, + userRole = UserAccount.Role.USER, + isProjectPublic = true, + ) + assertThat(computed.origin).isEqualTo(ComputedPermissionOrigin.COMMUNITY) + assertThat(computed.expandedScopes) + .contains(Scope.TRANSLATIONS_SUGGEST, Scope.TRANSLATIONS_COMMENTS_ADD) + } + + @Test + fun `org member with a NONE base is NOT floored on a private project`() { + val computed = + permissionService.computeProjectPermission( + organizationRole = OrganizationRoleType.MEMBER, + organizationBasePermission = ComputedPermissionDto.NONE, + directPermission = null, + userRole = UserAccount.Role.USER, + isProjectPublic = false, + ) + assertThat(computed.expandedScopes) + .doesNotContain(Scope.TRANSLATIONS_SUGGEST, Scope.TRANSLATIONS_COMMENTS_ADD) + } + + @Test + fun `a supporter non-member is never below a regular user on a public project`() { + val computed = + permissionService.computeProjectPermission( + organizationRole = null, + organizationBasePermission = ComputedPermissionDto.NONE, + directPermission = null, + userRole = UserAccount.Role.SUPPORTER, + isProjectPublic = true, + ) + assertThat(computed.expandedScopes).contains( + Scope.TRANSLATIONS_SUGGEST, + Scope.TRANSLATIONS_COMMENTS_ADD, + Scope.MEMBERS_VIEW, + Scope.TASKS_VIEW, + ) + } + + @Test + fun `a server admin still gets full admin on a public project`() { + val computed = + permissionService.computeProjectPermission( + organizationRole = null, + organizationBasePermission = ComputedPermissionDto.NONE, + directPermission = null, + userRole = UserAccount.Role.ADMIN, + isProjectPublic = true, + ) + assertThat(computed.origin).isEqualTo(ComputedPermissionOrigin.SERVER_ADMIN) + assertThat(computed.expandedScopes).contains(Scope.ADMIN) + } + + @Test + fun `community floor lifts view and suggest language restrictions but keeps translate restricted`() { + val restrictedToOneLanguage = + object : IPermission { + override val scopes = + arrayOf( + Scope.TRANSLATIONS_VIEW, + Scope.SCREENSHOTS_VIEW, + Scope.ACTIVITY_VIEW, + Scope.TRANSLATIONS_SUGGEST, + Scope.TRANSLATIONS_COMMENTS_ADD, + Scope.TRANSLATIONS_EDIT, + ) + override val projectId: Long? = null + override val organizationId: Long? = null + override val viewLanguageIds = setOf(1L) + override val translateLanguageIds = setOf(1L) + override val stateChangeLanguageIds = setOf(1L) + override val suggestLanguageIds = setOf(1L) + override val type = ProjectPermissionType.EDIT + override val granular = true + } + val computed = + permissionService.computeProjectPermission( + organizationRole = null, + organizationBasePermission = ComputedPermissionDto.NONE, + directPermission = restrictedToOneLanguage, + userRole = UserAccount.Role.USER, + isProjectPublic = true, + ) + assertThat(computed.viewLanguageIds).isNull() + assertThat(computed.suggestLanguageIds).isNull() + assertThat(computed.translateLanguageIds).containsExactly(1L) + } + + @Test + fun `public project grants nothing without an authenticated user`() { + val computed = + permissionService.computeProjectPermission( + organizationRole = null, + organizationBasePermission = ComputedPermissionDto.NONE, + directPermission = null, + userRole = null, + isProjectPublic = true, + ) + assertThat(computed.expandedScopes).isEmpty() + assertThat(computed.origin).isNotEqualTo(ComputedPermissionOrigin.COMMUNITY) + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionTest.kt new file mode 100644 index 00000000000..06ddfc11070 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionTest.kt @@ -0,0 +1,300 @@ +package io.tolgee.api.v2.controllers + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.ProjectPublishingTestData +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +@AutoConfigureMockMvc +class CommunityPermissionTest : ProjectAuthControllerTest("/v2/projects/") { + private lateinit var testData: ProjectPublishingTestData + + @BeforeEach + fun setup() { + testData = ProjectPublishingTestData() + testDataService.saveTestData(testData.root) + projectSupplier = { testData.project } + } + + @AfterEach + fun cleanup() { + testDataService.cleanTestData(testData.root) + } + + @Test + @ProjectJWTAuthTestMethod + fun `authenticated non-member gets the community permission on a public project`() { + setPublic(true) + userAccount = testData.communityUser + performAuthGet(projectUrl()).andIsOk.andAssertThatJson { + node("computedPermission.origin").isEqualTo("COMMUNITY") + node("computedPermission.scopes").isArray.contains( + "translations.view", + "screenshots.view", + "activity.view", + "translations.suggest", + "translation-comments.add", + ) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `community permission never grants write or restricted scopes`() { + setPublic(true) + userAccount = testData.communityUser + performAuthGet(projectUrl()).andIsOk.andAssertThatJson { + node("computedPermission.scopes").isArray.isNotEmpty + node("computedPermission.scopes") + .isArray + .doesNotContain( + "translations.edit", + "keys.create", + "keys.edit", + "keys.delete", + "members.view", + "organization-quotas.view", + ) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `non-member cannot access a private project`() { + userAccount = testData.communityUser + performAuthGet(projectUrl()).andIsNotFound + } + + @Test + @ProjectJWTAuthTestMethod + fun `un-publishing revokes community access`() { + setPublic(true) + userAccount = testData.communityUser + performAuthGet(projectUrl()).andIsOk + + setPublic(false) + performAuthGet(projectUrl()).andIsNotFound + } + + @Test + @ProjectJWTAuthTestMethod + fun `member count is hidden from community users`() { + setPublic(true) + userAccount = testData.communityUser + performProjectAuthGet("stats").andIsOk.andAssertThatJson { + node("membersCount").isEqualTo(0) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `member count is hidden from members lacking members-view`() { + userAccount = testData.viewer + performProjectAuthGet("stats").andIsOk.andAssertThatJson { + node("membersCount").isEqualTo(0) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `member count and credits stay visible to members with members-view`() { + setPublic(true) + userAccount = testData.manager + performProjectAuthGet("stats").andIsOk.andAssertThatJson { + node("membersCount").isEqualTo(6) + } + performProjectAuthGet("machine-translation-credit-balance").andIsOk + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user cannot read organization MT credit balance`() { + setPublic(true) + userAccount = testData.communityUser + performProjectAuthGet("machine-translation-credit-balance").andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user cannot store import settings`() { + setPublic(true) + userAccount = testData.communityUser + performProjectAuthPut("import-settings", mapOf()).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `a member with translations-edit can store import settings`() { + userAccount = testData.translationsEditor + storeImportSettings().andIsOk + } + + @Test + @ProjectJWTAuthTestMethod + fun `a member with only keys-create cannot store import settings`() { + userAccount = testData.keysCreator + storeImportSettings().andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `a member with only translations-view cannot store import settings`() { + userAccount = testData.granularUser + storeImportSettings().andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `anonymous user cannot open a public project`() { + setPublic(true) + performGet(projectUrl()).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `anonymous user cannot list translations of a public project`() { + setPublic(true) + performGet("${projectUrl()}/translations").andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user cannot change translations`() { + setPublic(true) + userAccount = testData.communityUser + performProjectAuthPut( + "translations", + mapOf( + "key" to "trash-me", + "translations" to mapOf("en" to "hacked"), + ), + ).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user cannot create keys`() { + setPublic(true) + userAccount = testData.communityUser + performProjectAuthPost("keys", mapOf("name" to "community-key")).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user cannot list members`() { + setPublic(true) + userAccount = testData.communityUser + performProjectAuthGet("users").andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `granular member without the credits scope cannot read MT credit balance`() { + userAccount = testData.granularUser + performProjectAuthGet("machine-translation-credit-balance").andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `a narrow direct permission is floored with the community scopes on a public project`() { + setPublic(true) + userAccount = testData.granularUser + performAuthGet(projectUrl()).andIsOk.andAssertThatJson { + node("computedPermission.origin").isEqualTo("DIRECT") + node("computedPermission.scopes").isArray.contains( + "translations.view", + "screenshots.view", + "activity.view", + "translations.suggest", + "translation-comments.add", + ) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `a VIEW member is floored with community suggest and comment on a public project`() { + setPublic(true) + userAccount = testData.viewer + performAuthGet(projectUrl()).andIsOk.andAssertThatJson { + node("computedPermission.origin").isEqualTo("DIRECT") + node("computedPermission.scopes").isArray.contains( + "translations.suggest", + "translation-comments.add", + ) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user may mint an API key within community scopes (intentional for v1)`() { + setPublic(true) + userAccount = testData.communityUser + performAuthPost( + "/v2/api-keys", + mapOf( + "projectId" to testData.project.id, + "scopes" to setOf("translations.suggest"), + ), + ).andIsOk + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user cannot mint an API key with scopes outside the community set`() { + setPublic(true) + userAccount = testData.communityUser + performAuthPost( + "/v2/api-keys", + mapOf( + "projectId" to testData.project.id, + "scopes" to setOf("translations.edit"), + ), + ).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `key-deleter email is hidden from community in the trash listing`() { + setPublic(true) + val key = keyService.get(testData.project.id, "trash-me", null) + executeInNewTransaction { + keyService.softDeleteMultiple(listOf(key.id), deletedBy = testData.manager) + } + + userAccount = testData.communityUser + performProjectAuthGet("keys/trash").andIsOk.andAssertThatJson { + node("_embedded.keys[0].deletedBy.username").isEqualTo("") + node("_embedded.keys[0].deletedBy.name").isEqualTo("Project Manager") + } + + userAccount = testData.manager + performProjectAuthGet("keys/trash").andIsOk.andAssertThatJson { + node("_embedded.keys[0].deletedBy.username").isEqualTo("project_manager") + } + } + + private fun storeImportSettings() = + performProjectAuthPut( + "import-settings", + mapOf( + "overrideKeyDescriptions" to true, + "createNewKeys" to false, + ), + ) + + private fun setPublic(public: Boolean) { + projectService.setPublic(testData.project.id, public) + } + + private fun projectUrl() = "/v2/projects/${testData.project.id}" +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectStatsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectStatsControllerTest.kt index 96ed796d449..58b3eb17946 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectStatsControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ProjectStatsControllerTest.kt @@ -7,6 +7,7 @@ import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.andPrettyPrint import io.tolgee.fixtures.isValidId import io.tolgee.fixtures.node +import io.tolgee.model.enums.Scope import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import org.junit.jupiter.api.AfterEach @@ -86,11 +87,22 @@ class ProjectStatsControllerTest : ProjectAuthControllerTest("/v2/projects/") { } } - @ProjectApiKeyAuthTestMethod + @ProjectApiKeyAuthTestMethod( + scopes = [Scope.TRANSLATIONS_EDIT, Scope.KEYS_EDIT, Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW, Scope.MEMBERS_VIEW], + ) fun `returns stats with API key`() { `returns stats`() } + @ProjectApiKeyAuthTestMethod( + scopes = [Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW], + ) + fun `hides member count from API key lacking members-view`() { + performProjectAuthGet("stats").andIsOk.andAssertThatJson { + node("membersCount").isEqualTo(0) + } + } + @Test @ProjectJWTAuthTestMethod fun `returns daily activity`() { diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/notification/NotificationControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/notification/NotificationControllerTest.kt index c7de115a5d6..3496896fa60 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/notification/NotificationControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/notification/NotificationControllerTest.kt @@ -39,6 +39,7 @@ class NotificationControllerTest : AuthorizedControllerTest() { node("_embedded.notificationModelList").isArray.hasSize(4) node("_embedded.notificationModelList[0].linkedTask.name").isEqualTo("Notification task 104") node("_embedded.notificationModelList[0].originatingUser.name").isEqualTo("originating user") + node("_embedded.notificationModelList[0].originatingUser.username").isEqualTo("notificationsOriginatingUser") } } diff --git a/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt b/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt index 4397086a315..5c8f0ff0892 100644 --- a/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt @@ -74,7 +74,8 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" }, ) { assertThatJson(it.poll()).apply { - node("actor.data.username").isEqualTo("test_username") + node("actor.data.username").isEqualTo("") + node("actor.data.name").isEqualTo("Test user name") node("data.keys[0]") { node("id").isValidId node("modifications.name") { @@ -274,6 +275,7 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" private fun prepareTestData() { testData = BaseTestData() + testData.user.name = "Test user name" testData.root.addUserAccount { username = "anotherUser" anotherUser = this diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/ComputedPermissionOrigin.kt b/backend/data/src/main/kotlin/io/tolgee/constants/ComputedPermissionOrigin.kt index 2bb84fea19b..2984194eb1e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/ComputedPermissionOrigin.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/ComputedPermissionOrigin.kt @@ -7,4 +7,5 @@ enum class ComputedPermissionOrigin { NONE, SERVER_ADMIN, SERVER_SUPPORTER, + COMMUNITY, } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectPublishingTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectPublishingTestData.kt index 4736fd2fdbf..a50a5d2edc8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectPublishingTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectPublishingTestData.kt @@ -2,11 +2,17 @@ package io.tolgee.development.testDataBuilder.data import io.tolgee.model.UserAccount import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.Scope class ProjectPublishingTestData : BaseTestData() { val owner: UserAccount get() = user lateinit var manager: UserAccount lateinit var serverAdmin: UserAccount + lateinit var viewer: UserAccount + lateinit var granularUser: UserAccount + lateinit var translationsEditor: UserAccount + lateinit var keysCreator: UserAccount + lateinit var communityUser: UserAccount init { root.apply { @@ -15,18 +21,64 @@ class ProjectPublishingTestData : BaseTestData() { name = "Project Manager" manager = this } + addUserAccount { + username = "project_viewer" + name = "Project Viewer" + viewer = this + } addUserAccount { username = "server_admin" name = "Server Admin" role = UserAccount.Role.ADMIN serverAdmin = this } + addUserAccount { + username = "granular_user" + name = "Granular User" + granularUser = this + } + addUserAccount { + username = "translations_editor" + name = "Translations Editor" + translationsEditor = this + } + addUserAccount { + username = "keys_creator" + name = "Keys Creator" + keysCreator = this + } + addUserAccount { + username = "community_user" + name = "Community User" + communityUser = this + } } projectBuilder.build { addPermission { user = this@ProjectPublishingTestData.manager type = ProjectPermissionType.MANAGE } + addPermission { + user = this@ProjectPublishingTestData.viewer + type = ProjectPermissionType.VIEW + } + addPermission { + user = this@ProjectPublishingTestData.granularUser + scopes = arrayOf(Scope.TRANSLATIONS_VIEW) + } + addPermission { + user = this@ProjectPublishingTestData.translationsEditor + scopes = arrayOf(Scope.TRANSLATIONS_EDIT) + } + addPermission { + user = this@ProjectPublishingTestData.keysCreator + scopes = arrayOf(Scope.KEYS_CREATE) + } + addKey { + name = "trash-me" + }.build { + addTranslation("en", "To be trashed") + } } } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt index 11ecc8471f3..4e6a97c1768 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt @@ -20,6 +20,7 @@ class SuggestionsTestData( var projectTranslator: UserAccountBuilder var czechTranslator: UserAccountBuilder var czechReviewer: UserAccountBuilder + var communityUser: UserAccountBuilder var relatedProject: ProjectBuilder var keys: MutableList = mutableListOf() val czechSuggestions: MutableList = mutableListOf() @@ -73,6 +74,12 @@ class SuggestionsTestData( name = "Czech reviewer" } + communityUser = + root.addUserAccount { + username = "community@test.com" + name = "Community user" + } + userAccountBuilder.defaultOrganizationBuilder.apply { addRole { user = orgMember.self @@ -110,6 +117,7 @@ class SuggestionsTestData( addPermission { user = czechTranslator.self type = ProjectPermissionType.TRANSLATE + viewLanguages = mutableSetOf(czechLanguage) translateLanguages = mutableSetOf(czechLanguage) suggestLanguages = mutableSetOf(czechLanguage) } @@ -170,6 +178,13 @@ class SuggestionsTestData( ) } + czechTranslations[0].apply { + addComment { + text = "Member comment" + author = this@SuggestionsTestData.user + } + } + pluralKey = addKey(null, "pluralKey").apply { self.isPlural = true diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt index 74b68c53d45..bde265f23f6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt @@ -88,6 +88,14 @@ class ComputedPermissionDto( return this } + fun withCommunityFloor(): ComputedPermissionDto { + val hasCommunityScopes = expandedScopes.toSet().containsAll(COMMUNITY.scopes.toList()) + val viewAndSuggestUnrestricted = viewLanguageIds.isNullOrEmpty() && suggestLanguageIds.isNullOrEmpty() + if (hasCommunityScopes && viewAndSuggestUnrestricted) return this + if (scopes.isEmpty()) return COMMUNITY + return ComputedPermissionDto(getCommunityFlooredPermission(this), origin = origin) + } + constructor(permission: IPermission) : this( permission, origin = @@ -136,6 +144,17 @@ class ComputedPermissionDto( } } + private fun getCommunityFlooredPermission(base: IPermission): IPermission { + return object : IPermission by getExtendedPermission(base, COMMUNITY.scopes) { + // All-language view + suggest: a language-restricted base must not leave a member + // viewing/suggesting in fewer languages than a non-member. + override val viewLanguageIds: Set? + get() = null + override val suggestLanguageIds: Set? + get() = null + } + } + val ComputedPermissionDto.isAllReadOnlyPermitted: Boolean get() = expandedScopes.toSet().containsAll(Scope.readOnlyScopes.toList()) @@ -168,5 +187,22 @@ class ComputedPermissionDto( ), origin = ComputedPermissionOrigin.SERVER_SUPPORTER, ) + + val COMMUNITY + get() = + ComputedPermissionDto( + getEmptyPermission( + scopes = + arrayOf( + Scope.TRANSLATIONS_VIEW, + Scope.SCREENSHOTS_VIEW, + Scope.ACTIVITY_VIEW, + Scope.TRANSLATIONS_SUGGEST, + Scope.TRANSLATIONS_COMMENTS_ADD, + ), + type = ProjectPermissionType.VIEW, + ), + origin = ComputedPermissionOrigin.COMMUNITY, + ) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt index 8b72d0cf716..f01d565c5b3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt @@ -11,6 +11,7 @@ enum class ProjectPermissionType( Scope.ACTIVITY_VIEW, Scope.KEYS_VIEW, Scope.TASKS_VIEW, + Scope.ORGANIZATION_QUOTAS_VIEW, ), ), TRANSLATE( @@ -25,6 +26,7 @@ enum class ProjectPermissionType( Scope.TRANSLATIONS_COMMENTS_SET_STATE, Scope.TASKS_VIEW, Scope.TRANSLATION_LABEL_ASSIGN, + Scope.ORGANIZATION_QUOTAS_VIEW, ), ), REVIEW( @@ -40,6 +42,7 @@ enum class ProjectPermissionType( Scope.TRANSLATIONS_STATE_EDIT, Scope.TASKS_VIEW, Scope.TRANSLATION_LABEL_ASSIGN, + Scope.ORGANIZATION_QUOTAS_VIEW, ), ), EDIT( @@ -66,6 +69,7 @@ enum class ProjectPermissionType( Scope.PROMPTS_VIEW, Scope.PROMPTS_EDIT, Scope.TRANSLATION_LABEL_ASSIGN, + Scope.ORGANIZATION_QUOTAS_VIEW, ), ), MANAGE( diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt index 063b4ca09e4..b1d71eb5578 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt @@ -45,6 +45,7 @@ enum class Scope( ALL_VIEW("all.view"), BRANCH_MANAGEMENT("branch.management"), BRANCH_PROTECTED_MODIFY("branch.protected-modify"), + ORGANIZATION_QUOTAS_VIEW("organization-quotas.view"), ; fun expand() = Scope.expand(this) @@ -160,6 +161,7 @@ enum class Scope( batchJobsView, tasksView, promptsView, + HierarchyItem(ORGANIZATION_QUOTAS_VIEW), ), ), HierarchyItem(BRANCH_MANAGEMENT), diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index a9bc0314339..6371b632870 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -308,6 +308,7 @@ class ProjectService( organization.basePermission, permission, userAccount.role ?: UserAccount.Role.USER, + isProjectPublic = project.public, ).scopes fromEntityAndPermission(project, scopes) }.toList() diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt index 9839029e0d2..d92929bdbf5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt @@ -120,6 +120,7 @@ class PermissionService( organizationBasePermission = organizationBasePermission, directPermission = projectPermission, userAccountService.findDto(userAccountId)?.role ?: throw IllegalStateException("User not found"), + isProjectPublic = project.public, ) return ProjectPermissionData( @@ -232,6 +233,7 @@ class PermissionService( organizationBasePermission: IPermission, directPermission: IPermission?, userRole: UserAccount.Role? = null, + isProjectPublic: Boolean = false, ): ComputedPermissionDto { val computed = when { @@ -246,6 +248,10 @@ class PermissionService( else -> ComputedPermissionDto.NONE } + if (isProjectPublic && userRole != null) { + return computed.withCommunityFloor().getAdminOrSupporterPermissions(userRole) + } + return computed.getAdminOrSupporterPermissions(userRole) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt index c655bccb530..231b9b8300d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt @@ -31,6 +31,7 @@ import io.tolgee.service.task.ITaskService import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service +import org.springframework.web.context.request.RequestContextHolder @Service class SecurityService( @@ -77,6 +78,19 @@ class SecurityService( return getCurrentPermittedScopes(projectHolder.project.id).containsAll(scopes) } + fun maskedMemberField(value: String?): String { + if (shouldExposeMemberInfo()) return value ?: "" + return "" + } + + fun shouldExposeMemberInfo(): Boolean { + // Off-request threads (e.g. @Async activity-websocket broadcasts) have no request scope, where + // reading projectHolder.projectOrNull throws; there is also no caller whose scope could gate this. + if (RequestContextHolder.getRequestAttributes() == null) return true + if (projectHolder.projectOrNull == null) return true + return currentPermittedScopesContain(Scope.MEMBERS_VIEW) + } + /** * Returns current permitted scopes, expanded */ @@ -118,26 +132,23 @@ class SecurityService( this.checkApiKeyScopes(listOf(requiredPermission), apiKey) } - fun hasTaskEditScopeOrIsAssigned( + fun checkTaskEditScopeOrAssigned( projectId: Long, taskNumber: Long, - ) { - try { - checkProjectPermission(projectId, Scope.TASKS_EDIT) - } catch (err: PermissionException) { - val assignees = taskService.findAssigneeById(projectId, taskNumber, activeUser.id) - if (assignees.isEmpty() || assignees[0].id != activeUser.id) { - throw err - } - } - } + ) = checkTaskScopeOrAssigned(projectId, taskNumber, Scope.TASKS_EDIT) - fun hasTaskViewScopeOrIsAssigned( + fun checkTaskViewScopeOrAssigned( projectId: Long, taskNumber: Long, + ) = checkTaskScopeOrAssigned(projectId, taskNumber, Scope.TASKS_VIEW) + + private fun checkTaskScopeOrAssigned( + projectId: Long, + taskNumber: Long, + scope: Scope, ) { try { - checkProjectPermission(projectId, Scope.TASKS_VIEW) + checkProjectPermission(projectId, scope) } catch (err: PermissionException) { val assignees = taskService.findAssigneeById(projectId, taskNumber, activeUser.id) if (assignees.isEmpty() || assignees[0].id != activeUser.id) { diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/CommunityPermissionScopesTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/CommunityPermissionScopesTest.kt new file mode 100644 index 00000000000..513d486ba7a --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/CommunityPermissionScopesTest.kt @@ -0,0 +1,75 @@ +package io.tolgee.unit + +import io.tolgee.constants.ComputedPermissionOrigin +import io.tolgee.dtos.ComputedPermissionDto +import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.Scope +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class CommunityPermissionScopesTest { + @Test + fun `community grant has the COMMUNITY origin`() { + assertThat(ComputedPermissionDto.COMMUNITY.origin).isEqualTo(ComputedPermissionOrigin.COMMUNITY) + } + + @Test + fun `community raw scopes are exactly the expected set`() { + assertThat(ComputedPermissionDto.COMMUNITY.scopes.toSet()) + .containsExactlyInAnyOrder( + Scope.TRANSLATIONS_VIEW, + Scope.SCREENSHOTS_VIEW, + Scope.ACTIVITY_VIEW, + Scope.TRANSLATIONS_SUGGEST, + Scope.TRANSLATIONS_COMMENTS_ADD, + ) + } + + @Test + fun `community expanded scopes pull in the implied read scopes`() { + assertThat(ComputedPermissionDto.COMMUNITY.expandedScopes.toSet()) + .contains(Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW) + } + + @Test + fun `community never grants write, member, or restricted-internals scopes`() { + assertThat(ComputedPermissionDto.COMMUNITY.expandedScopes.toSet()) + .doesNotContain( + Scope.TRANSLATIONS_EDIT, + Scope.TRANSLATIONS_STATE_EDIT, + Scope.KEYS_EDIT, + Scope.KEYS_CREATE, + Scope.KEYS_DELETE, + Scope.LANGUAGES_EDIT, + Scope.MEMBERS_VIEW, + Scope.MEMBERS_EDIT, + Scope.BRANCH_MANAGEMENT, + Scope.ORGANIZATION_QUOTAS_VIEW, + Scope.PROMPTS_VIEW, + Scope.ADMIN, + ) + } + + @Test + fun `community grant carries no language restriction (all languages)`() { + val permission = ComputedPermissionDto.COMMUNITY + assertThat(permission.viewLanguageIds).isNull() + assertThat(permission.translateLanguageIds).isNull() + assertThat(permission.suggestLanguageIds).isNull() + assertThat(permission.stateChangeLanguageIds).isNull() + } + + @Test + fun `standard member permission types grant the organization-quotas scope`() { + listOf( + ProjectPermissionType.VIEW, + ProjectPermissionType.TRANSLATE, + ProjectPermissionType.REVIEW, + ProjectPermissionType.EDIT, + ProjectPermissionType.MANAGE, + ).forEach { type -> + assertThat(Scope.expand(type.availableScopes).toSet()) + .contains(Scope.ORGANIZATION_QUOTAS_VIEW) + } + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/MemberInfoMaskingTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/MemberInfoMaskingTest.kt new file mode 100644 index 00000000000..1d710fd7f9d --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/MemberInfoMaskingTest.kt @@ -0,0 +1,23 @@ +package io.tolgee.unit + +import io.tolgee.security.ProjectHolder +import io.tolgee.service.security.SecurityService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class MemberInfoMaskingTest { + @Test + fun `maskedMemberField exposes the value off-request without touching the project holder`() { + val projectHolder = + mock { + whenever(it.projectOrNull).thenThrow(IllegalStateException("project holder must not be read off-request")) + } + val securityService = + SecurityService(mock(), mock(), mock(), projectHolder = projectHolder, branchService = mock()) + + assertThat(securityService.shouldExposeMemberInfo()).isTrue() + assertThat(securityService.maskedMemberField("translator@test.com")).isEqualTo("translator@test.com") + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt index db468a8c392..6a48898baef 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt @@ -127,8 +127,7 @@ class TaskController( @PathVariable taskNumber: Long, ): TaskModel { - // users can view tasks assigned to them - securityService.hasTaskViewScopeOrIsAssigned(projectHolder.project.id, taskNumber) + securityService.checkTaskViewScopeOrAssigned(projectHolder.project.id, taskNumber) val task = taskService.getTask(projectHolder.project.id, taskNumber) return taskModelAssembler.toModel(task) } @@ -179,8 +178,7 @@ class TaskController( @PathVariable taskNumber: Long, ): TaskModel { - // users can only finish tasks assigned to them - securityService.hasTaskEditScopeOrIsAssigned(projectHolder.project.id, taskNumber) + securityService.checkTaskEditScopeOrAssigned(projectHolder.project.id, taskNumber) val task = taskService.setTaskState(projectHolder.project.id, taskNumber, TaskState.FINISHED) return taskModelAssembler.toModel(task) } @@ -240,7 +238,7 @@ class TaskController( @PathVariable taskNumber: Long, ): List { - securityService.hasTaskViewScopeOrIsAssigned(projectHolder.project.id, taskNumber) + securityService.checkTaskViewScopeOrAssigned(projectHolder.project.id, taskNumber) val result = taskService.getReport(projectHolder.projectEntity, taskNumber) return result.map { taskPerUserReportModelAssembler.toModel(it) } @@ -258,7 +256,7 @@ class TaskController( @PathVariable taskNumber: Long, ): ResponseEntity { - securityService.hasTaskViewScopeOrIsAssigned(projectHolder.project.id, taskNumber) + securityService.checkTaskViewScopeOrAssigned(projectHolder.project.id, taskNumber) val byteArray = taskService.getExcelFile(projectHolder.projectEntity, taskNumber) val resource = ByteArrayResource(byteArray) @@ -279,7 +277,7 @@ class TaskController( @PathVariable taskNumber: Long, ): TaskKeysResponse { - securityService.hasTaskViewScopeOrIsAssigned(projectHolder.project.id, taskNumber) + securityService.checkTaskViewScopeOrAssigned(projectHolder.project.id, taskNumber) return TaskKeysResponse( keys = taskService.getTaskKeys(projectHolder.project.id, taskNumber), ) @@ -310,6 +308,7 @@ class TaskController( @PathVariable taskNumber: Long, ): List { + securityService.checkTaskViewScopeOrAssigned(projectHolder.project.id, taskNumber) return taskService.getBlockingTasks(projectHolder.project.id, taskNumber) } @@ -330,8 +329,7 @@ class TaskController( @RequestBody @Valid dto: UpdateTaskKeyRequest, ): UpdateTaskKeyResponse { - // users can only update tasks assigned to them - securityService.hasTaskEditScopeOrIsAssigned(projectHolder.project.id, taskNumber) + securityService.checkTaskEditScopeOrAssigned(projectHolder.project.id, taskNumber) return taskService.updateTaskKey(projectHolder.project.id, taskNumber, keyId, dto) } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/branching/BranchController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/branching/BranchController.kt index ebb10049cb2..273fd6cfa86 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/branching/BranchController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/branching/BranchController.kt @@ -177,7 +177,7 @@ class BranchController( @GetMapping(value = ["/merge"]) @Operation(summary = "Get branch merges") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(4) fun getBranchMerges( @ParameterObject @@ -191,7 +191,7 @@ class BranchController( @PostMapping(value = ["/merge/preview"]) @Operation(summary = "Creates a merge, dry-runs source branch to target branch and return preview") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(5) fun dryRunMerge( @RequestBody request: DryRunMergeBranchRequest, @@ -204,7 +204,7 @@ class BranchController( @GetMapping(value = ["/merge/{mergeId}/preview"]) @Operation(summary = "Get branch merge session preview") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(6) fun getBranchMergeSessionPreview( @PathVariable mergeId: Long, @@ -217,7 +217,7 @@ class BranchController( @PostMapping(value = ["/merge/{mergeId}/refresh"]) @Operation(summary = "Refresh branch merge session preview") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(11) fun refreshBranchMerge( @PathVariable mergeId: Long, @@ -230,7 +230,7 @@ class BranchController( @GetMapping(value = ["/merge/{mergeId}/conflicts"]) @Operation(summary = "Get branch merge session conflicts") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(7) fun getBranchMergeSessionConflicts( @ParameterObject @@ -245,7 +245,7 @@ class BranchController( @GetMapping(value = ["/merge/{mergeId}/changes"]) @Operation(summary = "Get branch merge session changes") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(7) fun getBranchMergeSessionChanges( @ParameterObject pageable: Pageable, @@ -260,7 +260,7 @@ class BranchController( @GetMapping(value = ["/merge/{mergeId}/changes/{changeId}"]) @Operation(summary = "Get single branch merge session change") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(8) fun getBranchMergeSessionChange( @PathVariable mergeId: Long, @@ -274,7 +274,7 @@ class BranchController( @PutMapping(value = ["/merge/{mergeId}/resolve"]) @Operation(summary = "Resolve branch merge session conflicts") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(9) fun resolveConflict( @PathVariable mergeId: Long, @@ -287,7 +287,7 @@ class BranchController( @PutMapping(value = ["/merge/{mergeId}/resolve-all"]) @Operation(summary = "Resolve all branch merge session conflicts") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(10) fun resolveAllConflicts( @PathVariable mergeId: Long, @@ -300,7 +300,7 @@ class BranchController( @DeleteMapping(value = ["/merge/{mergeId}"]) @Operation(summary = "Delete branch merge session") @AllowApiAccess - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(11) fun deleteBranchMerge( @PathVariable mergeId: Long, @@ -313,7 +313,7 @@ class BranchController( @Operation(summary = "Merge source branch to target branch") @AllowApiAccess @RequestActivity(ActivityType.BRANCH_MERGE) - @UseDefaultPermissions + @RequiresProjectPermissions([Scope.BRANCH_MANAGEMENT]) @OpenApiOrderExtension(12) fun merge( @PathVariable mergeId: Long, diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/CommunitySuggestionTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/CommunitySuggestionTest.kt new file mode 100644 index 00000000000..714646a5f7e --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/CommunitySuggestionTest.kt @@ -0,0 +1,202 @@ +package io.tolgee.ee.api.v2.controllers + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.SuggestionsTestData +import io.tolgee.dtos.request.translation.SetTranslationsWithKeyDto +import io.tolgee.dtos.request.translation.comment.TranslationCommentWithLangKeyDto +import io.tolgee.ee.data.translationSuggestion.CreateTranslationSuggestionRequest +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CommunitySuggestionTest : ProjectAuthControllerTest("/v2/projects/") { + private lateinit var testData: SuggestionsTestData + + @BeforeEach + fun setup() { + testData = SuggestionsTestData() + projectSupplier = { testData.relatedProject.self } + testDataService.saveTestData(testData.root) + projectService.setPublic(testData.relatedProject.self.id, true) + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user can suggest on a public project`() { + userAccount = testData.communityUser.self + performProjectAuthPost( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion", + CreateTranslationSuggestionRequest(translation = "Community suggested translation"), + ).andIsOk + } + + @Test + @ProjectJWTAuthTestMethod + fun `a language-restricted member can suggest outside their languages on a public project`() { + userAccount = testData.czechTranslator.self + performProjectAuthPost( + "languages/${testData.englishLanguage.id}/key/${testData.keys[0].self.id}/suggestion", + CreateTranslationSuggestionRequest(translation = "English suggestion from a Czech-only translator"), + ).andIsOk + } + + @Test + @ProjectJWTAuthTestMethod + fun `a view-restricted member can view outside their languages on a public project`() { + userAccount = testData.czechTranslator.self + performProjectAuthGet( + "/translations?languages=en&languages=cs&filterKeyId=${testData.keys[0].self.id}", + ).andIsOk.andAssertThatJson { + node("_embedded.keys[0].translations.en.text").isEqualTo("Translation 0") + node("_embedded.keys[0].translations.cs.text").isEqualTo("Překlad 0") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user can comment on a public project`() { + userAccount = testData.communityUser.self + performProjectAuthPost( + "translations/create-comment", + TranslationCommentWithLangKeyDto( + keyId = testData.keys[0].self.id, + languageId = testData.czechLanguage.id, + text = "Community comment", + ), + ).andIsCreated + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user cannot accept a suggestion`() { + userAccount = testData.communityUser.self + performProjectAuthPut( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}" + + "/suggestion/${testData.czechSuggestions[0].self.id}/accept", + null, + ).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user cannot reach branch merge sessions`() { + userAccount = testData.communityUser.self + performProjectAuthGet("branches/merge").andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `a member without branch-management cannot start a branch merge`() { + userAccount = testData.projectTranslator.self + performProjectAuthPost("branches/merge/preview", mapOf()).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `community user cannot read blocking tasks`() { + userAccount = testData.communityUser.self + performProjectAuthGet("tasks/1/blocking-tasks").andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `author emails are hidden from community but the name stays visible`() { + val path = "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion" + + userAccount = testData.communityUser.self + performProjectAuthGet(path).andIsOk.andAssertThatJson { + node("_embedded.suggestions[0].author.username").isEqualTo("") + node("_embedded.suggestions[0].author.name").isEqualTo("Project translator") + } + + userAccount = testData.user + performProjectAuthGet(path).andIsOk.andAssertThatJson { + node("_embedded.suggestions[0].author.username").isEqualTo("translator@test.com") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `author emails are hidden from community in the embedded translations list`() { + val path = "/translations?sort=id&filterKeyId=${testData.keys[0].self.id}" + + userAccount = testData.communityUser.self + performProjectAuthGet(path).andIsOk.andAssertThatJson { + node("_embedded.keys[0].translations.cs.suggestions[0].author.username").isEqualTo("") + node("_embedded.keys[0].translations.cs.suggestions[0].author.name").isEqualTo("Project reviewer") + } + + userAccount = testData.user + performProjectAuthGet(path).andIsOk.andAssertThatJson { + node("_embedded.keys[0].translations.cs.suggestions[0].author.username").isEqualTo("reviewer@test.com") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `author emails are hidden from community in the activity feed and translation history`() { + userAccount = testData.user + performProjectAuthPut( + "translations", + SetTranslationsWithKeyDto(testData.keys[0].self.name, null, mutableMapOf("cs" to "Edited by member")), + ).andIsOk + + val translationId = + transactionTemplate.execute { + translationService.find(testData.keys[0].self, testData.czechLanguage).get().id + }!! + + userAccount = testData.communityUser.self + performProjectAuthGet("activity").andIsOk.andAssertThatJson { + node("_embedded.activities[0].author.username").isEqualTo("") + node("_embedded.activities[0].author.name").isEqualTo("Tasks test user") + } + performProjectAuthGet("translations/$translationId/history").andIsOk.andAssertThatJson { + node("_embedded.revisions[0].author.username").isEqualTo("") + node("_embedded.revisions[0].author.name").isEqualTo("Tasks test user") + } + + userAccount = testData.user + performProjectAuthGet("activity").andIsOk.andAssertThatJson { + node("_embedded.activities[0].author.username").isEqualTo("suggestionsuggestionsTestUser") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `comment author emails are hidden from community`() { + val translationId = + transactionTemplate.execute { + translationService.find(testData.keys[0].self, testData.czechLanguage).get().id + }!! + + userAccount = testData.communityUser.self + performProjectAuthGet("translations/$translationId/comments").andIsOk.andAssertThatJson { + node("_embedded.translationComments[0].author.username").isEqualTo("") + node("_embedded.translationComments[0].author.name").isEqualTo("Tasks test user") + } + + userAccount = testData.user + performProjectAuthGet("translations/$translationId/comments").andIsOk.andAssertThatJson { + node("_embedded.translationComments[0].author.username").isEqualTo("suggestionsuggestionsTestUser") + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `suggesting does not turn the community user into a member`() { + userAccount = testData.communityUser.self + performProjectAuthPost( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion", + CreateTranslationSuggestionRequest(translation = "Community suggested translation"), + ).andIsOk + performAuthGet("/v2/projects/${testData.relatedProject.self.id}").andIsOk.andAssertThatJson { + node("computedPermission.origin").isEqualTo("COMMUNITY") + } + } +} diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt index fd152bba8c4..7cd9aaa54b4 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt @@ -40,12 +40,12 @@ class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { .andAssertThatJson { node("_embedded.suggestions") { node("[0].translation").isEqualTo("Navržený překlad 0-1") - node("[0].author.username").isEqualTo("translator@test.com") + node("[0].author.username").isEqualTo("") node("[0].author.name").isEqualTo("Project translator") } node("_embedded.suggestions") { node("[1].translation").isEqualTo("Navržený překlad 0-2") - node("[1].author.username").isEqualTo("reviewer@test.com") + node("[1].author.username").isEqualTo("") node("[1].author.name").isEqualTo("Project reviewer") } node("page.totalElements").isNumber.isEqualTo(BigDecimal(2)) @@ -89,7 +89,7 @@ class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { ).andAssertThatJson { node("accepted") { node("translation").isEqualTo("Navržený překlad 0-1") - node("author.username").isEqualTo("translator@test.com") + node("author.username").isEqualTo("") node("state").isEqualTo("ACCEPTED") } node("declined").isArray.hasSize(0) @@ -148,7 +148,7 @@ class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { ).andAssertThatJson { node("accepted") { node("translation").isEqualTo("Navržený překlad 0-1") - node("author.username").isEqualTo("translator@test.com") + node("author.username").isEqualTo("") node("state").isEqualTo("ACCEPTED") } node("declined[0]").isEqualTo(testData.czechSuggestions[1].self.id) diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerPermissionsTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerPermissionsTest.kt index 121370b401a..e1c63bdd8c3 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerPermissionsTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerPermissionsTest.kt @@ -53,6 +53,7 @@ class TaskControllerPermissionsTest : ProjectAuthControllerTest("/v2/projects/") performProjectAuthGet("tasks/${testData.translateTask.self.number}/per-user-report").andIsOk performProjectAuthGet("tasks/${testData.translateTask.self.number}/xlsx-report").andIsOk performProjectAuthGet("tasks/${testData.translateTask.self.number}/keys").andIsOk + performProjectAuthGet("tasks/${testData.translateTask.self.number}/blocking-tasks").andIsOk } @Test diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt index c01f404b8cd..b9a1f8947de 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt @@ -79,6 +79,25 @@ class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { } } + @Test + @ProjectJWTAuthTestMethod + fun `task author email is hidden from a member lacking members-view`() { + userAccount = testData.projectViewRoleUser.self + performProjectAuthGet( + "tasks/${testData.translateTask.self.number}", + ).andAssertThatJson { + node("author.username").isEqualTo("") + node("author.name").isEqualTo("Project user") + } + + userAccount = testData.projectManageRoleUser.self + performProjectAuthGet( + "tasks/${testData.translateTask.self.number}", + ).andAssertThatJson { + node("author.username").isEqualTo("project.user@test.com") + } + } + @Test @ProjectJWTAuthTestMethod fun `creates new task which triggers notification`() { diff --git a/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts b/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts index dcb2ad48508..0af4e9e9230 100644 --- a/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts +++ b/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts @@ -117,6 +117,9 @@ export const usePermissionsStructure = () => { { value: 'translations.batch-machine' }, ], }, + { + value: 'organization-quotas.view', + }, { label: t('permissions_item_members'), children: [ diff --git a/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx b/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx index 363a80c9a90..ae82bd0ceec 100644 --- a/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx +++ b/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx @@ -53,6 +53,7 @@ export const useScopeTranslations = () => { 'all.view': t('permissions_item_all_view'), 'branch.management': t('permissions_item_branch_management'), 'branch.protected-modify': t('permissions_item_branch_protected_modify'), + 'organization-quotas.view': t('permissions_item_organization_quotas_view'), }; return { diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 0e70e080b45..7e17e0fc6f8 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -878,7 +878,7 @@ export interface paths { delete: operations["deletePrompt"]; }; "/v2/projects/{projectId}/publishing": { - /** Marks the project as public or private. Only the organization owner can change this. */ + /** Marks the project as public or private. Only the organization owner or a server admin can change this. */ put: operations["setProjectPublic"]; }; "/v2/projects/{projectId}/qa-settings": { @@ -1435,6 +1435,7 @@ export interface components { | "all.view" | "branch.management" | "branch.protected-modify" + | "organization-quotas.view" )[]; /** * @description List of languages user can change state to. If null, changing state of all language values is permitted. @@ -2088,7 +2089,8 @@ export interface components { | "ORGANIZATION_OWNER" | "NONE" | "SERVER_ADMIN" - | "SERVER_SUPPORTER"; + | "SERVER_SUPPORTER" + | "COMMUNITY"; permissionModel?: components["schemas"]["PermissionModel"]; /** * @deprecated @@ -2145,6 +2147,7 @@ export interface components { | "all.view" | "branch.management" | "branch.protected-modify" + | "organization-quotas.view" )[]; /** * @description List of languages user can change state to. If null, changing state of all language values is permitted. @@ -3423,7 +3426,8 @@ export interface components { | "translation-labels.assign" | "all.view" | "branch.management" - | "branch.protected-modify"; + | "branch.protected-modify" + | "organization-quotas.view"; }; IdentifyRequest: { anonymousUserId: string; @@ -4917,6 +4921,7 @@ export interface components { | "all.view" | "branch.management" | "branch.protected-modify" + | "organization-quotas.view" )[]; /** * @description List of languages user can change state to. If null, changing state of all language values is permitted. @@ -5013,6 +5018,7 @@ export interface components { | "all.view" | "branch.management" | "branch.protected-modify" + | "organization-quotas.view" )[]; /** * @description List of languages user can change state to. If null, changing state of all language values is permitted. @@ -19903,7 +19909,7 @@ export interface operations { }; }; }; - /** Marks the project as public or private. Only the organization owner can change this. */ + /** Marks the project as public or private. Only the organization owner or a server admin can change this. */ setProjectPublic: { parameters: { path: { @@ -24575,6 +24581,7 @@ export interface operations { | "all.view" | "branch.management" | "branch.protected-modify" + | "organization-quotas.view" )[]; }; }; diff --git a/webapp/src/views/projects/dashboard/ProjectTotals.tsx b/webapp/src/views/projects/dashboard/ProjectTotals.tsx index 51eb2daa753..8293bb02278 100644 --- a/webapp/src/views/projects/dashboard/ProjectTotals.tsx +++ b/webapp/src/views/projects/dashboard/ProjectTotals.tsx @@ -301,12 +301,16 @@ export const ProjectTotals: React.FC< > - {Number(stats.membersCount).toLocaleString(locale)} + {canViewMembers + ? Number(stats.membersCount).toLocaleString(locale) + : '—'} - {t('project_dashboard_member_count_plural', { - value: Number(stats.membersCount), - })} + {canViewMembers + ? t('project_dashboard_member_count_plural', { + value: Number(stats.membersCount), + }) + : t('project_menu_members')} {membersEditable && ( diff --git a/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx b/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx index 82fe19c3696..3d107a5b9ec 100644 --- a/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx +++ b/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx @@ -188,7 +188,10 @@ export const TranslationSuggestion = ({ title={ }} + params={{ + user: suggestion.author.name ?? suggestion.author.username, + b: , + }} /> } > From 79c705e71ef2a1517cf8d25e0d13d9ff1b73db48 Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Tue, 30 Jun 2026 17:58:43 +0200 Subject: [PATCH 3/8] feat: public badge + organization on project list rows (#3771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the Community Translation v1.0 pitch (#3763), PR 1 — Public projects. Targets the `jirikuchynka/public-projects` branch. ## What Public projects now show a **second line** under the project name in the project list row: an outlined lowercase **`public`** badge followed by the **organization name**. Per the shaped pitch, this list-row badge is the *only* public indicator — there is no separate icon in the project detail view. ## Changes - **Backend**: add `public: Boolean` to `ProjectWithStatsModel` + map it in `ProjectWithStatsModelAssembler`; regenerate the frontend API schema. - **Frontend**: `DashboardProjectListItem` renders the badge (`TransparentChip`, i18n key `project_list_public_badge`) + organization name when the project is public. - **Tests**: backend `OrganizationProjectsControllerTest` asserts the `public` flag is serialized (public + private); cypress `projectsListDashboard.cy.ts` asserts the badge + org name render on a public row and are absent on a private row. ## Verification - Backend test green; ktlint green. - webapp + e2e eslint + tsc green. - Cypress `projectsListDashboard.cy.ts` — all 5 tests pass (incl. the 2 new badge tests) against a live dev stack. ## Notes - i18n key `project_list_public_badge` ("public") created in the Tolgee project; no inline default in code (matches sibling keys). - In the normal org list the title column is 150px, so very long org names ellipsize (consistent with the existing project-name layout). Full names show in the wider community/public list layouts (later PRs in this pitch). --- .../hateoas/project/ProjectWithStatsModel.kt | 2 + .../project/ProjectWithStatsModelAssembler.kt | 1 + .../OrganizationProjectsControllerTest.kt | 25 ++++++++++++ .../data/ProjectsListDashboardTestData.kt | 7 ++++ .../testDataBuilder/data/ProjectsTestData.kt | 2 +- .../data/PublicProjectsStatsTestData.kt | 31 ++++++++++++++ .../ProjectListDashboardE2eDataController.kt | 6 +-- .../e2e/projects/projectsListDashboard.cy.ts | 19 +++++++++ e2e/cypress/support/dataCyType.d.ts | 2 + webapp/src/service/apiSchema.generated.ts | 2 + .../projects/DashboardProjectListItem.tsx | 40 +++++++++++++++++++ 11 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsListDashboardTestData.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsStatsTestData.kt diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModel.kt index 6067b0a13ee..59e8628a338 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModel.kt @@ -31,6 +31,8 @@ open class ProjectWithStatsModel( val computedPermission: ComputedPermissionModel, val stats: ProjectStatistics, val languages: List, + @Schema(description = "Whether the project is public — discoverable and open to community suggestions") + val public: Boolean, @Schema(description = "Whether to disable ICU placeholder visualization in the editor and it's support.") var icuPlaceholders: Boolean, ) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt index 0adfe680088..5fe0857a6ad 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt @@ -57,6 +57,7 @@ class ProjectWithStatsModelAssembler( computedPermission = computedPermissionModelAssembler.toModel(computedPermissions), stats = view.stats, languages = view.languages.map { languageModelAssembler.toModel(it) }, + public = view.public, icuPlaceholders = view.icuPlaceholders, ) } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationProjectsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationProjectsControllerTest.kt index ac07e369b5d..0b8bd2f3995 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationProjectsControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationProjectsControllerTest.kt @@ -2,6 +2,7 @@ package io.tolgee.api.v2.controllers.organizationController import io.tolgee.development.testDataBuilder.data.PermissionsTestData import io.tolgee.development.testDataBuilder.data.ProjectTranslationsStatsTestData +import io.tolgee.development.testDataBuilder.data.PublicProjectsStatsTestData import io.tolgee.fixtures.andAssertThatJson import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.andPrettyPrint @@ -134,6 +135,30 @@ class OrganizationProjectsControllerTest : AuthorizedControllerTest() { } } + @Test + fun `projects-with-stats serializes the public flag`() { + val testData = PublicProjectsStatsTestData() + testDataService.saveTestData(testData.root) + userAccount = testData.admin.self + val organizationId = testData.organizationBuilder.self.id + + performAuthGet("/v2/organizations/$organizationId/projects-with-stats?search=alpha") + .andIsOk + .andAssertThatJson { + node("_embedded.projects").isArray.hasSize(1) + node("_embedded.projects[0].name").isEqualTo("Alpha Public") + node("_embedded.projects[0].public").isEqualTo(true) + } + + performAuthGet("/v2/organizations/$organizationId/projects-with-stats?search=beta") + .andIsOk + .andAssertThatJson { + node("_embedded.projects").isArray.hasSize(1) + node("_embedded.projects[0].name").isEqualTo("Beta Private") + node("_embedded.projects[0].public").isEqualTo(false) + } + } + @Test fun `project with single language should show correct translation percentages`() { val testData = ProjectTranslationsStatsTestData() diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsListDashboardTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsListDashboardTestData.kt new file mode 100644 index 00000000000..ad91ce62a5d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsListDashboardTestData.kt @@ -0,0 +1,7 @@ +package io.tolgee.development.testDataBuilder.data + +class ProjectsListDashboardTestData : ProjectsTestData() { + init { + project2.public = true + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsTestData.kt index 03c069e3f03..52e3569f6c1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ProjectsTestData.kt @@ -9,7 +9,7 @@ import io.tolgee.model.enums.TaskType import io.tolgee.model.enums.TranslationState import io.tolgee.model.task.Task -class ProjectsTestData : BaseTestData() { +open class ProjectsTestData : BaseTestData() { lateinit var project2English: Language lateinit var project2Deutsch: Language lateinit var project2: Project diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsStatsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsStatsTestData.kt new file mode 100644 index 00000000000..2b2be56fdd1 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsStatsTestData.kt @@ -0,0 +1,31 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.OrganizationBuilder +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.development.testDataBuilder.builders.UserAccountBuilder + +class PublicProjectsStatsTestData { + var organizationBuilder: OrganizationBuilder + var admin: UserAccountBuilder + + val root: TestDataBuilder = + TestDataBuilder().apply { + admin = addUserAccount { username = "public-stats-admin@admin.com" } + + organizationBuilder = admin.defaultOrganizationBuilder + organizationBuilder.self.name = "StatsOrg" + + addProject { + name = "Alpha Public" + public = true + }.build { + addEnglish() + } + + addProject { + name = "Beta Private" + }.build { + addEnglish() + } + } +} diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/ProjectListDashboardE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/ProjectListDashboardE2eDataController.kt index 18ace0ffaaa..246b51bfb49 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/ProjectListDashboardE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/ProjectListDashboardE2eDataController.kt @@ -2,7 +2,7 @@ package io.tolgee.controllers.internal.e2eData import io.tolgee.controllers.internal.InternalController import io.tolgee.development.testDataBuilder.TestDataService -import io.tolgee.development.testDataBuilder.data.ProjectsTestData +import io.tolgee.development.testDataBuilder.data.ProjectsListDashboardTestData import io.tolgee.service.project.ProjectService import io.tolgee.service.security.UserAccountService import org.springframework.transaction.annotation.Transactional @@ -17,7 +17,7 @@ class ProjectListDashboardE2eDataController( @GetMapping(value = ["/generate"]) @Transactional fun generateBasicTestData() { - val data = ProjectsTestData() + val data = ProjectsListDashboardTestData() data.user.username = "projectListDashboardUser" data.user.name = "Test User" testDataService.saveTestData(data.root) @@ -32,7 +32,7 @@ class ProjectListDashboardE2eDataController( } userAccountService.delete(it) } - userAccountService.findActive(ProjectsTestData().userWithTranslatePermission.username)?.let { + userAccountService.findActive(ProjectsListDashboardTestData().userWithTranslatePermission.username)?.let { userAccountService.delete(it) } } diff --git a/e2e/cypress/e2e/projects/projectsListDashboard.cy.ts b/e2e/cypress/e2e/projects/projectsListDashboard.cy.ts index 86bd253e7c2..055cd4c8a27 100644 --- a/e2e/cypress/e2e/projects/projectsListDashboard.cy.ts +++ b/e2e/cypress/e2e/projects/projectsListDashboard.cy.ts @@ -46,6 +46,25 @@ describe('Projects Dashboard', () => { assertTooltip('Deutsch'); }); + it('shows public badge and organization on a public project', () => { + cy.contains('Project 2') + .closestDcy('dashboard-projects-list-item') + .findDcy('project-list-public-badge') + .should('be.visible') + .and('contain', 'public'); + cy.contains('Project 2') + .closestDcy('dashboard-projects-list-item') + .findDcy('project-list-org-name') + .should('contain', 'test_username'); + }); + + it('does not show public badge on a private project', () => { + cy.contains('test_project') + .closestDcy('dashboard-projects-list-item') + .findDcy('project-list-public-badge') + .should('not.exist'); + }); + afterEach(() => { projectListData.clean(); setBypassSeatCountCheck(false); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index fb234e05757..041319d4a9c 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -572,6 +572,8 @@ declare namespace DataCy { "project-leave-button": true; "project-list-languages": true; "project-list-more-button": true; + "project-list-org-name": true; + "project-list-public-badge": true; "project-list-qa-badge-button": true; "project-list-translations-button": true; "project-member-item": true; diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 7e17e0fc6f8..67ae87f0d5e 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -5497,6 +5497,8 @@ export interface components { organizationOwner?: components["schemas"]["SimpleOrganizationModel"]; /** @enum {string} */ organizationRole?: "MEMBER" | "OWNER" | "MAINTAINER"; + /** @description Whether the project is public — discoverable and open to community suggestions */ + public: boolean; slug?: string; stats: components["schemas"]["ProjectStatistics"]; }; diff --git a/webapp/src/views/projects/DashboardProjectListItem.tsx b/webapp/src/views/projects/DashboardProjectListItem.tsx index 2f61793df42..e848d0ffc41 100644 --- a/webapp/src/views/projects/DashboardProjectListItem.tsx +++ b/webapp/src/views/projects/DashboardProjectListItem.tsx @@ -21,6 +21,7 @@ import { useGlobalContext } from 'tg.globalContext/GlobalContext'; import { useEnabledFeatures, useQaCheckTypes } from 'tg.globalContext/helpers'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; import { CircledLanguageIconList } from 'tg.component/languages/CircledLanguageIconList'; +import { TransparentChip } from 'tg.component/common/chips/TransparentChip'; import { QaBadge } from 'tg.ee'; const StyledContainer = styled('div')` @@ -117,6 +118,28 @@ const StyledProjectName = styled(Typography)` word-break: break-word; `; +const StyledPublicLine = styled('div')` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(0.5)}; + overflow: hidden; + margin-top: ${({ theme }) => theme.spacing(0.25)}; +`; + +const StyledPublicChip = styled(TransparentChip)` + flex-shrink: 0; + & .MuiChip-label { + font-size: 13px; + } +`; + +const StyledOrganizationName = styled(Typography)` + color: ${({ theme }) => theme.palette.text.secondary}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + type ProjectWithStatsModel = components['schemas']['ProjectWithStatsModel']; const DashboardProjectListItem = (p: ProjectWithStatsModel) => { @@ -163,6 +186,23 @@ const DashboardProjectListItem = (p: ProjectWithStatsModel) => { {p.name} + {p.public && ( + + + {p.organizationOwner?.name && ( + + {p.organizationOwner.name} + + )} + + )} From 3b5ddf3f48fefea6b549d22328f95b7f6ea60a82 Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Tue, 30 Jun 2026 18:06:25 +0200 Subject: [PATCH 4/8] feat: split suggestion accept into "accept only" and "accept and decline others" (#3776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the Community Translation v1.0 pitch (suggestion resolution split). Stacked on top of `jirikuchynka/public-projects` — merge that first. ## What Splits the single suggestion **Accept** action into two: - **Accept only** — accepts this suggestion and leaves the competing suggestions active (`declineOther=false`). - **Accept and decline others** — accepts this one and declines the rest (`declineOther=true`, the previous behavior). ### UI (decided with design) - Inline row actions: **✓ Accept only** and **✕ Decline** (inactive rows show **↺ Reactivate**). - Overflow **⋮** menu holds the secondary actions: **Accept and decline others** and **Delete** (the latter only on your own suggestion). The menu only renders when at least one of those applies. ## Scope Frontend-only — the backend already supported `declineOther` on the accept endpoint (`SuggestionController.acceptSuggestion`), so no backend, migration, or API-schema changes. ## Notable details - Optimistic cache fix in `applyAcceptedSuggestion`: on accept-only it decrements `activeSuggestionCount` but leaves the single-item preview for the `invalidatePrefix` refetch to reconcile — so the surviving sibling stays visible and no stale count / phantom `+N` chip flashes. Covered by a vitest unit test. - `accept-and-decline-others` is gated on the row being **active** (so it never appears on declined rows in show-all view). ## i18n Two new keys created in the **Tolgee itself** project via the Tolgee API (tagged `draft: suggestion-resolution-split`): `translation_suggestion_accept_only`, `translation_suggestion_accept_and_decline_others`. The previously-unused `translation_suggestion_show_more_tooltip` is referenced again by the kebab. ## Tests - Vitest: `applyAcceptedSuggestion` (6 cases). - Cypress: reworked the suggestion specs for the inline/kebab split (accept-only keeps the sibling, accept-and-decline-others clears it, kebab delete, single-suggestion has no kebab, translator can't accept). ### Known follow-ups - The `active &&` guard's show-all false-branch is not e2e-covered (a 3-active fixture would break the backend `filters by suggestion` test and key-0 count assertions; the cheap route later is a pure action-builder helper unit-tested in vitest). - In-flight `disabled` on menu items not e2e-asserted (flaky to test deterministically). --- .../suggestions/suggestions.reviewer.cy.ts | 144 +++++++++++++----- .../suggestions/suggestions.translator.cy.ts | 19 ++- .../suggestions/suggestions.unprotected.cy.ts | 12 +- .../TranslationSuggestionServiceEeImpl.kt | 3 + .../Suggestions/SuggestionsList.tsx | 24 ++- .../Suggestions/TranslationSuggestion.tsx | 90 ++++++----- .../Suggestions/__tests__/utils.test.ts | 69 +++++++++ .../translations/Suggestions/utils.ts | 37 +++++ 8 files changed, 305 insertions(+), 93 deletions(-) create mode 100644 webapp/src/views/projects/translations/Suggestions/__tests__/utils.test.ts create mode 100644 webapp/src/views/projects/translations/Suggestions/utils.ts diff --git a/e2e/cypress/e2e/projects/suggestions/suggestions.reviewer.cy.ts b/e2e/cypress/e2e/projects/suggestions/suggestions.reviewer.cy.ts index bbda50be919..1a16fd116ed 100644 --- a/e2e/cypress/e2e/projects/suggestions/suggestions.reviewer.cy.ts +++ b/e2e/cypress/e2e/projects/suggestions/suggestions.reviewer.cy.ts @@ -3,6 +3,7 @@ import { suggestionsTestData } from '../../../common/apiCalls/testData/testData' import { waitForGlobalLoading } from '../../../common/loading'; import { assertMessage, + assertNotMessage, gcyAdvanced, visitProjectDashboard, } from '../../../common/shared'; @@ -60,18 +61,49 @@ describe('Suggestions reviewer', () => { cy.gcy('translation-suggestion').contains('návrhů').should('be.visible'); }); - it('reviewer can accept suggestion', () => { - acceptSuggestion(); - assertMessage('Suggestion accepted, other variants declined (1)'); + it('reviewer can accept only, leaving the other suggestion active', () => { + cy.intercept('PUT', /\/suggestion\/\d+\/accept/).as('accept'); + getTranslationCell('key 0', 'cs').click(); + waitForGlobalLoading(); + acceptOnly('Navržený překlad 0-1'); + cy.wait('@accept') + .its('request.url') + .should('not.contain', 'declineOther=true'); + assertMessage('Suggestion accepted'); + assertNotMessage('declined'); + waitForGlobalLoading(); + getTranslationCell('key 0', 'cs').click(); + cy.gcy('suggestions-list') + .findDcy('translation-panel-items-count') + .should('contain', '1'); + cy.gcy('suggestions-list') + .findDcy('translation-suggestion') + .contains('Navržený překlad 0-2') + .should('exist'); + cy.gcy('suggestions-list') + .findDcy('translation-suggestion') + .contains('Navržený překlad 0-1') + .should('not.exist'); }); it('accepted suggestion stays reviewed', () => { - acceptSuggestion(); + getTranslationCell('key 0', 'cs').click(); + waitForGlobalLoading(); + acceptOnly('Navržený překlad 0-1'); + waitForGlobalLoading(); assertHasState('Navržený překlad 0-1', 'Reviewed'); }); - it('acceptation of suggestion declines other suggestions', () => { - acceptSuggestion(); + it('accept and decline others declines the rest', () => { + cy.intercept('PUT', /\/suggestion\/\d+\/accept/).as('accept'); + getTranslationCell('key 0', 'cs').click(); + waitForGlobalLoading(); + openRowMenu('Navržený překlad 0-1', 'accept-decline-others'); + cy.wait('@accept') + .its('request.url') + .should('contain', 'declineOther=true'); + assertMessage('Suggestion accepted, other variants declined (1)'); + waitForGlobalLoading(); getTranslationCell('key 0', 'cs').click(); cy.gcy('suggestions-list') .findDcy('translation-panel-items-count') @@ -79,7 +111,10 @@ describe('Suggestions reviewer', () => { }); it('reviewer can reverse inactive suggestion', () => { - acceptSuggestion(); + getTranslationCell('key 0', 'cs').click(); + waitForGlobalLoading(); + openRowMenu('Navržený překlad 0-1', 'accept-decline-others'); + waitForGlobalLoading(); getTranslationCell('key 0', 'cs').click(); cy.gcy('suggestions-list') .findDcy('translation-tools-suggestions-show-all-checkbox') @@ -108,16 +143,33 @@ describe('Suggestions reviewer', () => { it('reviewer can decline suggestion', () => { getTranslationCell('key 0', 'cs').click(); waitForGlobalLoading(); - gcyAdvanced({ - value: 'suggestion-action', - action: 'decline', - }) - .first() - .click(); + cy.gcy('suggestions-list') + .findDcy('translation-suggestion') + .should('have.length', 2); + cy.gcy('translation-suggestion') + .contains('Navržený překlad 0-1') + .closest('[data-cy="translation-suggestion"]') + .within(() => { + gcyAdvanced({ value: 'suggestion-action', action: 'decline' }).click(); + }); waitForGlobalLoading(); cy.gcy('suggestions-list') .findDcy('translation-suggestion') - .should('have.length', 1); + .should('have.length', 1) + .within(() => { + gcyAdvanced({ value: 'suggestion-action', action: 'accept' }).should( + 'exist' + ); + gcyAdvanced({ value: 'suggestion-action', action: 'menu' }).click(); + }); + gcyAdvanced({ + value: 'translation-suggestion-action-menu-item', + action: 'delete', + }).should('exist'); + gcyAdvanced({ + value: 'translation-suggestion-action-menu-item', + action: 'accept-decline-others', + }).should('not.exist'); visitProjectDashboard(projectId); gcyAdvanced({ @@ -129,12 +181,7 @@ describe('Suggestions reviewer', () => { it('reviewer can delete his own suggestion', () => { getTranslationCell('key 0', 'cs').click(); waitForGlobalLoading(); - gcyAdvanced({ - value: 'suggestion-action', - action: 'delete', - }) - .should('exist') - .click(); + openRowMenu('Navržený překlad 0-2', 'delete'); waitForGlobalLoading(); cy.gcy('translation-suggestion') .contains('Navržený překlad 0-2') @@ -150,15 +197,7 @@ describe('Suggestions reviewer', () => { it('reviewer can accept his own suggestion', () => { getTranslationCell('key 0', 'cs').click(); waitForGlobalLoading(); - gcyAdvanced({ - value: 'suggestion-action', - action: 'menu', - }) - .should('exist') - .click(); - cy.gcy('translation-suggestion-action-menu-item') - .contains('Accept suggestion') - .click(); + openRowMenu('Navržený překlad 0-2', 'accept-decline-others'); waitForGlobalLoading(); cy.gcy('translation-suggestion') .contains('Navržený překlad 0-2') @@ -172,15 +211,46 @@ describe('Suggestions reviewer', () => { }).should('contain', 'Navržený překlad 0-1'); }); - function acceptSuggestion() { - getTranslationCell('key 0', 'cs').click(); + it('single active suggestion shows accept inline without the overflow menu', () => { + getTranslationCell('pluralKey', 'cs').click(); waitForGlobalLoading(); + cy.gcy('suggestions-list') + .findDcy('translation-suggestion') + .should('have.length', 1) + .within(() => { + gcyAdvanced({ value: 'suggestion-action', action: 'accept' }).should( + 'exist' + ); + gcyAdvanced({ value: 'suggestion-action', action: 'menu' }).should( + 'not.exist' + ); + gcyAdvanced({ value: 'suggestion-action', action: 'accept' }).click(); + }); + assertMessage('Suggestion accepted'); + }); + + function acceptOnly(text: string) { + cy.gcy('translation-suggestion') + .contains(text) + .closest('[data-cy="translation-suggestion"]') + .within(() => { + gcyAdvanced({ value: 'suggestion-action', action: 'accept' }).click(); + }); + } + + function openRowMenu( + text: string, + action: 'accept-decline-others' | 'delete' + ) { + cy.gcy('translation-suggestion') + .contains(text) + .closest('[data-cy="translation-suggestion"]') + .within(() => { + gcyAdvanced({ value: 'suggestion-action', action: 'menu' }).click(); + }); gcyAdvanced({ - value: 'suggestion-action', - action: 'accept', - }) - .first() - .click(); - waitForGlobalLoading(); + value: 'translation-suggestion-action-menu-item', + action, + }).click(); } }); diff --git a/e2e/cypress/e2e/projects/suggestions/suggestions.translator.cy.ts b/e2e/cypress/e2e/projects/suggestions/suggestions.translator.cy.ts index 3c2d237a3c2..cfa480384dc 100644 --- a/e2e/cypress/e2e/projects/suggestions/suggestions.translator.cy.ts +++ b/e2e/cypress/e2e/projects/suggestions/suggestions.translator.cy.ts @@ -58,26 +58,29 @@ describe('Suggestions translator', () => { visitTranslations(projectId); getTranslationCell('key 0', 'cs').click(); waitForGlobalLoading(); + cy.gcy('suggestions-list') + .findDcy('translation-suggestion') + .should('have.length', 2); gcyAdvanced({ value: 'suggestion-action', action: 'accept', }).should('not.exist'); - gcyAdvanced({ - value: 'suggestion-action', - action: 'menu', - }).should('not.exist'); }); it('translator can delete his own suggestion', () => { visitTranslations(projectId); getTranslationCell('key 0', 'cs').click(); waitForGlobalLoading(); + cy.gcy('translation-suggestion') + .contains('Navržený překlad 0-1') + .closest('[data-cy="translation-suggestion"]') + .within(() => { + gcyAdvanced({ value: 'suggestion-action', action: 'menu' }).click(); + }); gcyAdvanced({ - value: 'suggestion-action', + value: 'translation-suggestion-action-menu-item', action: 'delete', - }) - .should('exist') - .click(); + }).click(); waitForGlobalLoading(); cy.gcy('translation-suggestion') .contains('Navržený překlad 0-1') diff --git a/e2e/cypress/e2e/projects/suggestions/suggestions.unprotected.cy.ts b/e2e/cypress/e2e/projects/suggestions/suggestions.unprotected.cy.ts index e47ae160b9d..762696ba09c 100644 --- a/e2e/cypress/e2e/projects/suggestions/suggestions.unprotected.cy.ts +++ b/e2e/cypress/e2e/projects/suggestions/suggestions.unprotected.cy.ts @@ -45,12 +45,12 @@ describe('Suggestions in when translations are not protected', () => { visitTranslations(projectId); getTranslationCell('key 0', 'cs').click(); waitForGlobalLoading(); - gcyAdvanced({ - value: 'suggestion-action', - action: 'accept', - }) - .first() - .click(); + cy.gcy('translation-suggestion') + .contains('Navržený překlad 0-1') + .closest('[data-cy="translation-suggestion"]') + .within(() => { + gcyAdvanced({ value: 'suggestion-action', action: 'accept' }).click(); + }); waitForGlobalLoading(); assertHasState('Navržený překlad 0-1', 'Reviewed'); }); diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt index 45858862215..b3d0fcd0aa3 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt @@ -140,6 +140,9 @@ class TranslationSuggestionServiceEeImpl( return suggestion } + // No row lock / @Version / ACTIVE precondition here: two moderators accepting different suggestions + // on the same key+language concurrently can both end ACCEPTED (last accept wins the live translation). + // Do not assume an at-most-one-ACCEPTED invariant holds. @Transactional fun acceptSuggestion( projectId: Long, diff --git a/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx b/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx index f8a8e4d1cb9..3696111fcd7 100644 --- a/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx +++ b/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx @@ -12,6 +12,7 @@ import { useInfiniteSuggestions } from './useInfiniteSuggestions'; import { LabelHint } from 'tg.component/common/LabelHint'; import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { useUser } from 'tg.globalContext/helpers'; +import { applyAcceptedSuggestion } from './utils'; const FETCH_NEXT_PAGE_SCROLL_THRESHOLD_IN_PIXELS = 220; @@ -156,6 +157,10 @@ export const SuggestionsList = ({ const suggestionsLoadable = showAll && allLoaded ? allSuggestions : activeSuggestions; + const activeCount = + activeSuggestions.data?.pages?.[0]?.page?.totalElements ?? + translation.activeSuggestionCount; + function handleDecline(suggestionId: number) { declineLoadable.mutate({ path: { projectId, keyId, languageId, suggestionId }, @@ -189,9 +194,10 @@ export const SuggestionsList = ({ data(translation) { return { ...translation, - text: data.accepted.translation, - suggestions: [], - activeSuggestionCount: 0, + ...applyAcceptedSuggestion(translation, { + acceptedTranslation: data.accepted.translation, + declinedCount: data.declined.length, + }), }; }, }); @@ -211,8 +217,11 @@ export const SuggestionsList = ({ } function handleAccept(suggestionId: number) { - const firstPage = activeSuggestions.data?.pages?.[0]; - acceptSuggestion(suggestionId, (firstPage?.page?.totalElements ?? 0) > 1); + acceptSuggestion(suggestionId, false); + } + + function handleAcceptAndDeclineOthers(suggestionId: number) { + acceptSuggestion(suggestionId, true); } const handleFetchMore = () => { @@ -310,6 +319,11 @@ export const SuggestionsList = ({ onAccept={ canReview ? () => handleAccept(item.id) : undefined } + onAcceptAndDeclineOthers={ + canReview && activeCount > 1 + ? () => handleAcceptAndDeclineOthers(item.id) + : undefined + } onDecline={ canReview ? () => handleDecline(item.id) : undefined } diff --git a/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx b/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx index 3d107a5b9ec..27d7b780f71 100644 --- a/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx +++ b/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx @@ -14,6 +14,7 @@ import { components } from 'tg.service/apiSchema.generated'; import { useTimeDistance } from 'tg.hooks/useTimeDistance'; import { Check, + CheckDone01, DotsVertical, ReverseLeft, Trash01, @@ -99,6 +100,7 @@ type Props = { isLoading?: boolean; onDecline?: () => void; onAccept?: () => void; + onAcceptAndDeclineOthers?: () => void; onDelete?: () => void; onReverse?: () => void; sx?: SxProps; @@ -110,6 +112,7 @@ export const TranslationSuggestion = ({ locale, maxLines = 3, onAccept, + onAcceptAndDeclineOthers, onDecline, onDelete, onReverse, @@ -125,33 +128,16 @@ export const TranslationSuggestion = ({ const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); - const actions: ActionItem[] = []; + const active = suggestion.state === 'ACTIVE'; - if (onDelete && suggestion.author.id === user?.id) { - actions.push({ - action: 'delete', - label: t('translation_suggestion_delete_tooltip'), - onClick: onDelete, - icon: Trash01, - color: theme.palette.tokens.error.main, - disabled: isLoading, - }); - } - if (suggestion.state !== 'ACTIVE') { - if (onReverse) { - actions.push({ - action: 'reverse', - label: t('translation_suggestion_reverse_tooltip'), - onClick: onReverse, - icon: ReverseLeft, - disabled: isLoading, - }); - } - } else { + const inlineActions: ActionItem[] = []; + if (active) { if (onAccept) { - actions.push({ + inlineActions.push({ action: 'accept', - label: t('translation_suggestion_accept_tooltip'), + label: onAcceptAndDeclineOthers + ? t('translation_suggestion_accept_only') + : t('translation_suggestion_accept_tooltip'), onClick: onAccept, icon: Check, color: theme.palette.tokens.success.main, @@ -159,7 +145,7 @@ export const TranslationSuggestion = ({ }); } if (onDecline) { - actions.push({ + inlineActions.push({ action: 'decline', label: t('translation_suggestion_decline_tooltip'), onClick: onDecline, @@ -167,18 +153,45 @@ export const TranslationSuggestion = ({ disabled: isLoading, }); } + } else if (onReverse) { + inlineActions.push({ + action: 'reverse', + label: t('translation_suggestion_reverse_tooltip'), + onClick: onReverse, + icon: ReverseLeft, + disabled: isLoading, + }); } - const showMenu = actions.length > 2; - const regularItems = showMenu ? actions.slice(0, 1) : actions; - const menuItems = showMenu ? actions.slice(1) : []; - const active = suggestion.state === 'ACTIVE'; + const menuItems: ActionItem[] = []; + if (active && onAcceptAndDeclineOthers) { + menuItems.push({ + action: 'accept-decline-others', + label: t('translation_suggestion_accept_and_decline_others'), + onClick: onAcceptAndDeclineOthers, + icon: CheckDone01, + disabled: isLoading, + }); + } + if (onDelete && suggestion.author.id === user?.id) { + menuItems.push({ + action: 'delete', + label: t('translation_suggestion_delete_tooltip'), + onClick: onDelete, + icon: Trash01, + color: theme.palette.tokens.error.main, + disabled: isLoading, + }); + } + + const hasActions = inlineActions.length > 0 || menuItems.length > 0; + return ( {formatDate(lastUpdated)} - {regularItems.map((action, i) => ( + {inlineActions.map((action, i) => ( ))} - {showMenu && ( + {menuItems.length > 0 && ( <> setMenuOpen(true)} ref={menuRef} action="menu" @@ -238,21 +252,23 @@ export const TranslationSuggestion = ({ anchorEl={menuRef.current} onClose={() => setMenuOpen(false)} > - {menuItems.map((action, i) => { - const Icon = action.icon; + {menuItems.map((item, i) => { + const Icon = item.icon; return ( { - action.onClick(); + item.onClick(); setMenuOpen(false); }} - sx={{ color: action.color }} + disabled={item.disabled} + sx={{ color: item.color }} data-cy="translation-suggestion-action-menu-item" + data-cy-action={item.action} > - {action.label} + {item.label} ); diff --git a/webapp/src/views/projects/translations/Suggestions/__tests__/utils.test.ts b/webapp/src/views/projects/translations/Suggestions/__tests__/utils.test.ts new file mode 100644 index 00000000000..c75d9eea943 --- /dev/null +++ b/webapp/src/views/projects/translations/Suggestions/__tests__/utils.test.ts @@ -0,0 +1,69 @@ +import { applyAcceptedSuggestion } from '../utils'; + +describe('applyAcceptedSuggestion', () => { + it('keeps the preview untouched and decrements the count when siblings remain', () => { + const result = applyAcceptedSuggestion( + { activeSuggestionCount: 2 }, + { acceptedTranslation: 'accepted', declinedCount: 0 } + ); + expect(result).toEqual({ text: 'accepted', activeSuggestionCount: 1 }); + expect(result).not.toHaveProperty('suggestions'); + }); + + it('clears the preview and count when it was the only active suggestion', () => { + const result = applyAcceptedSuggestion( + { activeSuggestionCount: 1 }, + { acceptedTranslation: 'accepted', declinedCount: 0 } + ); + expect(result).toEqual({ + text: 'accepted', + suggestions: [], + activeSuggestionCount: 0, + }); + }); + + it('clears the preview and count when the rest were declined', () => { + const result = applyAcceptedSuggestion( + { activeSuggestionCount: 2 }, + { acceptedTranslation: 'accepted', declinedCount: 1 } + ); + expect(result).toEqual({ + text: 'accepted', + suggestions: [], + activeSuggestionCount: 0, + }); + }); + + it('clamps a stale-cache over-count to zero rather than going negative', () => { + const result = applyAcceptedSuggestion( + { activeSuggestionCount: 2 }, + { acceptedTranslation: 'accepted', declinedCount: 3 } + ); + expect(result).toEqual({ + text: 'accepted', + suggestions: [], + activeSuggestionCount: 0, + }); + }); + + it('treats a missing activeSuggestionCount as zero', () => { + const result = applyAcceptedSuggestion( + {}, + { acceptedTranslation: 'accepted', declinedCount: 0 } + ); + expect(result).toEqual({ + text: 'accepted', + suggestions: [], + activeSuggestionCount: 0, + }); + }); + + it('decrements correctly when siblings remain after declines (keep-branch arithmetic)', () => { + const result = applyAcceptedSuggestion( + { activeSuggestionCount: 5 }, + { acceptedTranslation: 'accepted', declinedCount: 2 } + ); + expect(result).toEqual({ text: 'accepted', activeSuggestionCount: 2 }); + expect(result).not.toHaveProperty('suggestions'); + }); +}); diff --git a/webapp/src/views/projects/translations/Suggestions/utils.ts b/webapp/src/views/projects/translations/Suggestions/utils.ts new file mode 100644 index 00000000000..0f51c6501d2 --- /dev/null +++ b/webapp/src/views/projects/translations/Suggestions/utils.ts @@ -0,0 +1,37 @@ +import { components } from 'tg.service/apiSchema.generated'; + +type TranslationViewModel = components['schemas']['TranslationViewModel']; + +type AcceptedSuggestionUpdate = Pick & + Partial>; + +/** + * `translation.suggestions` is only a capped display preview (up to + * MAX_DISPLAYED_SUGGESTIONS); the real total lives in `activeSuggestionCount`. + * When siblings remain, decrement the count + * alone — do NOT re-sync the preview array to it (the surviving sibling can't be + * derived here; the refetch reconciles it). Clear the preview only when none remain. + */ +export function applyAcceptedSuggestion( + translation: { activeSuggestionCount?: number }, + { + acceptedTranslation, + declinedCount, + }: { + acceptedTranslation: string | undefined; + declinedCount: number; + } +): AcceptedSuggestionUpdate { + const remainingActive = Math.max( + 0, + (translation.activeSuggestionCount ?? 0) - 1 - declinedCount + ); + if (remainingActive === 0) { + return { + text: acceptedTranslation, + suggestions: [], + activeSuggestionCount: 0, + }; + } + return { text: acceptedTranslation, activeSuggestionCount: remainingActive }; +} From e88e0150d892e395898c677255163e48c9e7d28f Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Wed, 1 Jul 2026 21:16:36 +0200 Subject: [PATCH 5/8] feat: show up to 3 translation suggestions per cell by default (#3774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Part of the Community Translation v1.0 pitch (suggestion behavior changes). The translations list read view showed a single suggestion per cell; it now shows **up to 3** by default, keeping the existing **`+N`** overflow badge as the indicator (no new control). Targets `jirikuchynka/public-projects` to be folded into that PR. ## Changes **Backend** - `TranslationSuggestionRepository.getByKeyId` swaps `DISTINCT ON` (1 row/group) for a `row_number()` windowed top-N (`rn <= :maxPerKey`) with an explicit outer `ORDER BY` — the window `OVER(...)` order only assigns `row_number()`, not the delivery order. Ordering is `created_at desc, id desc`. - `MAX_DISPLAYED_SUGGESTIONS = 3` constant in `TranslationSuggestionServiceEeImpl` drives the cap. - No new index: the `WHERE` predicate is unchanged from the previous query and the existing `(project_id, language_id, key_id)` composite index covers it. **Frontend** - The read view (`SuggestionsFirst`) renders up to 3 and self-caps in render; the `+N` badge is guarded non-negative. - The cell cache stays at 3 across the editor **open/close**, **mutate**, and **suggest-create** paths (the suggest path previously collapsed the stack to 1). - The editor suggestion query gains an `id,desc` sort tiebreaker so its order matches the embed. ## Tests - Backend: embed cap (3) + `activeSuggestionCount`, per-`(key, language)` partition isolation, cap-by-constant, and a dedicated `created_at desc` primary-sort test (created via API at non-id-monotonic timestamps so id-order and createdAt-order disagree). - Cypress: read-cell 3-stack + `+N`, open/close, suggest-keeps-3, decline-then-resync, and a table-view check. Backend test suite green; frontend `eslint`/`tsc` green for `webapp` and `e2e`. --- .../TranslationsControllerFilterTest.kt | 4 +- .../data/SuggestionsTestData.kt | 18 +++ .../suggestions/suggestions.reviewer.cy.ts | 36 ++++++ .../suggestions/suggestions.translator.cy.ts | 98 +++++++++++++++++ .../translationFilters.suggestions.cy.ts | 4 +- e2e/cypress/support/dataCyType.d.ts | 1 + .../TranslationSuggestionRepository.kt | 53 +++++---- .../TranslationSuggestionServiceEeImpl.kt | 7 +- .../controllers/SuggestionControllerTest.kt | 103 ++++++++++++++++++ .../Suggestions/SuggestionsFirst.tsx | 81 ++++++++------ .../Suggestions/SuggestionsList.tsx | 9 +- .../Suggestions/useInfiniteSuggestions.ts | 2 +- .../TranslationsList/TranslationRead.tsx | 1 + .../TranslationsTable/TranslationRead.tsx | 1 + .../TranslationsTable/TranslationWrite.tsx | 19 ++++ .../context/services/useEditService.tsx | 14 ++- 16 files changed, 383 insertions(+), 68 deletions(-) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerFilterTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerFilterTest.kt index 83efaae2e99..6a26d3656c5 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerFilterTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerFilterTest.kt @@ -631,7 +631,7 @@ class TranslationsControllerFilterTest : ProjectAuthControllerTest("/v2/projects "/translations?filterHasSuggestionsInLang=${testData.czechLanguage.tag}", ).andIsOk.andAssertThatJson { node("_embedded.keys") { - isArray.hasSize(2) + isArray.hasSize(4) node("[0].keyName").isEqualTo("key 0") } } @@ -640,7 +640,7 @@ class TranslationsControllerFilterTest : ProjectAuthControllerTest("/v2/projects "/translations?filterHasNoSuggestionsInLang=${testData.czechLanguage.tag}", ).andIsOk.andAssertThatJson { node("_embedded.keys") { - isArray.hasSize(3) + isArray.hasSize(1) node("[0].keyName").isEqualTo("key 1") } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt index 4e6a97c1768..a8b137e313a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt @@ -185,6 +185,24 @@ class SuggestionsTestData( } } + keys[2].apply { + (1..4).forEach { i -> + addSuggestion { + this.language = czechLanguage + this.author = projectTranslator.self + this.translation = "Many suggestion 2-$i" + } + } + } + + keys[3].apply { + addSuggestion { + this.language = czechLanguage + this.author = projectTranslator.self + this.translation = "Only suggestion 3-1" + } + } + pluralKey = addKey(null, "pluralKey").apply { self.isPlural = true diff --git a/e2e/cypress/e2e/projects/suggestions/suggestions.reviewer.cy.ts b/e2e/cypress/e2e/projects/suggestions/suggestions.reviewer.cy.ts index 1a16fd116ed..b76aa7226bc 100644 --- a/e2e/cypress/e2e/projects/suggestions/suggestions.reviewer.cy.ts +++ b/e2e/cypress/e2e/projects/suggestions/suggestions.reviewer.cy.ts @@ -229,6 +229,42 @@ describe('Suggestions reviewer', () => { assertMessage('Suggestion accepted'); }); + it('read cell shows the next correct 3 suggestions after one is declined', () => { + getTranslationCell('key 2', 'cs') + .findDcy('translation-suggestion') + .should('have.length', 3); + getTranslationCell('key 2', 'cs').within(() => { + cy.gcy('suggestions-show-all').should('be.visible'); + }); + getTranslationCell('key 2', 'cs').click(); + waitForGlobalLoading(); + cy.gcy('suggestions-list') + .findDcy('translation-suggestion') + .first() + .should('contain', 'Many suggestion 2-4'); + gcyAdvanced({ value: 'suggestion-action', action: 'decline' }) + .first() + .click(); + waitForGlobalLoading(); + visitTranslations(projectId); + waitForGlobalLoading(); + getTranslationCell('key 2', 'cs') + .findDcy('translation-suggestion') + .should('have.length', 3); + getTranslationCell('key 2', 'cs').within(() => { + cy.gcy('translation-suggestion') + .eq(0) + .should('contain', 'Many suggestion 2-3'); + cy.gcy('translation-suggestion') + .eq(1) + .should('contain', 'Many suggestion 2-2'); + cy.gcy('translation-suggestion') + .eq(2) + .should('contain', 'Many suggestion 2-1'); + cy.gcy('suggestions-show-all').should('not.exist'); + }); + }); + function acceptOnly(text: string) { cy.gcy('translation-suggestion') .contains(text) diff --git a/e2e/cypress/e2e/projects/suggestions/suggestions.translator.cy.ts b/e2e/cypress/e2e/projects/suggestions/suggestions.translator.cy.ts index cfa480384dc..0064e9aa020 100644 --- a/e2e/cypress/e2e/projects/suggestions/suggestions.translator.cy.ts +++ b/e2e/cypress/e2e/projects/suggestions/suggestions.translator.cy.ts @@ -3,6 +3,7 @@ import { suggestionsTestData } from '../../../common/apiCalls/testData/testData' import { waitForGlobalLoading } from '../../../common/loading'; import { gcyAdvanced } from '../../../common/shared'; import { + getCellCancelButton, getPluralEditor, getTranslationCell, visitTranslations, @@ -86,4 +87,101 @@ describe('Suggestions translator', () => { .contains('Navržený překlad 0-1') .should('not.exist'); }); + + it('read cell shows the newest 3 suggestions + a "Show all" line, and keeps them after the editor opens and closes', () => { + visitTranslations(projectId); + assertReadCellSuggestions(['2-4', '2-3', '2-2']); + getTranslationCell('key 2', 'cs') + .findDcy('suggestions-show-all') + .should('be.visible'); + getTranslationCell('key 2', 'cs').click(); + waitForGlobalLoading(); + getCellCancelButton().click(); + waitForGlobalLoading(); + assertReadCellSuggestions(['2-4', '2-3', '2-2']); + }); + + it('"Show all" opens the editor and reveals every active suggestion', () => { + visitTranslations(projectId); + getTranslationCell('key 2', 'cs').findDcy('suggestions-show-all').click(); + waitForGlobalLoading(); + cy.gcy('suggestions-list') + .findDcy('translation-suggestion') + .should('have.length', 4); + cy.gcy('suggestions-list').should('contain', 'Many suggestion 2-1'); + }); + + it('a view-only user sees the 3 previews but no "Show all" line (cannot open the cell)', () => { + login('view.only@test.com'); + visitTranslations(projectId); + assertReadCellSuggestions(['2-4', '2-3', '2-2']); + getTranslationCell('key 2', 'cs') + .findDcy('suggestions-show-all') + .should('not.exist'); + }); + + it('keeps up to 3 read-cell suggestions after suggesting on a cell that already has some', () => { + visitTranslations(projectId); + getTranslationCell('key 2', 'cs').click(); + cy.gcy('global-editor').clear().type('Brand new suggestion'); + cy.gcy('translations-cell-main-action-button') + .should('contain', 'Suggest') + .click(); + waitForGlobalLoading(); + getTranslationCell('key 2', 'cs') + .findDcy('translation-suggestion') + .should('have.length', 3); + getTranslationCell('key 2', 'cs').within(() => { + cy.gcy('translation-suggestion') + .eq(0) + .should('contain', 'Brand new suggestion'); + cy.gcy('translation-suggestion') + .eq(1) + .should('contain', 'Many suggestion 2-4'); + cy.gcy('translation-suggestion') + .eq(2) + .should('contain', 'Many suggestion 2-3'); + }); + }); + + it('shows no "Show all" line when the cell has 3 or fewer suggestions', () => { + visitTranslations(projectId); + getTranslationCell('key 3', 'cs') + .findDcy('translation-suggestion') + .should('have.length', 1); + getTranslationCell('key 3', 'cs') + .findDcy('suggestions-show-all') + .should('not.exist'); + }); + + it('renders the 3-suggestion stack in table view too', () => { + visitTranslations(projectId); + cy.gcy('translations-view-table-button').click(); + waitForGlobalLoading(); + assertReadCellSuggestions(['2-4', '2-3', '2-2']); + }); + + it('"Show all" opens the editor from table view too', () => { + visitTranslations(projectId); + cy.gcy('translations-view-table-button').click(); + waitForGlobalLoading(); + getTranslationCell('key 2', 'cs').findDcy('suggestions-show-all').click(); + waitForGlobalLoading(); + cy.gcy('suggestions-list') + .findDcy('translation-suggestion') + .should('have.length', 4); + }); + + function assertReadCellSuggestions(suffixes: string[]) { + getTranslationCell('key 2', 'cs') + .findDcy('translation-suggestion') + .should('have.length', suffixes.length); + getTranslationCell('key 2', 'cs').within(() => { + suffixes.forEach((suffix, index) => { + cy.gcy('translation-suggestion') + .eq(index) + .should('contain', `Many suggestion ${suffix}`); + }); + }); + } }); diff --git a/e2e/cypress/e2e/translations/translationFilters.suggestions.cy.ts b/e2e/cypress/e2e/translations/translationFilters.suggestions.cy.ts index 740d1346e78..282a2bdaacd 100644 --- a/e2e/cypress/e2e/translations/translationFilters.suggestions.cy.ts +++ b/e2e/cypress/e2e/translations/translationFilters.suggestions.cy.ts @@ -30,7 +30,7 @@ describe('Translation filters suggestions', () => { assertFilter({ submenu: 'Suggestions', filterOption: ['With suggestions'], - toSeeAfter: ['key 0', 'pluralKey'], + toSeeAfter: ['key 0', 'key 2', 'key 3', 'pluralKey'], checkAfter() { cy.gcy('translations-filter-select').contains('With suggestions'); }, @@ -41,7 +41,7 @@ describe('Translation filters suggestions', () => { assertFilter({ submenu: 'Suggestions', filterOption: ['No suggestions'], - toSeeAfter: ['key 1', 'key 2', 'key 3'], + toSeeAfter: ['key 1'], checkAfter() { cy.gcy('translations-filter-select').contains('No suggestions'); }, diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 041319d4a9c..0a7450d5c4f 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -724,6 +724,7 @@ declare namespace DataCy { "submenu-item": true; "suggestion-action": true; "suggestions-list": true; + "suggestions-show-all": true; "switch-popover-item": true; "switch-popover-new": true; "switch-popover-search": true; diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TranslationSuggestionRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TranslationSuggestionRepository.kt index a0071dfb2e1..914f3e55682 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TranslationSuggestionRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TranslationSuggestionRepository.kt @@ -13,28 +13,36 @@ import org.springframework.stereotype.Repository interface TranslationSuggestionRepository : JpaRepository { @Query( """ - select distinct on (ts.language_id, ts.key_id) - ts.id as id, - ts.key_id as keyId, - ts.language_id as languageId, - l.tag as languageTag, - ts.translation as translation, - ts.state as state, - ts.is_plural as plural, - - u.id as authorId, - u.name as authorName, - u.username as authorUsername, - u.avatar_hash as authorAvatarHash, - u.deleted_at as authorDeletedAt - from translation_suggestion ts - left join language l on l.id = ts.language_id - left join user_account u on u.id = ts.author_id - where ts.key_id in :keyIds - and ts.project_id = :projectId - and ts.language_id in :languageIds - and ts.state = 'ACTIVE' - order by ts.language_id, ts.key_id, ts.created_at DESC + select id, keyId, languageId, languageTag, translation, state, plural, + authorId, authorName, authorUsername, authorAvatarHash, authorDeletedAt + from ( + select + ts.id as id, + ts.key_id as keyId, + ts.language_id as languageId, + l.tag as languageTag, + ts.translation as translation, + ts.state as state, + ts.is_plural as plural, + u.id as authorId, + u.name as authorName, + u.username as authorUsername, + u.avatar_hash as authorAvatarHash, + u.deleted_at as authorDeletedAt, + row_number() over ( + partition by ts.language_id, ts.key_id + order by ts.created_at desc, ts.id desc + ) as rn + from translation_suggestion ts + left join language l on l.id = ts.language_id + left join user_account u on u.id = ts.author_id + where ts.key_id in :keyIds + and ts.project_id = :projectId + and ts.language_id in :languageIds + and ts.state = 'ACTIVE' + ) sub + where sub.rn <= :maxPerKey + order by sub.languageId, sub.keyId, sub.rn """, nativeQuery = true, ) @@ -42,6 +50,7 @@ interface TranslationSuggestionRepository : JpaRepository, keyIds: List, + maxPerKey: Int, ): List @Query( diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt index b3d0fcd0aa3..287aae43553 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt @@ -44,7 +44,7 @@ class TranslationSuggestionServiceEeImpl( keyIds: List, languageIds: List, ): Map, List> { - val data = translationSuggestionRepository.getByKeyId(projectId, languageIds, keyIds) + val data = translationSuggestionRepository.getByKeyId(projectId, languageIds, keyIds, MAX_DISPLAYED_SUGGESTIONS) val result = mutableMapOf, MutableList>() data.forEach { val pair = Pair(it.keyId, it.languageTag) @@ -240,4 +240,9 @@ class TranslationSuggestionServiceEeImpl( throw BadRequestException(Message.TRANSLATION_TEXT_TOO_LONG, listOf(tolgeeProperties.maxTranslationTextLength)) } } + + companion object { + // Mirror of the frontend MAX_DISPLAYED_SUGGESTIONS (SuggestionsFirst.tsx) — both must change together. + const val MAX_DISPLAYED_SUGGESTIONS = 3 + } } diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt index 7cd9aaa54b4..a0d8e234af0 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt @@ -15,9 +15,11 @@ import io.tolgee.model.enums.SuggestionsMode import io.tolgee.model.enums.TranslationSuggestionState import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assert +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import java.math.BigDecimal +import java.util.Date class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { lateinit var testData: SuggestionsTestData @@ -25,6 +27,11 @@ class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { @Autowired private lateinit var translationSuggestionRepository: TranslationSuggestionRepository + @AfterEach + fun resetClock() { + clearForcedDate() + } + fun initTestData(suggestionsMode: SuggestionsMode = SuggestionsMode.ENABLED) { testData = SuggestionsTestData(suggestionsMode) projectSupplier = { testData.relatedProject.self } @@ -52,6 +59,102 @@ class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { } } + @Test + @ProjectJWTAuthTestMethod + fun `embeds up to MAX_DISPLAYED_SUGGESTIONS newest suggestions per cell in the translations view`() { + initTestData() + val manyKey = testData.keys[2].self + val oneKey = testData.keys[3].self + val twoKey = testData.keys[0].self + performProjectAuthGet( + "/translations?sort=id" + + "&filterKeyId=${twoKey.id}&filterKeyId=${manyKey.id}&filterKeyId=${oneKey.id}", + ).andIsOk + .andAssertThatJson { + node("_embedded.keys[1].translations.cs") { + node("suggestions").isArray.hasSize(3) + node("suggestions[0].translation").isEqualTo("Many suggestion 2-4") + node("suggestions[1].translation").isEqualTo("Many suggestion 2-3") + node("suggestions[2].translation").isEqualTo("Many suggestion 2-2") + node("activeSuggestionCount").isNumber.isEqualTo(BigDecimal(4)) + } + node("_embedded.keys[2].translations.cs") { + node("suggestions").isArray.hasSize(1) + node("suggestions[0].translation").isEqualTo("Only suggestion 3-1") + node("activeSuggestionCount").isNumber.isEqualTo(BigDecimal(1)) + } + node("_embedded.keys[0].translations.cs") { + node("suggestions").isArray.hasSize(2) + node("activeSuggestionCount").isNumber.isEqualTo(BigDecimal(2)) + } + node("_embedded.keys[0].translations.en") { + node("suggestions").isArray.hasSize(2) + node("activeSuggestionCount").isNumber.isEqualTo(BigDecimal(2)) + } + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `breaks createdAt ties by id desc in the embed`() { + initTestData() + val key = testData.keys[1].self + // both suggestions share one forced createdAt, so only the id desc tiebreaker can order them + setForcedDate(currentDateProvider.date) + performProjectAuthPost( + "languages/${testData.czechLanguage.id}/key/${key.id}/suggestion", + CreateTranslationSuggestionRequest(translation = "cs lower id"), + ).andIsOk + performProjectAuthPost( + "languages/${testData.czechLanguage.id}/key/${key.id}/suggestion", + CreateTranslationSuggestionRequest(translation = "cs higher id"), + ).andIsOk + + performProjectAuthGet("/translations?sort=id&filterKeyId=${key.id}") + .andIsOk + .andAssertThatJson { + node("_embedded.keys[0].translations.cs.suggestions") { + isArray.hasSize(2) + node("[0].translation").isEqualTo("cs higher id") + node("[1].translation").isEqualTo("cs lower id") + } + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `embeds suggestions ordered by createdAt desc, not by id`() { + initTestData() + val key = testData.keys[1].self + val base = currentDateProvider.date.time + + fun createAt( + offsetMillis: Long, + translation: String, + ) { + setForcedDate(Date(base + offsetMillis)) + performProjectAuthPost( + "languages/${testData.czechLanguage.id}/key/${key.id}/suggestion", + CreateTranslationSuggestionRequest(translation = translation), + ).andIsOk + } + + createAt(0, "cs oldest") + createAt(120_000, "cs newest") + createAt(60_000, "cs middle") + + performProjectAuthGet("/translations?sort=id&filterKeyId=${key.id}") + .andIsOk + .andAssertThatJson { + node("_embedded.keys[0].translations.cs.suggestions") { + isArray.hasSize(3) + node("[0].translation").isEqualTo("cs newest") + node("[1].translation").isEqualTo("cs middle") + node("[2].translation").isEqualTo("cs oldest") + } + } + } + @Test @ProjectJWTAuthTestMethod fun `creates suggestion`() { diff --git a/webapp/src/views/projects/translations/Suggestions/SuggestionsFirst.tsx b/webapp/src/views/projects/translations/Suggestions/SuggestionsFirst.tsx index 82a7dec1e79..a34b0af6c82 100644 --- a/webapp/src/views/projects/translations/Suggestions/SuggestionsFirst.tsx +++ b/webapp/src/views/projects/translations/Suggestions/SuggestionsFirst.tsx @@ -1,4 +1,4 @@ -import { styled, Tooltip } from '@mui/material'; +import { styled } from '@mui/material'; import { components } from 'tg.service/apiSchema.generated'; import { TranslationSuggestion } from './TranslationSuggestion'; import { useTranslate } from '@tolgee/react'; @@ -6,34 +6,38 @@ import { useTranslate } from '@tolgee/react'; type TranslationSuggestionSimpleModel = components['schemas']['TranslationSuggestionSimpleModel']; -const StyledContainer = styled('div')` - display: grid; - grid-template-columns: 1fr auto; - align-items: start; -`; - -const StyledExtra = styled('div')` - display: flex; - padding: 1px 8px; - align-items: center; - border-radius: 13px; - background: ${({ theme }) => theme.palette.tokens.background.onDefaultGrey}; - margin-top: 8px; - margin-left: 8px; -`; +// Mirror of TranslationSuggestionServiceEeImpl.MAX_DISPLAYED_SUGGESTIONS (backend caps the embed); keep in sync. +export const MAX_DISPLAYED_SUGGESTIONS = 3; const StyledWrapper = styled('div')` display: grid; - gap: 8px; border-radius: 8px; background: ${({ theme }) => theme.palette.tokens.background.onDefaultGrey}; `; +const StyledShowAll = styled('button')` + border: none; + background: none; + cursor: pointer; + font-family: inherit; + padding: 0 0 6px 0; + font-size: 13px; + font-weight: ${({ theme }) => theme.typography.button.fontWeight}; + letter-spacing: 0.46px; + text-transform: uppercase; + color: ${({ theme }) => theme.palette.text.secondary}; + + &:hover { + color: ${({ theme }) => theme.palette.text.primary}; + } +`; + type Props = { count: number; suggestions: TranslationSuggestionSimpleModel[]; isPlural: boolean; locale: string; + onShowAll?: () => void; }; export const SuggestionsFirst = ({ @@ -41,27 +45,34 @@ export const SuggestionsFirst = ({ suggestions, isPlural, locale, + onShowAll, }: Props) => { const { t } = useTranslate(); - const extraCount = count - suggestions.length; + const displayed = suggestions.slice(0, MAX_DISPLAYED_SUGGESTIONS); + const hasMore = count > displayed.length; return ( - - - {suggestions.map((s) => ( - - ))} - - {Boolean(extraCount) && ( - - +{extraCount} - + + {displayed.map((s) => ( + + ))} + {hasMore && onShowAll && ( + { + e.stopPropagation(); + onShowAll(); + }} + > + {t('translation_tools_suggestions_show_all_label')} + )} - + ); }; diff --git a/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx b/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx index 3696111fcd7..139ce2e3e6c 100644 --- a/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx +++ b/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx @@ -4,6 +4,7 @@ import { PanelHeader } from '../ToolsPanel/common/PanelHeader'; import { T, useTranslate } from '@tolgee/react'; import { components } from 'tg.service/apiSchema.generated'; import { TranslationSuggestion } from './TranslationSuggestion'; +import { MAX_DISPLAYED_SUGGESTIONS } from './SuggestionsFirst'; import { useApiMutation } from 'tg.service/http/useQueryApi'; import { useProject } from 'tg.hooks/useProject'; import { useTranslationsActions } from '../context/TranslationsContext'; @@ -133,10 +134,14 @@ export const SuggestionsList = ({ lang: languageTag, data(translation) { const firstPage = suggestions.pages?.[0]; - const firstSuggestion = firstPage?._embedded?.suggestions?.[0]; + const shown = + firstPage?._embedded?.suggestions?.slice( + 0, + MAX_DISPLAYED_SUGGESTIONS + ) ?? []; return { ...translation, - suggestions: firstSuggestion ? [firstSuggestion] : [], + suggestions: shown, activeSuggestionCount: firstPage.page?.totalElements ?? 0, }; }, diff --git a/webapp/src/views/projects/translations/Suggestions/useInfiniteSuggestions.ts b/webapp/src/views/projects/translations/Suggestions/useInfiniteSuggestions.ts index 56b4a99a2a0..a558d9b6c03 100644 --- a/webapp/src/views/projects/translations/Suggestions/useInfiniteSuggestions.ts +++ b/webapp/src/views/projects/translations/Suggestions/useInfiniteSuggestions.ts @@ -33,7 +33,7 @@ export const useInfiniteSuggestions = ({ }: Props) => { const params: SuggestionParams = { query: { - sort: ['createdAt,desc'], + sort: ['createdAt,desc', 'id,desc'], filterState, // @ts-ignore force react-query cache only requests with the same count expected expectedCount, diff --git a/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx b/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx index 4b5eb30f36e..9e321f1959c 100644 --- a/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx +++ b/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx @@ -172,6 +172,7 @@ export const TranslationRead: React.FC> = ({ count={translation?.activeSuggestionCount ?? 0} isPlural={keyData.keyIsPlural} locale={language.tag} + onShowAll={cellClickable ? () => handleOpen() : undefined} /> )} {aiPlaygroundData && ( diff --git a/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx b/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx index b8407958887..3450831a4e6 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx @@ -124,6 +124,7 @@ export const TranslationRead: React.FC> = ({ count={translation!.activeSuggestionCount} isPlural={keyData.keyIsPlural} locale={language.tag} + onShowAll={cellClickable ? () => handleOpen() : undefined} /> )} {aiPlaygroundData && ( diff --git a/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx b/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx index 4c06a82635d..3793b385138 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx @@ -14,11 +14,17 @@ import { TranslationVisual } from '../translationVisual/TranslationVisual'; import { ControlsEditorReadOnly } from '../cell/ControlsEditorReadOnly'; import { useBaseTranslation } from '../useBaseTranslation'; import { TaskInfoMessage } from 'tg.ee'; +import { SuggestionsList } from '../Suggestions/SuggestionsList'; const StyledContainer = styled('div')` display: grid; `; +const StyledSuggestions = styled('div')` + display: grid; + padding: 4px 12px 12px 16px; +`; + const StyledEditor = styled('div')` padding: 12px 12px 12px 16px; `; @@ -195,6 +201,19 @@ export const TranslationWrite: React.FC> = ({ + + {Boolean(translation?.totalSuggestionCount) && ( + + + + )} ); }; diff --git a/webapp/src/views/projects/translations/context/services/useEditService.tsx b/webapp/src/views/projects/translations/context/services/useEditService.tsx index e19e47255b5..cfa2cd7579e 100644 --- a/webapp/src/views/projects/translations/context/services/useEditService.tsx +++ b/webapp/src/views/projects/translations/context/services/useEditService.tsx @@ -17,6 +17,7 @@ import { usePutTranslation, } from 'tg.service/TranslationHooks'; import { components } from 'tg.service/apiSchema.generated'; +import { MAX_DISPLAYED_SUGGESTIONS } from '../../Suggestions/SuggestionsFirst'; import type { useTranslationsService } from './useTranslationsService'; import type { useRefsService } from './useRefsService'; @@ -192,10 +193,17 @@ export const useEditService = ({ keyId: result.keyId, lang: lang.tag, data(value) { + const isNew = !(value.suggestions ?? []).some( + (s) => s.id === result.id + ); + const delta = isNew ? 1 : 0; return { - suggestions: [result], - activeSuggestionCount: (value.activeSuggestionCount ?? 0) + 1, - totalSuggestionCount: (value.totalSuggestionCount ?? 0) + 1, + suggestions: [ + result, + ...(value.suggestions ?? []).filter((s) => s.id !== result.id), + ].slice(0, MAX_DISPLAYED_SUGGESTIONS), + activeSuggestionCount: (value.activeSuggestionCount ?? 0) + delta, + totalSuggestionCount: (value.totalSuggestionCount ?? 0) + delta, } satisfies Partial; }, }); From e5e240baf8d451b36c59faa0b061f846121ec543 Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Wed, 1 Jul 2026 23:36:13 +0200 Subject: [PATCH 6/8] feat: public projects view (Community Translation v1.0) (#3775) Adds a logged-out-capable /public-projects page listing all public projects (stats, search, paging) via a new anonymous GET /v2/public/projects/with-stats endpoint, reusing the project-list UI with a community-translation onboarding banner. - Backend: PublicProjectsController (anonymous, @OpenApiHideFromPublicDocs, @Transactional(readOnly=true)); ProjectService.findAllPublicPaged (NO_USER_ID sentinel); ProjectRepository.findAllPublic (reuses BASE_VIEW_QUERY; guards public/deleted/org-owner/base-language to keep the anonymous read side-effect-free + community-permission floor via #3770). - Frontend: /public-projects route, PublicProjectListView + PublicTopBar + community-translation banner (Figma-matched gradient, dev-mouse mascot, left-anchored gated search); shared ProjectsList extracted from ProjectListView; DashboardProjectListItem variant='public' (member affordances suppressed, row click -> project when logged-in, else login). - Tests: PublicProjectsControllerTest (anonymous/member/community/direct-permission joins, guard filters, case-insensitive search) + publicProjects.cy.ts E2E. --- .../project/PublicProjectsController.kt | 44 ++++ .../tolgee/configuration/WebSecurityConfig.kt | 3 + .../PublicProjectsControllerTest.kt | 199 ++++++++++++++++++ .../data/PublicProjectsControllerTestData.kt | 105 +++++++++ .../data/PublicProjectsE2eData.kt | 33 +++ .../io/tolgee/repository/ProjectRepository.kt | 24 +++ .../tolgee/service/project/ProjectService.kt | 14 ++ .../PublicProjectsE2eDataController.kt | 23 ++ .../common/apiCalls/testData/testData.ts | 5 + e2e/cypress/e2e/projects/publicProjects.cy.ts | 91 ++++++++ e2e/cypress/support/dataCyType.d.ts | 3 + webapp/public/images/communityMouse.svg | 55 +++++ webapp/src/component/RootRouter.tsx | 7 + webapp/src/constants/links.tsx | 2 + webapp/src/service/apiSchema.generated.ts | 50 +++++ .../projects/DashboardProjectListItem.tsx | 150 +++++++------ webapp/src/views/projects/ProjectListView.tsx | 117 +++++----- webapp/src/views/projects/ProjectsList.tsx | 48 +++++ .../public/CommunityTranslationBanner.tsx | 130 ++++++++++++ .../projects/public/PublicProjectListView.tsx | 75 +++++++ .../views/projects/public/PublicTopBar.tsx | 89 ++++++++ .../projects/public/publicProjectsLayout.ts | 1 + 22 files changed, 1136 insertions(+), 132 deletions(-) create mode 100644 backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/PublicProjectsController.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt create mode 100644 backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt create mode 100644 e2e/cypress/e2e/projects/publicProjects.cy.ts create mode 100644 webapp/public/images/communityMouse.svg create mode 100644 webapp/src/views/projects/ProjectsList.tsx create mode 100644 webapp/src/views/projects/public/CommunityTranslationBanner.tsx create mode 100644 webapp/src/views/projects/public/PublicProjectListView.tsx create mode 100644 webapp/src/views/projects/public/PublicTopBar.tsx create mode 100644 webapp/src/views/projects/public/publicProjectsLayout.ts diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/PublicProjectsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/PublicProjectsController.kt new file mode 100644 index 00000000000..454843d9a7c --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/PublicProjectsController.kt @@ -0,0 +1,44 @@ +package io.tolgee.api.v2.controllers.project + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.api.v2.controllers.IController +import io.tolgee.facade.ProjectWithStatsFacade +import io.tolgee.hateoas.project.ProjectWithStatsModel +import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs +import io.tolgee.service.project.ProjectService +import org.springdoc.core.annotations.ParameterObject +import org.springframework.data.domain.Pageable +import org.springframework.data.web.SortDefault +import org.springframework.hateoas.PagedModel +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping(value = ["/v2/public/projects"]) +@Tag(name = "Public projects") +@OpenApiHideFromPublicDocs +class PublicProjectsController( + private val projectService: ProjectService, + private val projectWithStatsFacade: ProjectWithStatsFacade, +) : IController { + @Operation( + summary = "Get all public projects with stats", + description = + "Returns all public projects (including statistics), discoverable by anyone — no authentication required", + ) + @GetMapping("/with-stats") + @Transactional(readOnly = true) + fun getAllPublicWithStatistics( + @ParameterObject @SortDefault("name") pageable: Pageable, + @RequestParam("search") search: String?, + ): PagedModel { + val projects = projectService.findAllPublicPaged(pageable, search) + return projectWithStatsFacade.getPagedModelWithStats(projects) + } +} diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt index c4ce42006e7..4830b06d5d7 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt @@ -138,6 +138,9 @@ class WebSecurityConfig( registry .addInterceptor(organizationAuthorizationInterceptor) .addPathPatterns(*ORGANIZATION_ENDPOINTS) + // These authorization interceptors are NOT registered for /v2/public/**; routes there must stay free + // of {projectId}/{organizationId} path vars + @RequiresProjectPermissions — adding one would silently + // bypass authorization. registry .addInterceptor(projectAuthorizationInterceptor) .addPathPatterns(*PROJECT_ENDPOINTS) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt new file mode 100644 index 00000000000..8d0163115ba --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt @@ -0,0 +1,199 @@ +package io.tolgee.api.v2.controllers + +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +@AutoConfigureMockMvc +class PublicProjectsControllerTest : AuthorizedControllerTest() { + private lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + // Drive the degenerate-exclusion projects into states the JPA layer self-heals/forbids on entity + // save, via native SQL (the list query is a projection, so it never reloads them as entities). + executeInNewTransaction { + entityManager + .createNativeQuery("update project set base_language_id = null where id = :id") + .setParameter("id", testData.noBaseLanguageProject.id) + .executeUpdate() + entityManager + .createNativeQuery("update language set deleted_at = now() where project_id = :id") + .setParameter("id", testData.softDeletedBaseProject.id) + .executeUpdate() + entityManager + .createNativeQuery("update project set organization_owner_id = null where id = :id") + .setParameter("id", testData.orgLessProject.id) + .executeUpdate() + entityManager + .createNativeQuery("update project set deleted_at = now() where id = :id") + .setParameter("id", testData.deletedPublicProject.id) + .executeUpdate() + } + } + + @Test + fun `lists only public projects to an anonymous visitor`() { + performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects") { + isArray.hasSize(2) + node("[0].id").isEqualTo(testData.otherOrgPublicProject.id) + node("[1].id").isEqualTo(testData.publicProject.id) + } + } + } + + @Test + fun `a public row carries stats, org and NONE permission for an anonymous visitor`() { + performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects[1]") { + node("id").isEqualTo(testData.publicProject.id) + node("public").isEqualTo(true) + node("organizationOwner.name").isEqualTo("test_username") + node("stats.keyCount").isEqualTo(0) + node("languages").isArray.hasSize(1) + node("directPermission").isEqualTo(null) + node("organizationRole").isEqualTo(null) + node("computedPermission.type").isEqualTo("NONE") + node("computedPermission.scopes").isArray.hasSize(0) + } + } + } + + @Test + fun `search matches the project name case-insensitively`() { + performGet("/v2/public/projects/with-stats?search=other ORG").andIsOk.andAssertThatJson { + node("_embedded.projects") { + isArray.hasSize(1) + node("[0].id").isEqualTo(testData.otherOrgPublicProject.id) + } + } + } + + @Test + fun `search matches the organization name case-insensitively`() { + performGet("/v2/public/projects/with-stats?search=vibrant").andIsOk.andAssertThatJson { + node("_embedded.projects") { + isArray.hasSize(1) + node("[0].id").isEqualTo(testData.otherOrgPublicProject.id) + } + } + } + + @Test + fun `pages the public projects`() { + performGet("/v2/public/projects/with-stats?size=1").andIsOk.andAssertThatJson { + node("_embedded.projects").isArray.hasSize(1) + node("page.totalElements").isEqualTo(2) + node("page.size").isEqualTo(1) + } + } + + @Test + fun `the with-stats payload reflects the public flag of each project`() { + userAccount = testData.user + performAuthGet("/v2/projects/with-stats?sort=id").andIsOk.andAssertThatJson { + node("_embedded.projects") { + node("[0].id").isEqualTo(testData.privateProject.id) + node("[0].public").isEqualTo(false) + node("[1].id").isEqualTo(testData.publicProject.id) + node("[1].public").isEqualTo(true) + } + } + } + + @Test + fun `a logged-in member sees their real permission on a public row (per-user join wired)`() { + userAccount = testData.user + performAuthGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects") { + node("[0].id").isEqualTo(testData.otherOrgPublicProject.id) + node("[0].organizationRole").isEqualTo(null) + node("[0].computedPermission.type").isEqualTo("VIEW") + node("[0].computedPermission.origin").isEqualTo("COMMUNITY") + node("[1].id").isEqualTo(testData.publicProject.id) + node("[1].organizationRole").isEqualTo("OWNER") + node("[1].computedPermission.type").isEqualTo("MANAGE") + } + } + } + + @Test + fun `a logged-in user sees their direct project permission on a public row`() { + userAccount = testData.directPermissionUser + performAuthGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects[0]") { + node("id").isEqualTo(testData.otherOrgPublicProject.id) + node("organizationRole").isEqualTo(null) + node("directPermission.type").isEqualTo("TRANSLATE") + node("computedPermission.type").isEqualTo("TRANSLATE") + } + } + } + + @Test + fun `excludes a soft-deleted public project`() { + performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects").isArray.hasSize(2) + } + } + + @Test + fun `a logged-in non-member gets the community permission on a public row`() { + userAccount = testData.nonMember + performAuthGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects") { + isArray.hasSize(2) + node("[1].id").isEqualTo(testData.publicProject.id) + node("[1].directPermission").isEqualTo(null) + node("[1].organizationRole").isEqualTo(null) + node("[1].computedPermission.type").isEqualTo("VIEW") + node("[1].computedPermission.origin").isEqualTo("COMMUNITY") + } + } + } + + @Test + fun `excludes a public project with no base language without mutating it`() { + performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects").isArray.hasSize(2) + } + baseLanguageId(testData.noBaseLanguageProject.id).assert.isNull() + } + + @Test + fun `excludes a public project whose base language is soft-deleted without mutating it`() { + val baseBefore = baseLanguageId(testData.softDeletedBaseProject.id) + performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects").isArray.hasSize(2) + } + baseLanguageId(testData.softDeletedBaseProject.id).assert.isEqualTo(baseBefore) + } + + @Test + fun `excludes a public project with no organization owner`() { + performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects").isArray.hasSize(2) + } + } + + private fun baseLanguageId(projectId: Long): Long? = + executeInNewTransaction { + ( + entityManager + .createNativeQuery("select base_language_id from project where id = :id") + .setParameter("id", projectId) + .singleResult as Number? + )?.toLong() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt new file mode 100644 index 00000000000..2ff78893b9f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt @@ -0,0 +1,105 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.ProjectBuilder +import io.tolgee.model.Organization +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ProjectPermissionType + +class PublicProjectsControllerTestData : BaseTestData() { + val privateProject: Project get() = project + + lateinit var publicProject: Project + lateinit var otherOrg: Organization + lateinit var otherOrgPublicProject: Project + lateinit var nonMember: UserAccount + + lateinit var directPermissionUser: UserAccount + + lateinit var noBaseLanguageProject: Project + lateinit var softDeletedBaseProject: Project + lateinit var orgLessProject: Project + lateinit var deletedPublicProject: Project + + init { + root.apply { + nonMember = + addUserAccount { + username = "non_member" + name = "Non Member" + }.self + + directPermissionUser = + addUserAccount { + username = "direct_perm_user" + name = "Direct Perm User" + }.self + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Public project" + public = true + }.build { + publicProject = self + addBaseLanguage() + } + + otherOrg = + addOrganization { + name = "Vibrant translators" + }.self + + addProject(organizationOwner = otherOrg) { + name = "Other org public project" + public = true + }.build { + otherOrgPublicProject = self + addBaseLanguage() + addPermission { + user = this@PublicProjectsControllerTestData.directPermissionUser + type = ProjectPermissionType.TRANSLATE + } + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "No base language project" + public = true + }.build { + noBaseLanguageProject = self + addBaseLanguage() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Soft-deleted base project" + public = true + }.build { + softDeletedBaseProject = self + addBaseLanguage() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Org-less project" + public = true + }.build { + orgLessProject = self + addBaseLanguage() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Deleted public project" + public = true + }.build { + deletedPublicProject = self + addBaseLanguage() + } + } + } + + private fun ProjectBuilder.addBaseLanguage() { + addLanguage { + name = "English" + tag = "en" + originalName = "English" + this@addBaseLanguage.self.baseLanguage = this + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt new file mode 100644 index 00000000000..49e739377d1 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt @@ -0,0 +1,33 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.ProjectBuilder + +/** + * The inherited BaseTestData project ("Private project") stays non-public so the list endpoint's + * exclusion of private projects can be asserted. + */ +class PublicProjectsE2eData( + count: Int = 6, +) : BaseTestData("publicProjectsUser", "Private project") { + init { + root.apply { + listOf("Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta").take(count).forEach { suffix -> + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Community $suffix" + public = true + }.build { + addBaseLanguage() + } + } + } + } + + private fun ProjectBuilder.addBaseLanguage() { + addLanguage { + name = "English" + tag = "en" + originalName = "English" + this@addBaseLanguage.self.baseLanguage = this + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt index 6d0406a7829..4c57e1e3c39 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt @@ -98,6 +98,30 @@ interface ProjectRepository : JpaRepository { filters: ProjectFilters, ): Page + /** + * Lists public projects for the anonymous endpoint. The `organizationOwner is not null` filter is + * required because the shared stats assembler dereferences a non-null `organizationOwner` (legacy + * user-owned projects would NPE). The `baseLanguage is not null and bl.deletedAt is null` filters keep + * out projects whose base language is missing/soft-deleted: the stats pipeline calls + * `languageService.getProjectLanguages`, which auto-assigns and persists a new base language + * (`setNewProjectBaseLanguage`) for any such project — an unauthenticated GET must never mutate. + */ + @Query( + """$BASE_VIEW_QUERY + where r.public = true and r.deletedAt is null and r.organizationOwner is not null + and r.baseLanguage is not null and bl.deletedAt is null + and ( + :search is null or (lower(r.name) like lower(concat('%', cast(:search as text), '%')) + or lower(o.name) like lower(concat('%', cast(:search as text),'%'))) + ) + """, + ) + fun findAllPublic( + userAccountId: Long, + pageable: Pageable, + @Param("search") search: String? = null, + ): Page + fun findAllByOrganizationOwnerId(organizationOwnerId: Long): List fun findAllByOrganizationOwnerIdAndDeletedAtIsNull(organizationOwnerId: Long): List diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index 6371b632870..95eef59d136 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -58,6 +58,10 @@ import org.springframework.transaction.annotation.Transactional import java.io.InputStream import java.io.Serializable +// fed into BASE_VIEW_QUERY's per-user permission/role joins; must never equal a real UserAccount id, +// or an anonymous caller would inherit that account's permissions +private const val NO_USER_ID = -1L + @Transactional @Service class ProjectService( @@ -455,6 +459,16 @@ class ProjectService( return addPermittedLanguagesToProjects(withoutPermittedLanguages, userAccountId) } + @Transactional(readOnly = true) + fun findAllPublicPaged( + pageable: Pageable, + search: String?, + ): Page { + val userAccountId = authenticationFacade.authenticatedUserOrNull?.id ?: NO_USER_ID + val projects = projectRepository.findAllPublic(userAccountId, pageable, search) + return projects.map { ProjectWithLanguagesView.fromProjectView(it, null) } + } + @CacheEvict(cacheNames = [Caches.PROJECTS], allEntries = true) fun saveAll(projects: Collection): MutableList = projectRepository.saveAll(projects) diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt new file mode 100644 index 00000000000..1748c86f274 --- /dev/null +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt @@ -0,0 +1,23 @@ +package io.tolgee.controllers.internal.e2eData + +import io.tolgee.controllers.internal.InternalController +import io.tolgee.data.StandardTestDataResult +import io.tolgee.data.service.TestDataGeneratingService +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.development.testDataBuilder.data.PublicProjectsE2eData +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.GetMapping + +@InternalController(["internal/e2e-data/public-projects"]) +class PublicProjectsE2eDataController( + private val generatingService: TestDataGeneratingService, +) : AbstractE2eDataController() { + override val testData: TestDataBuilder + get() = PublicProjectsE2eData().root + + @GetMapping(value = ["/generate-few"]) + @Transactional + fun generateFew(): StandardTestDataResult { + return generatingService.generate(PublicProjectsE2eData(count = 5).root) + } +} diff --git a/e2e/cypress/common/apiCalls/testData/testData.ts b/e2e/cypress/common/apiCalls/testData/testData.ts index 0f200e2f19d..b1baa1fd267 100644 --- a/e2e/cypress/common/apiCalls/testData/testData.ts +++ b/e2e/cypress/common/apiCalls/testData/testData.ts @@ -56,6 +56,11 @@ export const projectListData = generateTestDataObject( 'projects-list-dashboard' ); +export const publicProjectsData = { + ...generateTestDataObject('public-projects'), + generateFew: () => internalFetch('e2e-data/public-projects/generate-few'), +}; + export const projectTestData = generateTestDataObject('projects'); export const apiKeysTestData = generateTestDataObject('api-keys'); diff --git a/e2e/cypress/e2e/projects/publicProjects.cy.ts b/e2e/cypress/e2e/projects/publicProjects.cy.ts new file mode 100644 index 00000000000..bfa1d838ca8 --- /dev/null +++ b/e2e/cypress/e2e/projects/publicProjects.cy.ts @@ -0,0 +1,91 @@ +import { HOST } from '../../common/constants'; +import { gcy } from '../../common/shared'; +import { login } from '../../common/apiCalls/common'; +import { loginViaForm } from '../../common/login'; +import { publicProjectsData } from '../../common/apiCalls/testData/testData'; +import { waitForGlobalLoading } from '../../common/loading'; + +describe('Public projects view', () => { + const visit = () => { + cy.visit(`${HOST}/public-projects`); + waitForGlobalLoading(); + }; + + beforeEach(() => { + publicProjectsData.clean(); + }); + + afterEach(() => { + publicProjectsData.clean(); + }); + + it('shows the community banner and login/sign-up for a logged-out visitor', () => { + publicProjectsData.generateStandard(); + visit(); + gcy('community-translation-banner').should('be.visible'); + gcy('public-projects-login-button').should('be.visible'); + gcy('public-projects-sign-up-button').should('be.visible'); + gcy('organization-switch').should('not.exist'); + gcy('global-plus-button').should('not.exist'); + }); + + it('lists public projects with the public badge + org and hides private ones', () => { + publicProjectsData.generateStandard(); + visit(); + gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('project-list-public-badge').should('have.length', 6); + gcy('global-search-field').should('exist'); + cy.contains('Community Alpha').should('be.visible'); + cy.contains('Community Zeta').should('be.visible'); + cy.contains('Private project').should('not.exist'); + gcy('project-list-more-button').should('not.exist'); + gcy('project-list-translations-button').should('not.exist'); + gcy('project-list-qa-badge-button').should('not.exist'); + }); + + it('narrows the list with search', () => { + publicProjectsData.generateStandard(); + visit(); + gcy('global-search-field').find('input').type('Alpha'); + waitForGlobalLoading(); + gcy('dashboard-projects-list-item').should('have.length', 1); + cy.contains('Community Alpha').should('be.visible'); + gcy('global-search-field').should('exist'); + + gcy('global-search-field').find('input').clear(); + gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('global-search-field').should('exist'); + }); + + it('hides the search field when the project count is at the threshold', () => { + publicProjectsData.generateFew(); + visit(); + gcy('dashboard-projects-list-item').should('have.length', 5); + gcy('global-search-field').should('not.exist'); + }); + + it('routes a public row click to the login page', () => { + publicProjectsData.generateStandard(); + visit(); + gcy('dashboard-projects-list-item').first().click(); + cy.url().should('include', '/login'); + }); + + it('returns a logged-out visitor to the clicked project after login', () => { + publicProjectsData.generateStandard(); + visit(); + gcy('dashboard-projects-list-item').first().click(); + cy.url().should('include', '/login'); + loginViaForm('admin', 'admin'); + cy.url().should('match', /\/projects\/[0-9]+/); + }); + + it('opens the project instead of login for a logged-in visitor', () => { + publicProjectsData.generateStandard(); + login('admin', 'admin'); + visit(); + gcy('community-translation-banner').should('be.visible'); + gcy('dashboard-projects-list-item').first().click(); + cy.url().should('match', /\/projects\/[0-9]+/); + }); +}); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 0a7450d5c4f..5054e66443e 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -146,6 +146,7 @@ declare namespace DataCy { "comment-menu-needs-resolution": true; "comment-resolve": true; "comment-text": true; + "community-translation-banner": true; "content-delivery-add-button": true; "content-delivery-auto-publish-checkbox": true; "content-delivery-delete-button": true; @@ -650,6 +651,8 @@ declare namespace DataCy { "project-transfer-dialog": true; "prompt-basic-option": true; "prompt-basic-option-edit": true; + "public-projects-login-button": true; + "public-projects-sign-up-button": true; "qa-action-correct": true; "qa-action-ignore": true; "qa-badge": true; diff --git a/webapp/public/images/communityMouse.svg b/webapp/public/images/communityMouse.svg new file mode 100644 index 00000000000..0028ffa7d5c --- /dev/null +++ b/webapp/public/images/communityMouse.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index 2af7e325ad7..2a9ddf67dd9 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -27,6 +27,10 @@ const SlackConnectedView = React.lazy( const SignUpView = React.lazy(() => import('./security/SignUp/SignUpView')); +const PublicProjectListView = React.lazy( + () => import('tg.views/projects/public/PublicProjectListView') +); + const PasswordResetSetView = React.lazy( () => import('./security/ResetPasswordSetView') ); @@ -67,6 +71,9 @@ export const RootRouter = () => { + + + diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 567cf897168..49325c29bca 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -295,6 +295,8 @@ export class LINKS { static PROJECTS = Link.ofRoot('projects'); + static PUBLIC_PROJECTS = Link.ofRoot('public-projects'); + /** * Visible with view permissions */ diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 67ae87f0d5e..7aa6dba0edb 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1182,6 +1182,10 @@ export interface paths { /** Get machine translation providers */ get: operations["getInfo_4"]; }; + "/v2/public/projects/with-stats": { + /** Returns all public projects (including statistics), discoverable by anyone — no authentication required */ + get: operations["getAllPublicWithStatistics"]; + }; "/v2/public/scope-info/hierarchy": { get: operations["getHierarchy"]; }; @@ -24501,6 +24505,52 @@ export interface operations { }; }; }; + /** Returns all public projects (including statistics), discoverable by anyone — no authentication required */ + getAllPublicWithStatistics: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelProjectWithStatsModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; getHierarchy: { parameters: { query: { diff --git a/webapp/src/views/projects/DashboardProjectListItem.tsx b/webapp/src/views/projects/DashboardProjectListItem.tsx index e848d0ffc41..a420c4caf02 100644 --- a/webapp/src/views/projects/DashboardProjectListItem.tsx +++ b/webapp/src/views/projects/DashboardProjectListItem.tsx @@ -142,9 +142,15 @@ const StyledOrganizationName = styled(Typography)` type ProjectWithStatsModel = components['schemas']['ProjectWithStatsModel']; -const DashboardProjectListItem = (p: ProjectWithStatsModel) => { +type Props = ProjectWithStatsModel & { + variant?: 'default' | 'public'; +}; + +const DashboardProjectListItem = ({ variant = 'default', ...p }: Props) => { + const isPublicVariant = variant === 'public'; const { t } = useTranslate(); const history = useHistory(); + const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); const rightPanelWidth = useGlobalContext((c) => c.layout.rightPanelWidth); const isCompact = useMediaQuery( `@media(max-width: ${rightPanelWidth + 800}px)` @@ -156,71 +162,68 @@ const DashboardProjectListItem = (p: ProjectWithStatsModel) => { const showQaBadge = isEnabled('QA_CHECKS') && (hasQaIssues || hasStaleQaChecks); - return ( - + history.push( + isPublicVariant && !allowPrivate + ? LINKS.LOGIN.build() + : LINKS.PROJECT_DASHBOARD.build({ + [PARAMS.PROJECT_ID]: p.id, + }) + ) + } > - - history.push( - LINKS.PROJECT_DASHBOARD.build({ - [PARAMS.PROJECT_ID]: p.id, - }) - ) - } - > - - - - - {p.name} - {p.public && ( - - - {p.organizationOwner?.name && ( - - {p.organizationOwner.name} - - )} - - )} - - - - + + + + {p.name} + {p.public && ( + + - - - - - - - - - - - + {p.organizationOwner?.name && ( + + {p.organizationOwner.name} + + )} + + )} + + + + + + + + + + + + + + + + {!isPublicVariant && ( {showQaBadge ? ( @@ -262,8 +265,23 @@ const DashboardProjectListItem = (p: ProjectWithStatsModel) => { projectName={p.name} /> - - + )} + + + ); + + if (isPublicVariant) { + return content; + } + + return ( + + {content} ); }; diff --git a/webapp/src/views/projects/ProjectListView.tsx b/webapp/src/views/projects/ProjectListView.tsx index fb01aca1e30..368c3b4faa3 100644 --- a/webapp/src/views/projects/ProjectListView.tsx +++ b/webapp/src/views/projects/ProjectListView.tsx @@ -2,14 +2,13 @@ import { useState } from 'react'; import { T, useTranslate } from '@tolgee/react'; import { EmptyListMessage } from 'tg.component/common/EmptyListMessage'; -import { PaginatedHateoasList } from 'tg.component/common/list/PaginatedHateoasList'; import { BaseView } from 'tg.component/layout/BaseView'; import { BaseViewAddButton } from 'tg.component/layout/BaseViewAddButton'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { LINKS } from 'tg.constants/links'; import { useApiQuery } from 'tg.service/http/useQueryApi'; -import DashboardProjectListItem from 'tg.views/projects/DashboardProjectListItem'; -import { Button, styled } from '@mui/material'; +import { ProjectsList } from 'tg.views/projects/ProjectsList'; +import { Button } from '@mui/material'; import { Link } from 'react-router-dom'; import { useIsAdminOrSupporter, @@ -20,16 +19,6 @@ import { OrganizationSwitch } from 'tg.component/organizationSwitch/Organization import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; import { CriticalUsageCircle } from 'tg.ee'; -const StyledWrapper = styled('div')` - display: flex; - flex-direction: column; - align-items: stretch; - - & .listWrapper > * > * + * { - border-top: 1px solid ${({ theme }) => theme.palette.divider1}; - } -`; - export const ProjectListView = () => { const [page, setPage] = useState(0); const [search, setSearch] = useState(''); @@ -66,59 +55,55 @@ export const ProjectListView = () => { search || (listPermitted.data?.page?.totalElements ?? 0) > 5; return ( - - - - - - ) + + + + + ) + } + addLabel={t('projects_add_button')} + hideChildrenOnLoading={false} + navigation={[ + [], + [t('projects_title'), LINKS.PROJECTS.build()], + ]} + navigationRight={} + loading={listPermitted.isFetching} + > + + + + ) : undefined + } + > + + } - addLabel={t('projects_add_button')} - hideChildrenOnLoading={false} - navigation={[ - [], - [t('projects_title'), LINKS.PROJECTS.build()], - ]} - navigationRight={} - loading={listPermitted.isFetching} - > - } - emptyPlaceholder={ - - - - ) : undefined - } - > - - - } - /> - - - + /> + + ); }; diff --git a/webapp/src/views/projects/ProjectsList.tsx b/webapp/src/views/projects/ProjectsList.tsx new file mode 100644 index 00000000000..7322407f4b4 --- /dev/null +++ b/webapp/src/views/projects/ProjectsList.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from 'react'; +import { styled } from '@mui/material'; +import { UseQueryResult } from 'react-query'; + +import { PaginatedHateoasList } from 'tg.component/common/list/PaginatedHateoasList'; +import { HateoasPaginatedData } from 'tg.service/response.types'; +import { components } from 'tg.service/apiSchema.generated'; +import DashboardProjectListItem from 'tg.views/projects/DashboardProjectListItem'; + +type ProjectWithStatsModel = components['schemas']['ProjectWithStatsModel']; + +const StyledWrapper = styled('div')` + display: flex; + flex-direction: column; + align-items: stretch; + + & .listWrapper > * > * + * { + border-top: 1px solid ${({ theme }) => theme.palette.divider1}; + } +`; + +type Props = { + loadable: UseQueryResult, any>; + onPageChange: (page: number) => void; + emptyPlaceholder: ReactNode; + variant?: 'default' | 'public'; +}; + +export const ProjectsList = ({ + loadable, + onPageChange, + emptyPlaceholder, + variant = 'default', +}: Props) => { + return ( + + ( + + )} + emptyPlaceholder={emptyPlaceholder} + /> + + ); +}; diff --git a/webapp/src/views/projects/public/CommunityTranslationBanner.tsx b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx new file mode 100644 index 00000000000..87e273e35b1 --- /dev/null +++ b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx @@ -0,0 +1,130 @@ +import { alpha, styled, Typography } from '@mui/material'; +import { Edit05, MessageTextSquare02 } from '@untitled-ui/icons-react'; +import { T } from '@tolgee/react'; + +import { SPLIT_CONTENT_BREAK_POINT } from 'tg.component/layout/CompactView'; +import { PUBLIC_CONTENT_MAX_WIDTH } from './publicProjectsLayout'; + +const StyledBanner = styled('div')` + position: relative; + width: 100%; + background: linear-gradient( + to right, + ${({ theme }) => alpha(theme.palette.background.default, 0.12)}, + ${({ theme }) => alpha(theme.palette.primary.main, 0.12)} 69%, + ${({ theme }) => alpha(theme.palette.background.default, 0.12)} + ); +`; + +const StyledInner = styled('div')` + position: relative; + max-width: ${PUBLIC_CONTENT_MAX_WIDTH}px; + margin: 0 auto; + padding: ${({ theme }) => theme.spacing(5, 2)}; +`; + +const StyledContent = styled('div')` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2.5)}; + padding-right: 260px; + @media ${SPLIT_CONTENT_BREAK_POINT} { + padding-right: 0; + } +`; + +const StyledEyebrow = styled(Typography)` + font-size: 24px; + font-weight: 600; + line-height: 1.235; + letter-spacing: 0.25px; + color: ${({ theme }) => theme.palette.text.primary}; +`; + +const StyledHeading = styled(Typography)` + font-size: 40px; + font-weight: 700; + line-height: 1.167; + letter-spacing: -1.5px; + color: ${({ theme }) => theme.palette.primary.main}; +`; + +const StyledSubtext = styled(Typography)` + max-width: 561px; +`; + +const StyledBullets = styled('div')` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(0.5)}; +`; + +const StyledBullet = styled('div')` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; + color: ${({ theme }) => theme.palette.text.primary}; + & svg { + color: ${({ theme }) => theme.palette.primary.main}; + flex-shrink: 0; + } +`; + +const StyledMouse = styled('img')` + position: absolute; + right: ${({ theme }) => theme.spacing(2)}; + bottom: -15px; + height: 190px; + pointer-events: none; + user-select: none; + @media ${SPLIT_CONTENT_BREAK_POINT} { + display: none; + } +`; + +export const CommunityTranslationBanner = () => { + return ( + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + +
+ +
+
+ ); +}; diff --git a/webapp/src/views/projects/public/PublicProjectListView.tsx b/webapp/src/views/projects/public/PublicProjectListView.tsx new file mode 100644 index 00000000000..cd378ec4a91 --- /dev/null +++ b/webapp/src/views/projects/public/PublicProjectListView.tsx @@ -0,0 +1,75 @@ +import { T, useTranslate } from '@tolgee/react'; +import { styled } from '@mui/material'; + +import { EmptyListMessage } from 'tg.component/common/EmptyListMessage'; +import SearchField from 'tg.component/common/form/fields/SearchField'; +import { useWindowTitle } from 'tg.hooks/useWindowTitle'; +import { ProjectsList } from 'tg.views/projects/ProjectsList'; +import { usePublicProjectsList } from 'tg.views/projects/usePublicProjectsList'; +import { CommunityTranslationBanner } from './CommunityTranslationBanner'; +import { PublicTopBar } from './PublicTopBar'; +import { PUBLIC_CONTENT_MAX_WIDTH } from './publicProjectsLayout'; + +const StyledLayout = styled('div')` + display: flex; + flex-direction: column; + min-height: 100vh; + background: ${({ theme }) => theme.palette.background.default}; +`; + +const StyledContent = styled('div')` + container-type: inline-size; + width: 100%; + max-width: ${PUBLIC_CONTENT_MAX_WIDTH}px; + margin: 0 auto; + padding: ${({ theme }) => theme.spacing(4, 2)}; +`; + +const StyledSearch = styled('div')` + display: flex; + padding: ${({ theme }) => theme.spacing(0, 0, 3)}; + & > * { + width: 220px; + } +`; + +export const PublicProjectListView = () => { + const { t } = useTranslate(); + const { loadable, showSearch, search, onSearch, onPageChange } = + usePublicProjectsList(); + + useWindowTitle(t('public_projects_window_title')); + + return ( + + + + + {showSearch && ( + + + + )} + + + + } + /> + + + ); +}; + +export default PublicProjectListView; diff --git a/webapp/src/views/projects/public/PublicTopBar.tsx b/webapp/src/views/projects/public/PublicTopBar.tsx new file mode 100644 index 00000000000..ccd9dd86bba --- /dev/null +++ b/webapp/src/views/projects/public/PublicTopBar.tsx @@ -0,0 +1,89 @@ +import { Link } from 'react-router-dom'; +import { Box, Button, styled, Toolbar, Typography, useTheme } from '@mui/material'; +import AppBar from '@mui/material/AppBar'; +import { T } from '@tolgee/react'; + +import { useConfig } from 'tg.globalContext/helpers'; +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; +import { TolgeeLogo } from 'tg.component/common/icons/TolgeeLogo'; +import { LanguageMenu } from 'tg.component/layout/TopBar/LanguageMenu'; +import { UserMenu } from 'tg.component/security/UserMenu/UserMenu'; +import { LINKS } from 'tg.constants/links'; + +const StyledAppBar = styled(AppBar)(({ theme }) => ({ + zIndex: theme.zIndex.drawer + 1, + background: theme.palette.navbar.background, + color: theme.palette.text.primary, + boxShadow: + theme.palette.mode === 'light' + ? '0px 4px 6px 0px rgba(0, 0, 0, 0.02)' + : 'none', +})); + +const StyledToolbar = styled(Toolbar)` + padding-right: 12.5px !important; + padding-left: 12.5px !important; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledLogoTitle = styled(Typography)` + font-size: 20px; + font-weight: 500; + font-family: Righteous, Rubik, Arial, sans-serif; +`; + +const StyledTolgeeLink = styled(Link)` + color: ${({ theme }) => theme.palette.navbar.text}; + text-decoration: inherit; + outline: 0; +`; + +export const PublicTopBar = () => { + const theme = useTheme(); + const config = useConfig(); + const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); + + return ( + + + + + + + + {config.appName} + + + + + + {allowPrivate ? ( + + ) : ( + <> + + + + )} + + + ); +}; diff --git a/webapp/src/views/projects/public/publicProjectsLayout.ts b/webapp/src/views/projects/public/publicProjectsLayout.ts new file mode 100644 index 00000000000..b1da0094514 --- /dev/null +++ b/webapp/src/views/projects/public/publicProjectsLayout.ts @@ -0,0 +1 @@ +export const PUBLIC_CONTENT_MAX_WIDTH = 1000; From cfbc0c69c359bd7109cf1d0579e0e8245233bca2 Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Thu, 2 Jul 2026 19:54:40 +0200 Subject: [PATCH 7/8] feat: community projects page and switcher navigation entry Projects-page redesign lane of Community Translation v1.0 (#3763). - Projects list header: centered "Projects" title with the org switcher relocated next to it (standaloneTitle: hides the separator, adds top spacing, stacks search on its own row). Every non-list page keeps the top-left switcher. - Synthetic "Community translation" switcher entry navigating to /community-projects (does not switch org). Implemented but temporarily disabled pending contributor tracking; its e2e tests are skipped with a re-enable TODO. - /community-projects page: projects-list shell that ignores the selected org and lists all public projects, email-verification gated, with the community-translation banner on top (per designer). - DashboardProjectListItem gains a public grid variant that drops the controls column entirely (no reserved space); default layout title width 150px -> 180px, smaller breakpoints unchanged. - New i18n keys created in "Tolgee itself"; default values stripped. --- e2e/cypress/e2e/administration/base.cy.ts | 2 +- .../communityProjectsNavigation.cy.ts | 267 ++++++++++++++++++ e2e/cypress/support/dataCyType.d.ts | 3 + webapp/src/component/RootRouter.tsx | 4 + .../component/SwitchPopover/SwitchPopover.tsx | 28 +- webapp/src/component/layout/BaseView.tsx | 2 +- webapp/src/component/layout/HeaderBar.tsx | 126 ++++++--- .../CommunityTranslationItem.tsx | 22 ++ .../OrganizationPopover.tsx | 16 +- .../organizationSwitch/OrganizationSwitch.tsx | 77 ++--- webapp/src/constants/links.tsx | 2 + .../views/projects/CommunityProjectsView.tsx | 74 +++++ .../projects/DashboardProjectListItem.tsx | 55 ++-- webapp/src/views/projects/ProjectListView.tsx | 25 +- .../projects/useLatchedSearchVisibility.ts | 23 ++ .../views/projects/usePublicProjectsList.ts | 35 +++ 16 files changed, 647 insertions(+), 114 deletions(-) create mode 100644 e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts create mode 100644 webapp/src/component/organizationSwitch/CommunityTranslationItem.tsx create mode 100644 webapp/src/views/projects/CommunityProjectsView.tsx create mode 100644 webapp/src/views/projects/useLatchedSearchVisibility.ts create mode 100644 webapp/src/views/projects/usePublicProjectsList.ts diff --git a/e2e/cypress/e2e/administration/base.cy.ts b/e2e/cypress/e2e/administration/base.cy.ts index 6c3cedf1c5c..7d356028a49 100644 --- a/e2e/cypress/e2e/administration/base.cy.ts +++ b/e2e/cypress/e2e/administration/base.cy.ts @@ -48,7 +48,7 @@ describe('Administration', () => { .findDcy('administration-organizations-projects-button') .click(); assertAdminFrameVisible(); - gcy('navigation-item').contains('Projects'); + gcy('global-base-view-title').contains('Projects'); }); it('can access organization settings', () => { diff --git a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts new file mode 100644 index 00000000000..ede478f4c7d --- /dev/null +++ b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts @@ -0,0 +1,267 @@ +import { HOST } from '../../common/constants'; +import { + createUser, + deleteAllEmails, + deleteUserSql, + disableEmailVerification, + enableEmailVerification, + login, + logout, + setBypassSeatCountCheck, +} from '../../common/apiCalls/common'; +import { + organizationTestData, + publicProjectsData, +} from '../../common/apiCalls/testData/testData'; +import { gcy, gcyAdvanced, switchToOrganization } from '../../common/shared'; +import { waitForGlobalLoading } from '../../common/loading'; + +describe('Community projects navigation', () => { + let organizationData: Record; + + beforeEach(() => { + setBypassSeatCountCheck(true); + login(); + organizationTestData.clean({ timeout: 120000 }); + organizationTestData.generate().then((res) => { + organizationData = res.body as any; + visitProjects(); + }); + }); + + afterEach(() => { + organizationTestData.clean(); + setBypassSeatCountCheck(false); + }); + + const visitProjects = () => { + cy.visit(`${HOST}/projects`); + cy.waitForDom(); + }; + + const openSwitch = () => { + cy.waitForDom(); + gcy('organization-switch').click(); + cy.waitForDom(); + }; + + const visitCommunity = () => { + cy.visit(`${HOST}/community-projects`); + cy.waitForDom(); + }; + + // TODO: the "Community translation" switcher entry is currently disabled (it will return once + // contributor tracking lands in a future pitch). Re-enable these five skipped tests — the ones + // that drive `switch-popover-footer-action` — when the button is added back. + it.skip('navigates to the community page via the dropdown entry (mouse)', () => { + openSwitch(); + gcyAdvanced({ + value: 'switch-popover-footer-action', + action: 'organization-switch-community', + }).click(); + cy.location('pathname').should('eq', '/community-projects'); + }); + + it.skip('navigates to the community page via the dropdown entry (keyboard)', () => { + openSwitch(); + gcyAdvanced({ + value: 'switch-popover-footer-action', + action: 'organization-switch-community', + }) + .focus() + .type('{enter}'); + cy.location('pathname').should('eq', '/community-projects'); + }); + + it('shows the community chrome: banner, community chip, no add button', () => { + visitCommunity(); + gcy('community-projects-view').should('be.visible'); + gcy('community-translation-banner').should('be.visible'); + gcy('organization-switch') + .findDcy('community-translation-item') + .should('be.visible'); + gcy('global-plus-button').should('not.exist'); + }); + + it('returns to the org list when selecting an org from the community switcher', () => { + visitCommunity(); + switchToOrganization('Microsoft'); + cy.location('pathname').should('eq', '/'); + cy.waitForDom(); + gcy('organization-switch').contains('Microsoft').should('be.visible'); + + cy.reload(); + cy.waitForDom(); + gcy('organization-switch').contains('Microsoft').should('be.visible'); + }); + + it('does not navigate away when selecting an org from the normal list switcher', () => { + switchToOrganization('Microsoft'); + cy.location('pathname').should('eq', '/'); + }); + + it('renders the Projects title with the inline switcher on both list pages', () => { + gcy('global-base-view-title').contains('Projects').should('be.visible'); + gcy('global-base-view-title') + .findDcy('organization-switch') + .should('be.visible'); + + visitCommunity(); + gcy('global-base-view-title').contains('Projects').should('be.visible'); + gcy('global-base-view-title') + .findDcy('organization-switch') + .should('be.visible'); + }); + + it.skip('highlights no org row while on the community page but still offers the community entry', () => { + visitCommunity(); + openSwitch(); + gcy('switch-popover-item').should('exist'); + gcy('switch-popover-item').filter('.Mui-selected').should('not.exist'); + gcyAdvanced({ + value: 'switch-popover-footer-action', + action: 'organization-switch-community', + }).should('be.visible'); + }); + + it.skip('offers the community entry from a switcher outside the projects pages', () => { + cy.visit( + `${HOST}/organizations/${organizationData['Tolgee'].slug}/members` + ); + openSwitch(); + gcy('switch-popover-item').should('exist'); + gcyAdvanced({ + value: 'switch-popover-footer-action', + action: 'organization-switch-community', + }).should('be.visible'); + }); + + it('shows the empty state and hides search when there are no public projects', () => { + publicProjectsData.clean(); + visitCommunity(); + waitForGlobalLoading(); + gcy('community-projects-view').should('be.visible'); + gcy('global-paginated-list').should('be.visible'); + gcy('dashboard-projects-list-item').should('not.exist'); + gcy('global-list-search').should('not.exist'); + }); + + it.skip('closes the popover on Escape from the footer entry', () => { + openSwitch(); + gcyAdvanced({ + value: 'switch-popover-footer-action', + action: 'organization-switch-community', + }) + .focus() + .type('{esc}'); + gcy('switch-popover-item').should('not.exist'); + gcyAdvanced({ + value: 'switch-popover-footer-action', + action: 'organization-switch-community', + }).should('not.exist'); + }); +}); + +describe('Community projects email-verification gate', () => { + const email = 'community-projects-unverified@doe.com'; + const password = 'verysecurepassword'; + const changedEmail = 'community-projects-changed@doe.com'; + + beforeEach(() => { + enableEmailVerification(); + deleteUserSql(email); + deleteUserSql(changedEmail); + deleteAllEmails(); + createUser(email, password); + login(email, password); + // Changing the email while verification is enabled puts the authenticated session into the + // awaiting-verification state, which is what unverified users see. + cy.visit(`${HOST}/account/profile`); + cy.get('form').findInputByName('email').clear().type(changedEmail); + cy.xpath("//*[@name='currentPassword']").clear().type(password); + gcy('global-form-save-button').click(); + waitForGlobalLoading(); + }); + + afterEach(() => { + deleteUserSql(email); + deleteUserSql(changedEmail); + deleteAllEmails(); + disableEmailVerification(); + }); + + it('shows EmailNotVerifiedView instead of the switcher for unverified users', () => { + cy.visit(`${HOST}/community-projects`); + waitForGlobalLoading(); + gcy('resend-email-button').should('be.visible'); + gcy('organization-switch').should('not.exist'); + }); +}); + +describe('Community projects list content', () => { + beforeEach(() => { + publicProjectsData.clean(); + publicProjectsData.generateStandard(); + login('publicProjectsUser'); + cy.visit(`${HOST}/community-projects`); + waitForGlobalLoading(); + }); + + afterEach(() => { + publicProjectsData.clean(); + }); + + it('lists public projects across orgs with the public badge, hiding private ones', () => { + gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('project-list-public-badge').should('have.length', 6); + cy.contains('Community Alpha').should('be.visible'); + cy.contains('Community Zeta').should('be.visible'); + cy.contains('Private project').should('not.exist'); + }); + + it('narrows the community list with search', () => { + gcy('global-list-search').should('exist'); + gcy('global-list-search').find('input').type('Alpha'); + waitForGlobalLoading(); + gcy('dashboard-projects-list-item').should('have.length', 1); + cy.contains('Community Alpha').should('be.visible'); + }); + + it('keeps the search field visible after clearing an active search', () => { + gcy('global-list-search').find('input').type('Alpha'); + waitForGlobalLoading(); + gcy('dashboard-projects-list-item').should('have.length', 1); + gcy('global-list-search').find('input').clear(); + // The length-6 assertion must come first: it retries until the debounced cleared-search + // refetch lands, so the focus check below runs only after the field has (or hasn't) remounted. + gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('global-list-search').find('input').should('be.focused'); + }); +}); + +describe('Community projects search threshold', () => { + beforeEach(() => { + publicProjectsData.clean(); + publicProjectsData.generateFew(); + login('publicProjectsUser'); + cy.visit(`${HOST}/community-projects`); + waitForGlobalLoading(); + }); + + afterEach(() => { + publicProjectsData.clean(); + }); + + it('hides the search field at or below the project threshold', () => { + gcy('dashboard-projects-list-item').should('have.length', 5); + gcy('global-list-search').should('not.exist'); + }); +}); + +describe('Community projects access', () => { + it('redirects an unauthenticated visitor to login', () => { + logout(); + cy.visit(`${HOST}/community-projects`); + cy.location('pathname').should('include', '/login'); + }); +}); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 5054e66443e..6feb334aafa 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -146,7 +146,9 @@ declare namespace DataCy { "comment-menu-needs-resolution": true; "comment-resolve": true; "comment-text": true; + "community-projects-view": true; "community-translation-banner": true; + "community-translation-item": true; "content-delivery-add-button": true; "content-delivery-auto-publish-checkbox": true; "content-delivery-delete-button": true; @@ -728,6 +730,7 @@ declare namespace DataCy { "suggestion-action": true; "suggestions-list": true; "suggestions-show-all": true; + "switch-popover-footer-action": true; "switch-popover-item": true; "switch-popover-new": true; "switch-popover-search": true; diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index 2a9ddf67dd9..2e7fb539737 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -3,6 +3,7 @@ import { Redirect, Route, Switch } from 'react-router-dom'; import { LINKS } from 'tg.constants/links'; import { ProjectsRouter } from 'tg.views/projects/ProjectsRouter'; +import { CommunityProjectsView } from 'tg.views/projects/CommunityProjectsView'; import { UserSettingsRouter } from 'tg.views/userSettings/UserSettingsRouter'; import { OrganizationsRouter } from 'tg.views/organizations/OrganizationsRouter'; import { AdministrationView } from 'tg.views/administration/AdministrationView'; @@ -110,6 +111,9 @@ export const RootRouter = () => { + + + diff --git a/webapp/src/component/SwitchPopover/SwitchPopover.tsx b/webapp/src/component/SwitchPopover/SwitchPopover.tsx index dd696268e4b..a6229937cc1 100644 --- a/webapp/src/component/SwitchPopover/SwitchPopover.tsx +++ b/webapp/src/component/SwitchPopover/SwitchPopover.tsx @@ -10,6 +10,7 @@ import { styled, Typography, Button, + Divider, } from '@mui/material'; import { Plus } from '@untitled-ui/icons-react'; import { useTranslate } from '@tolgee/react'; @@ -65,7 +66,7 @@ type SwitchPopoverProps = { onClose: () => void; onSelect: (item: T) => void; anchorEl: HTMLElement; - selectedId: number; + selectedId?: number; // Data items: T[]; @@ -84,6 +85,12 @@ type SwitchPopoverProps = { onAddNew?: () => void; addNewTooltip?: string; + footerAction?: { + content: React.ReactNode; + onClick: () => void; + dataCyAction?: string; + }; + // Search callback for a parent to handle onSearchChange: (search: string) => void; }; @@ -105,6 +112,7 @@ export function SwitchPopover({ searchThreshold = DEFAULT_SEARCH_THRESHOLD, onAddNew, addNewTooltip, + footerAction, onSearchChange, }: SwitchPopoverProps) { const [inputValue, setInputValue] = useState(''); @@ -222,6 +230,24 @@ export function SwitchPopover({ )} /> + {footerAction && ( + <> + + + { + footerAction.onClick(); + onClose(); + }} + > + {footerAction.content} + + + + )} ); diff --git a/webapp/src/component/layout/BaseView.tsx b/webapp/src/component/layout/BaseView.tsx index 1febaf63d1c..409447676e5 100644 --- a/webapp/src/component/layout/BaseView.tsx +++ b/webapp/src/component/layout/BaseView.tsx @@ -91,7 +91,7 @@ export const BaseView: FC> = (props) => {
)} - + void; searchPlaceholder?: string; customHeader?: ReactNode; + standaloneTitle?: boolean; switcher?: ReactNode; maxWidth?: BaseViewWidth; initialSearch?: string; @@ -51,58 +52,99 @@ export const HeaderBar: React.VFC = (props) => { if (props.headerBarDisable || !displayHeader) { return null; } + + const hasSearch = typeof props.onSearch === 'function'; + const stackedSearch = Boolean(props.standaloneTitle) && hasSearch; + + const titleContent = ( + + {props.title !== undefined && ( + + {props.title} + + )} + {props.titleAdornment} + + ); + + const searchField = hasSearch && ( + + + + ); + + const switcherAndButtons = ( + + {props.switcher && ( + + {props.switcher} + + )} + {props.customButtons && + props.customButtons.map((button, index) => ( + + {button} + + ))} + + ); + + const addButton = props.addComponent + ? props.addComponent + : (props.onAdd || props.addLinkTo) && ( + + ); + return ( - {props.customHeader || ( - - - {props.title !== undefined && ( - - {props.title} - - )} - {props.titleAdornment} - {typeof props.onSearch === 'function' && ( - - - - )} + {props.customHeader || + (stackedSearch ? ( + + + {titleContent} + {switcherAndButtons} + + + {searchField} + {addButton} + - - {props.switcher && ( - - {props.switcher} - - )} - {props.customButtons && - props.customButtons.map((button, index) => ( - - {button} - - ))} - {props.addComponent - ? props.addComponent - : (props.onAdd || props.addLinkTo) && ( - - )} + ) : ( + + + {titleContent} + {searchField} + + + {switcherAndButtons} + {addButton} + - - )} + ))} ); diff --git a/webapp/src/component/organizationSwitch/CommunityTranslationItem.tsx b/webapp/src/component/organizationSwitch/CommunityTranslationItem.tsx new file mode 100644 index 00000000000..2bf7e1aef2e --- /dev/null +++ b/webapp/src/component/organizationSwitch/CommunityTranslationItem.tsx @@ -0,0 +1,22 @@ +import { Box, styled } from '@mui/material'; +import { Users01 } from '@untitled-ui/icons-react'; +import { useTranslate } from '@tolgee/react'; + +const StyledItem = styled('div')` + display: grid; + grid-auto-flow: column; + gap: ${({ theme }) => theme.spacing(0.75)}; + align-items: center; +`; + +export const CommunityTranslationItem = () => { + const { t } = useTranslate(); + return ( + + + + + {t('community_translation_switch_item')} + + ); +}; diff --git a/webapp/src/component/organizationSwitch/OrganizationPopover.tsx b/webapp/src/component/organizationSwitch/OrganizationPopover.tsx index 9c8004df2f1..966bcabef82 100644 --- a/webapp/src/component/organizationSwitch/OrganizationPopover.tsx +++ b/webapp/src/component/organizationSwitch/OrganizationPopover.tsx @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import { useTranslate } from '@tolgee/react'; import { OrganizationItem } from './OrganizationItem'; +import { CommunityTranslationItem } from './CommunityTranslationItem'; import { components } from 'tg.service/apiSchema.generated'; import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; import { useConfig, useIsAdmin } from 'tg.globalContext/helpers'; @@ -17,6 +18,8 @@ type Props = { selected: OrganizationModel | undefined; onAddNew: () => void; ownedOnly?: boolean; + onCommunityNavigate?: () => void; + communitySelected?: boolean; }; export const OrganizationPopover: React.FC> = ({ @@ -27,6 +30,8 @@ export const OrganizationPopover: React.FC> = ({ selected, onAddNew, ownedOnly, + onCommunityNavigate, + communitySelected, }) => { const { t } = useTranslate(); const [search, setSearch] = useState(''); @@ -87,7 +92,7 @@ export const OrganizationPopover: React.FC> = ({ onClose={onClose} onSelect={onSelect} anchorEl={anchorEl} - selectedId={selected.id} + selectedId={communitySelected ? undefined : selected.id} items={items || []} isLoading={organizationsLoadable.isFetching} hasNextPage={organizationsLoadable.hasNextPage ?? false} @@ -99,6 +104,15 @@ export const OrganizationPopover: React.FC> = ({ onSearchChange={handleSearchChange} onAddNew={canCreateOrganizations ? onAddNew : undefined} addNewTooltip={t('organizations_add_new')} + footerAction={ + onCommunityNavigate + ? { + content: , + onClick: onCommunityNavigate, + dataCyAction: 'organization-switch-community', + } + : undefined + } /> ); }; diff --git a/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx b/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx index 8813c62f3ce..b0418891751 100644 --- a/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx +++ b/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx @@ -4,6 +4,7 @@ import { ArrowDropDown } from 'tg.component/CustomIcons'; import { components } from 'tg.service/apiSchema.generated'; import { OrganizationItem } from './OrganizationItem'; +import { CommunityTranslationItem } from './CommunityTranslationItem'; import { useHistory } from 'react-router-dom'; import { LINKS } from 'tg.constants/links'; import { usePreferredOrganization } from 'tg.globalContext/helpers'; @@ -11,23 +12,36 @@ import { OrganizationPopover } from './OrganizationPopover'; type OrganizationModel = components['schemas']['OrganizationModel']; -const StyledLink = styled(Link)` +export type SwitchSurface = 'organization' | 'community'; + +const StyledLink = styled(Link, { + shouldForwardProp: (prop) => prop !== 'plain', +})<{ plain?: boolean }>` display: flex; + align-items: center; + flex-wrap: wrap; + flex-shrink: 1; + cursor: pointer; + ${({ plain, theme }) => plain && `color: ${theme.palette.text.primary};`} `; type Props = { onSelect?: (organization: OrganizationModel) => void; ownedOnly?: boolean; + selectedSurface?: SwitchSurface; + plain?: boolean; }; export const OrganizationSwitch: React.FC> = ({ onSelect, ownedOnly, + selectedSurface = 'organization', + plain, }) => { const anchorEl = useRef(null); const [isOpen, setIsOpen] = useState(false); - const { preferredOrganization } = usePreferredOrganization(); - const { updatePreferredOrganization } = usePreferredOrganization(); + const { preferredOrganization, updatePreferredOrganization } = + usePreferredOrganization(); const history = useHistory(); const handleClose = () => { @@ -49,40 +63,31 @@ export const OrganizationSwitch: React.FC> = ({ history.push(LINKS.ORGANIZATIONS_ADD.build()); }; + const isCommunitySurface = selectedSurface === 'community'; + + const switchLabel = isCommunitySurface ? ( + + ) : preferredOrganization ? ( + + ) : null; + return ( - <> - - - {preferredOrganization && ( - - )} - - + + + {switchLabel} + + - - - + + ); }; diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 49325c29bca..8774b7de695 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -297,6 +297,8 @@ export class LINKS { static PUBLIC_PROJECTS = Link.ofRoot('public-projects'); + static COMMUNITY_PROJECTS = Link.ofRoot('community-projects'); + /** * Visible with view permissions */ diff --git a/webapp/src/views/projects/CommunityProjectsView.tsx b/webapp/src/views/projects/CommunityProjectsView.tsx new file mode 100644 index 00000000000..851fea5a2a4 --- /dev/null +++ b/webapp/src/views/projects/CommunityProjectsView.tsx @@ -0,0 +1,74 @@ +import { styled } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { useHistory } from 'react-router-dom'; + +import { EmailNotVerifiedView } from 'tg.component/EmailNotVerifiedView'; +import { EmptyListMessage } from 'tg.component/common/EmptyListMessage'; +import { BaseView } from 'tg.component/layout/BaseView'; +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { OrganizationSwitch } from 'tg.component/organizationSwitch/OrganizationSwitch'; +import { LINKS } from 'tg.constants/links'; +import { useIsEmailVerified } from 'tg.globalContext/helpers'; +import { ProjectsList } from 'tg.views/projects/ProjectsList'; +import { CommunityTranslationBanner } from 'tg.views/projects/public/CommunityTranslationBanner'; +import { usePublicProjectsList } from 'tg.views/projects/usePublicProjectsList'; +import { CriticalUsageCircle } from 'tg.ee'; + +// Flex column so the full-width banner keeps its content height — the surrounding grid layouts +// (DashboardPage main + BaseView content) stretch a bare second grid row to fill the viewport. +const StyledContent = styled('div')` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(3)}; +`; + +const CommunityProjects = () => { + const { t } = useTranslate(); + const history = useHistory(); + const { loadable, showSearch, onSearch, onPageChange } = + usePublicProjectsList(); + + return ( + + history.push(LINKS.ROOT.build())} + /> + } + onSearch={showSearch ? onSearch : undefined} + searchPlaceholder={t('projects_search_placeholder')} + maxWidth={1000} + allCentered + hideChildrenOnLoading={false} + customButtons={[]} + loading={loadable.isFetching} + > + + + + + + } + /> + + + + ); +}; + +export const CommunityProjectsView = () => { + const isEmailVerified = useIsEmailVerified(); + return isEmailVerified ? : ; +}; diff --git a/webapp/src/views/projects/DashboardProjectListItem.tsx b/webapp/src/views/projects/DashboardProjectListItem.tsx index a420c4caf02..d3aafabc8e7 100644 --- a/webapp/src/views/projects/DashboardProjectListItem.tsx +++ b/webapp/src/views/projects/DashboardProjectListItem.tsx @@ -24,10 +24,18 @@ import { CircledLanguageIconList } from 'tg.component/languages/CircledLanguageI import { TransparentChip } from 'tg.component/common/chips/TransparentChip'; import { QaBadge } from 'tg.ee'; -const StyledContainer = styled('div')` +const StyledContainer = styled('div', { + shouldForwardProp: (prop) => prop !== 'isPublicVariant', +})<{ isPublicVariant?: boolean }>` display: grid; - grid-template-columns: calc(${({ theme }) => theme.spacing(2)} + 50px) 150px 100px 5fr 1.5fr 70px; - grid-template-areas: 'image title keyCount stats languages controls'; + grid-template-columns: ${({ theme, isPublicVariant }) => + isPublicVariant + ? `calc(${theme.spacing(2)} + 50px) 180px 100px 5fr 1.5fr` + : `calc(${theme.spacing(2)} + 50px) 150px 100px 5fr 1.5fr 70px`}; + grid-template-areas: ${({ isPublicVariant }) => + isPublicVariant + ? "'image title keyCount stats languages'" + : "'image title keyCount stats languages controls'"}; padding: ${({ theme }) => theme.spacing(3, 2.5)}; cursor: pointer; background-color: ${({ theme }) => theme.palette.background.default}; @@ -43,20 +51,21 @@ const StyledContainer = styled('div')` } } @container (max-width: 850px) { - grid-template-columns: auto 1fr 1fr 70px; - grid-template-areas: - 'image title keyCount controls' - 'image title languages controls' - 'image stats stats stats'; + grid-template-columns: ${({ isPublicVariant }) => + isPublicVariant ? 'auto 1fr 1fr' : 'auto 1fr 1fr 70px'}; + grid-template-areas: ${({ isPublicVariant }) => + isPublicVariant + ? "'image title keyCount' 'image title languages' 'image stats stats'" + : "'image title keyCount controls' 'image title languages controls' 'image stats stats stats'"}; } @container (max-width: 599px) { grid-gap: ${({ theme }) => theme.spacing(1, 2)}; - grid-template-columns: auto 1fr 70px; - grid-template-areas: - 'image title controls' - 'image keyCount controls' - 'languages languages languages' - 'stats stats stats'; + grid-template-columns: ${({ isPublicVariant }) => + isPublicVariant ? 'auto 1fr' : 'auto 1fr 70px'}; + grid-template-areas: ${({ isPublicVariant }) => + isPublicVariant + ? "'image title' 'image keyCount' 'languages languages' 'stats stats'" + : "'image title controls' 'image keyCount controls' 'languages languages languages' 'stats stats stats'"}; } `; @@ -150,7 +159,6 @@ const DashboardProjectListItem = ({ variant = 'default', ...p }: Props) => { const isPublicVariant = variant === 'public'; const { t } = useTranslate(); const history = useHistory(); - const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); const rightPanelWidth = useGlobalContext((c) => c.layout.rightPanelWidth); const isCompact = useMediaQuery( `@media(max-width: ${rightPanelWidth + 800}px)` @@ -165,13 +173,12 @@ const DashboardProjectListItem = ({ variant = 'default', ...p }: Props) => { const content = ( history.push( - isPublicVariant && !allowPrivate - ? LINKS.LOGIN.build() - : LINKS.PROJECT_DASHBOARD.build({ - [PARAMS.PROJECT_ID]: p.id, - }) + LINKS.PROJECT_DASHBOARD.build({ + [PARAMS.PROJECT_ID]: p.id, + }) ) } > @@ -222,8 +229,8 @@ const DashboardProjectListItem = ({ variant = 'default', ...p }: Props) => { - - {!isPublicVariant && ( + {!isPublicVariant && ( + {showQaBadge ? ( @@ -265,8 +272,8 @@ const DashboardProjectListItem = ({ variant = 'default', ...p }: Props) => { projectName={p.name} /> - )} - + + )} ); diff --git a/webapp/src/views/projects/ProjectListView.tsx b/webapp/src/views/projects/ProjectListView.tsx index 368c3b4faa3..2ce81b8f0a2 100644 --- a/webapp/src/views/projects/ProjectListView.tsx +++ b/webapp/src/views/projects/ProjectListView.tsx @@ -16,6 +16,7 @@ import { usePreferredOrganization, } from 'tg.globalContext/helpers'; import { OrganizationSwitch } from 'tg.component/organizationSwitch/OrganizationSwitch'; +import { useLatchedSearchVisibility } from 'tg.views/projects/useLatchedSearchVisibility'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; import { CriticalUsageCircle } from 'tg.ee'; @@ -51,14 +52,26 @@ export const ProjectListView = () => { const addAllowed = isOrganizationOwnerOrMaintainer || isAdminAccess; - const showSearch = - search || (listPermitted.data?.page?.totalElements ?? 0) > 5; + const showSearch = useLatchedSearchVisibility( + listPermitted.data?.page?.totalElements, + search + ); return ( } + standaloneTitle + onSearch={ + showSearch + ? (value) => { + setSearch(value); + setPage(0); + } + : undefined + } searchPlaceholder={t('projects_search_placeholder')} maxWidth={1000} allCentered @@ -74,11 +87,7 @@ export const ProjectListView = () => { } addLabel={t('projects_add_button')} hideChildrenOnLoading={false} - navigation={[ - [], - [t('projects_title'), LINKS.PROJECTS.build()], - ]} - navigationRight={} + customButtons={[]} loading={listPermitted.isFetching} > { + const relevant = + Boolean(search) || (totalElements ?? 0) > MAX_PROJECTS_WITHOUT_SEARCH; + const [latched, setLatched] = useState(false); + useEffect(() => { + if (relevant) { + setLatched(true); + } + }, [relevant]); + return relevant || latched; +}; diff --git a/webapp/src/views/projects/usePublicProjectsList.ts b/webapp/src/views/projects/usePublicProjectsList.ts new file mode 100644 index 00000000000..35d8340109f --- /dev/null +++ b/webapp/src/views/projects/usePublicProjectsList.ts @@ -0,0 +1,35 @@ +import { useState } from 'react'; + +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { useLatchedSearchVisibility } from 'tg.views/projects/useLatchedSearchVisibility'; + +export const usePublicProjectsList = () => { + const [page, setPage] = useState(0); + const [search, setSearch] = useState(''); + + const loadable = useApiQuery({ + url: '/v2/public/projects/with-stats', + method: 'get', + query: { + page, + size: 20, + search, + sort: ['name,asc'], + }, + options: { + keepPreviousData: true, + }, + }); + + const showSearch = useLatchedSearchVisibility( + loadable.data?.page?.totalElements, + search + ); + + const onSearch = (value: string) => { + setSearch(value); + setPage(0); + }; + + return { loadable, showSearch, search, onSearch, onPageChange: setPage }; +}; From d42feb8b2c23c8495be9fbb7139858cdb54dcfd0 Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Thu, 2 Jul 2026 23:03:10 +0200 Subject: [PATCH 8/8] feat: add translation-suggestions.manage permission scope (#3785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a new permission scope **`translation-suggestions.manage`** that lets project members delete translation suggestions authored by **other users**. Previously a community suggestion could be deleted only by its author, so there was no way for project members to clean up spam / bad suggestions. Part of Community Translation v1.0 (#3763). Targets the `jirikuchynka/public-projects` integration branch. ## Details - **New scope** `translation-suggestions.manage` (enum `TRANSLATION_SUGGESTIONS_MANAGE`), following the `translation-labels.manage` sub-entity naming convention. Granted to the **EDIT** preset (and MANAGE via ADMIN). - **Own per-language dimension** `suggestManageLanguages` (new `@ManyToMany` join table `permission_suggest_manage_languages` + migration with FK constraints), so a moderator can be restricted to specific languages — wired end-to-end incl. the language-deletion cleanup + de-escalation path. - **Granular editor:** `Suggest` and `Manage` are grouped under a new **"Suggestions"** category, each with its own language selector. - **Enforcement:** deleting another user's suggestion is gated on the scope + language (like the sibling suggestion actions); the author fast-path (delete your own) is unchanged. The list endpoint now accepts `suggest` **OR** `manage` so moderators can see the suggestions they can act on. ## Behavior changes to note - **Permission change:** on merge, existing **EDIT/MANAGE** project members gain the ability to delete other users' suggestions. - **Delete-only:** the scope gates deletion only; `decline`/`accept`/`set-active` intentionally remain on `translations.state-edit`. - i18n keys created in the Tolgee project (`permissions_item_translations_suggestions`, `permissions_item_translations_suggestions_manage`, `permission_type_suggest_manage`), tagged `draft: suggestion-moderation`. ## Follow-up / not in this PR - Consolidating the 5 parallel language dimensions into one scope-discriminated table (accepted architecture debt). - Deferred tests: PAK/API-key delete, community-boundary integration, FE Cypress e2e. --- .../api/v2/controllers/ApiKeyController.kt | 1 + .../hateoas/apiKey/ApiKeyPermissionsModel.kt | 1 + .../hateoas/permission/IPermissionModel.kt | 8 + .../hateoas/permission/PermissionModel.kt | 1 + .../permission/PermissionModelAssembler.kt | 1 + .../permission/PermissionWithAgencyModel.kt | 1 + .../PermissionWithAgencyModelAssembler.kt | 1 + .../CommunityPermissionComputationTest.kt | 1 + .../service/LanguageDeletePermissionTest.kt | 49 +++++ .../data/LanguagePermissionsTestData.kt | 41 ++++ .../data/SuggestionsTestData.kt | 69 +++++++ .../io/tolgee/dtos/ComputedPermissionDto.kt | 8 + .../io/tolgee/dtos/cacheable/IPermission.kt | 1 + .../io/tolgee/dtos/cacheable/PermissionDto.kt | 3 + .../organization/BasePermissionView.kt | 1 + .../request/project/LanguagePermissions.kt | 1 + .../request/project/ProjectInviteUserDto.kt | 1 + .../project/RequestWithLanguagePermissions.kt | 3 + .../project/SetPermissionLanguageParams.kt | 1 + .../tolgee/facade/ProjectPermissionFacade.kt | 5 + .../main/kotlin/io/tolgee/model/Permission.kt | 15 +- .../model/enums/ProjectPermissionType.kt | 1 + .../kotlin/io/tolgee/model/enums/Scope.kt | 5 + .../tolgee/repository/PermissionRepository.kt | 4 +- .../tolgee/service/CachedPermissionService.kt | 1 + .../LanguageDeletedPermissionUpdater.kt | 16 ++ .../service/security/PermissionService.kt | 2 + .../service/security/SecurityService.kt | 22 ++ .../main/resources/db/changelog/schema.xml | 16 ++ .../unit/CommunityPermissionScopesTest.kt | 28 +++ .../PermissionOrgBaseLanguageGuardTest.kt | 43 ++++ .../v2/controllers/SuggestionController.kt | 44 ++-- .../TranslationSuggestionServiceEeImpl.kt | 15 +- .../AdvancedPermissionControllerTest.kt | 27 +++ .../controllers/SuggestionControllerTest.kt | 192 +++++++++++++++++- .../LanguagePermissionsSummary.tsx | 16 +- .../PermissionsSettings.tsx | 1 + .../PermissionsSettings/hierarchyTools.ts | 1 + .../usePermissionsStructure.ts | 10 +- .../useScopeTranslations.tsx | 3 + webapp/src/fixtures/permissions.ts | 1 + webapp/src/service/apiSchema.generated.ts | 44 +++- .../Suggestions/SuggestionsList.tsx | 8 +- .../Suggestions/TranslationSuggestion.tsx | 9 +- 44 files changed, 679 insertions(+), 43 deletions(-) create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/PermissionOrgBaseLanguageGuardTest.kt diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt index 690a6beb107..b3bca56b935 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt @@ -230,6 +230,7 @@ class ApiKeyController( viewLanguageIds = computed.viewLanguageIds.toNormalizedPermittedLanguageSet(), stateChangeLanguageIds = computed.stateChangeLanguageIds.toNormalizedPermittedLanguageSet(), suggestLanguageIds = computed.suggestLanguageIds.toNormalizedPermittedLanguageSet(), + suggestManageLanguageIds = computed.suggestManageLanguageIds.toNormalizedPermittedLanguageSet(), scopes = securityService.getCurrentPermittedScopes(projectIdNotNull).toTypedArray(), project = simpleProjectModelAssembler.toModel(projectService.get(projectIdNotNull)), ) diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyPermissionsModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyPermissionsModel.kt index ce90484e74d..b951cf558c1 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyPermissionsModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyPermissionsModel.kt @@ -17,6 +17,7 @@ class ApiKeyPermissionsModel( override val translateLanguageIds: Set?, override var stateChangeLanguageIds: Set?, override val suggestLanguageIds: Collection?, + override val suggestManageLanguageIds: Collection?, override var scopes: Array = arrayOf(), @get:Schema( description = diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/IPermissionModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/IPermissionModel.kt index 9b2f551a76c..2de730a8c1a 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/IPermissionModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/IPermissionModel.kt @@ -45,4 +45,12 @@ interface IPermissionModel { example = "[200001, 200004]", ) val suggestLanguageIds: Collection? + + @get:Schema( + description = + "List of languages user can manage suggestions for. If null, managing " + + "suggestions for all languages is permitted.", + example = "[200001, 200004]", + ) + val suggestManageLanguageIds: Collection? } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionModel.kt index 05207383592..57159111c39 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionModel.kt @@ -12,5 +12,6 @@ open class PermissionModel( override val viewLanguageIds: Collection?, override val stateChangeLanguageIds: Collection?, override val suggestLanguageIds: Collection?, + override val suggestManageLanguageIds: Collection?, ) : RepresentationModel(), IDeprecatedPermissionModel diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionModelAssembler.kt index 4db5d10fc24..22be580d45a 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionModelAssembler.kt @@ -20,6 +20,7 @@ class PermissionModelAssembler : translateLanguageIds = entity.translateLanguageIds, stateChangeLanguageIds = entity.stateChangeLanguageIds, suggestLanguageIds = entity.suggestLanguageIds, + suggestManageLanguageIds = entity.suggestManageLanguageIds, type = entity.type, ) } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionWithAgencyModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionWithAgencyModel.kt index fdc4afedc4d..81499a3d8c9 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionWithAgencyModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionWithAgencyModel.kt @@ -13,6 +13,7 @@ class PermissionWithAgencyModel( override val viewLanguageIds: Collection?, override val stateChangeLanguageIds: Collection?, override val suggestLanguageIds: Collection?, + override val suggestManageLanguageIds: Collection?, val agency: TranslationAgencySimpleModel?, ) : RepresentationModel(), IDeprecatedPermissionModel diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionWithAgencyModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionWithAgencyModelAssembler.kt index 292c8aee1c4..b88bf27bcb2 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionWithAgencyModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/permission/PermissionWithAgencyModelAssembler.kt @@ -18,6 +18,7 @@ class PermissionWithAgencyModelAssembler( stateChangeLanguageIds = entity.stateChangeLanguageIds, viewLanguageIds = entity.viewLanguageIds, suggestLanguageIds = entity.suggestLanguageIds, + suggestManageLanguageIds = entity.suggestManageLanguageIds, type = entity.type, agency = entity.agency?.let { translationAgencySimpleModelAssembler.toModel(it) }, ) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionComputationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionComputationTest.kt index 72d9842e5ce..9f8458a38a1 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionComputationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/CommunityPermissionComputationTest.kt @@ -94,6 +94,7 @@ class CommunityPermissionComputationTest : AbstractSpringTest() { override val translateLanguageIds = setOf(1L) override val stateChangeLanguageIds = setOf(1L) override val suggestLanguageIds = setOf(1L) + override val suggestManageLanguageIds = setOf(1L) override val type = ProjectPermissionType.EDIT override val granular = true } diff --git a/backend/app/src/test/kotlin/io/tolgee/service/LanguageDeletePermissionTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/LanguageDeletePermissionTest.kt index c5809bc9f12..d2eead1f5e0 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/LanguageDeletePermissionTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/LanguageDeletePermissionTest.kt @@ -109,6 +109,55 @@ class LanguageDeletePermissionTest : AbstractSpringTest() { } } + @Test + @Transactional + fun `lowers permissions for suggestions manage (granular)`() { + checkUser(testData.suggestManageScopeUser) { + assertThat(computedPermissions.scopes).doesNotContain(Scope.TRANSLATION_SUGGESTIONS_MANAGE) + assertThat(computedPermissions.scopes) + .containsExactlyInAnyOrder(Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW) + } + } + + @Test + @Transactional + fun `keeps suggestions manage scope when a non-last language is deleted (granular)`() { + checkUser(testData.suggestManageMultiLangUser) { + assertThat(computedPermissions.scopes).contains(Scope.TRANSLATION_SUGGESTIONS_MANAGE) + assertThat(computedPermissions.suggestManageLanguageIds).hasSize(1) + } + } + + @Test + @Transactional + fun `lowers permissions for suggest (granular)`() { + checkUser(testData.suggestScopeUser) { + assertThat(computedPermissions.scopes).doesNotContain(Scope.TRANSLATIONS_SUGGEST) + assertThat(computedPermissions.scopes) + .containsExactlyInAnyOrder(Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW) + } + } + + @Test + @Transactional + fun `keeps suggest scope when a non-last language is deleted (granular)`() { + checkUser(testData.suggestMultiLangUser) { + assertThat(computedPermissions.scopes).contains(Scope.TRANSLATIONS_SUGGEST) + assertThat(computedPermissions.suggestLanguageIds).hasSize(1) + } + } + + @Test + @Transactional + fun `lowers permissions for combined state-edit and suggestions manage (granular)`() { + checkUser(testData.stateChangeAndSuggestManageScopeUser) { + assertThat(computedPermissions.scopes) + .doesNotContain(Scope.TRANSLATIONS_STATE_EDIT, Scope.TRANSLATION_SUGGESTIONS_MANAGE) + assertThat(computedPermissions.scopes) + .containsExactlyInAnyOrder(Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW) + } + } + private fun checkUser( userAccount: UserAccount, checkFn: ProjectPermissionData.() -> Unit, diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/LanguagePermissionsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/LanguagePermissionsTestData.kt index 022eca7de38..242289600f2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/LanguagePermissionsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/LanguagePermissionsTestData.kt @@ -118,6 +118,47 @@ class LanguagePermissionsTestData( stateChangeLanguages = mutableSetOf(englishLanguage) } + val suggestManageScopeUser = + addUserAccountWithPermissions { + scopes = arrayOf(Scope.TRANSLATIONS_VIEW, Scope.TRANSLATION_SUGGESTIONS_MANAGE) + type = null + suggestManageLanguages = mutableSetOf(englishLanguage) + } + + val stateChangeAndSuggestManageScopeUser = + addUserAccountWithPermissions { + scopes = + arrayOf( + Scope.TRANSLATIONS_VIEW, + Scope.TRANSLATIONS_STATE_EDIT, + Scope.TRANSLATION_SUGGESTIONS_MANAGE, + ) + type = null + stateChangeLanguages = mutableSetOf(englishLanguage) + suggestManageLanguages = mutableSetOf(englishLanguage) + } + + val suggestManageMultiLangUser = + addUserAccountWithPermissions { + scopes = arrayOf(Scope.TRANSLATIONS_VIEW, Scope.TRANSLATION_SUGGESTIONS_MANAGE) + type = null + suggestManageLanguages = mutableSetOf(englishLanguage, germanLanguage) + } + + val suggestScopeUser = + addUserAccountWithPermissions { + scopes = arrayOf(Scope.TRANSLATIONS_VIEW, Scope.TRANSLATIONS_SUGGEST) + type = null + suggestLanguages = mutableSetOf(englishLanguage) + } + + val suggestMultiLangUser = + addUserAccountWithPermissions { + scopes = arrayOf(Scope.TRANSLATIONS_VIEW, Scope.TRANSLATIONS_SUGGEST) + type = null + suggestLanguages = mutableSetOf(englishLanguage, germanLanguage) + } + init { projectBuilder.apply { addApiKey { diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt index a8b137e313a..a67b448dc87 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionsTestData.kt @@ -6,8 +6,10 @@ import io.tolgee.development.testDataBuilder.builders.SuggestionBuilder import io.tolgee.development.testDataBuilder.builders.TranslationBuilder import io.tolgee.development.testDataBuilder.builders.UserAccountBuilder import io.tolgee.model.Language +import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.Scope import io.tolgee.model.enums.SuggestionsMode import io.tolgee.model.enums.TranslationState @@ -21,10 +23,16 @@ class SuggestionsTestData( var czechTranslator: UserAccountBuilder var czechReviewer: UserAccountBuilder var communityUser: UserAccountBuilder + var suggestionModerator: UserAccountBuilder + var czechSuggestionModerator: UserAccountBuilder + var serverAdmin: UserAccountBuilder + var projectEditor: UserAccountBuilder + var viewOnlyUser: UserAccountBuilder var relatedProject: ProjectBuilder var keys: MutableList = mutableListOf() val czechSuggestions: MutableList = mutableListOf() val englishSuggestions: MutableList = mutableListOf() + lateinit var czechReviewerEnglishSuggestion: SuggestionBuilder val czechTranslations: MutableList = mutableListOf() lateinit var pluralKey: KeyBuilder lateinit var pluralSuggestion: SuggestionBuilder @@ -80,6 +88,37 @@ class SuggestionsTestData( name = "Community user" } + suggestionModerator = + root.addUserAccount { + username = "suggestion.moderator@test.com" + name = "Suggestion moderator" + } + + czechSuggestionModerator = + root.addUserAccount { + username = "cs.suggestion.moderator@test.com" + name = "Czech suggestion moderator" + } + + serverAdmin = + root.addUserAccount { + username = "server.admin@test.com" + name = "Server admin" + role = UserAccount.Role.ADMIN + } + + projectEditor = + root.addUserAccount { + username = "editor@test.com" + name = "Project editor" + } + + viewOnlyUser = + root.addUserAccount { + username = "view.only@test.com" + name = "View only user" + } + userAccountBuilder.defaultOrganizationBuilder.apply { addRole { user = orgMember.self @@ -130,6 +169,29 @@ class SuggestionsTestData( stateChangeLanguages = mutableSetOf(czechLanguage) } + addPermission { + user = projectEditor.self + type = ProjectPermissionType.EDIT + } + + addPermission { + user = viewOnlyUser.self + type = ProjectPermissionType.VIEW + } + + addPermission { + user = suggestionModerator.self + type = null + scopes = arrayOf(Scope.TRANSLATIONS_VIEW, Scope.TRANSLATION_SUGGESTIONS_MANAGE) + } + + addPermission { + user = czechSuggestionModerator.self + type = null + scopes = arrayOf(Scope.TRANSLATIONS_VIEW, Scope.TRANSLATION_SUGGESTIONS_MANAGE) + suggestManageLanguages = mutableSetOf(czechLanguage) + } + (0 until 4).forEach { keys.add( addKey(null, "key $it").apply { @@ -201,6 +263,13 @@ class SuggestionsTestData( this.author = projectTranslator.self this.translation = "Only suggestion 3-1" } + + czechReviewerEnglishSuggestion = + addSuggestion { + this.language = englishLanguage + this.author = czechReviewer.self + this.translation = "Suggested translation 3 by cs reviewer" + } } pluralKey = diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt index bde265f23f6..75acee7221e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/ComputedPermissionDto.kt @@ -35,6 +35,12 @@ class ComputedPermissionDto( suggestLanguageIds, ) + fun checkSuggestManagePermitted(vararg languageIds: Long) = + checkLanguagePermitted( + languageIds.toList(), + suggestManageLanguageIds, + ) + private fun isAllLanguagesPermitted(languageIds: Collection?): Boolean { if (scopes.isEmpty()) { return false @@ -126,6 +132,8 @@ class ComputedPermissionDto( get() = null override val suggestLanguageIds: Set? get() = null + override val suggestManageLanguageIds: Set? + get() = null override val type: ProjectPermissionType get() = type override val granular: Boolean? diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/IPermission.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/IPermission.kt index b6e28efeeeb..50ac2f0f04b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/IPermission.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/IPermission.kt @@ -11,6 +11,7 @@ interface IPermission { val viewLanguageIds: Set? val stateChangeLanguageIds: Set? val suggestLanguageIds: Set? + val suggestManageLanguageIds: Set? val type: ProjectPermissionType? val granular: Boolean? diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/PermissionDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/PermissionDto.kt index 9ab140da9f9..4ac8c2161ba 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/PermissionDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/PermissionDto.kt @@ -17,6 +17,7 @@ data class PermissionDto( override val viewLanguageIds: Set?, override val stateChangeLanguageIds: Set?, override val suggestLanguageIds: Set?, + override val suggestManageLanguageIds: Set?, ) : Serializable, IPermission { override fun equals(other: Any?): Boolean { @@ -33,6 +34,7 @@ data class PermissionDto( if (organizationId != other.organizationId) return false if (translateLanguageIds != other.translateLanguageIds) return false if (suggestLanguageIds != other.suggestLanguageIds) return false + if (suggestManageLanguageIds != other.suggestManageLanguageIds) return false if (type != other.type) return false if (granular != other.granular) return false if (viewLanguageIds != other.viewLanguageIds) return false @@ -54,6 +56,7 @@ data class PermissionDto( result = 31 * result + (viewLanguageIds?.hashCode() ?: 0) result = 31 * result + (stateChangeLanguageIds?.hashCode() ?: 0) result = 31 * result + (suggestLanguageIds?.hashCode() ?: 0) + result = 31 * result + (suggestManageLanguageIds?.hashCode() ?: 0) return result } } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/organization/BasePermissionView.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/organization/BasePermissionView.kt index 40773578ff9..3af23e9d5ea 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/organization/BasePermissionView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/organization/BasePermissionView.kt @@ -17,6 +17,7 @@ class BasePermissionView( override val viewLanguageIds: Set? = null override val stateChangeLanguageIds: Set? = null override val suggestLanguageIds: Set? = null + override val suggestManageLanguageIds: Set? = null override val granular: Boolean get() = _scopes != null diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/LanguagePermissions.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/LanguagePermissions.kt index aefa1a0447e..81fd1787da4 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/LanguagePermissions.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/LanguagePermissions.kt @@ -7,4 +7,5 @@ class LanguagePermissions( var view: Set? = null, var stateChange: Set? = null, var suggest: Set? = null, + var suggestManage: Set? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/ProjectInviteUserDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/ProjectInviteUserDto.kt index 04ce3a124c8..34d693d6452 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/ProjectInviteUserDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/ProjectInviteUserDto.kt @@ -17,6 +17,7 @@ data class ProjectInviteUserDto( override var viewLanguages: Set? = null, override var stateChangeLanguages: Set? = null, override var suggestLanguages: Set? = null, + override var suggestManageLanguages: Set? = null, @Schema( description = """Email to send invitation to""", ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/RequestWithLanguagePermissions.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/RequestWithLanguagePermissions.kt index 61dfeb87097..c0b94df4179 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/RequestWithLanguagePermissions.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/RequestWithLanguagePermissions.kt @@ -17,4 +17,7 @@ interface RequestWithLanguagePermissions { @get:Schema(description = "Languages user can suggest translation") var suggestLanguages: Set? + + @get:Schema(description = "Languages user can manage suggestions for") + var suggestManageLanguages: Set? } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/SetPermissionLanguageParams.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/SetPermissionLanguageParams.kt index ff6e51d9ca4..f33d497aa5e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/SetPermissionLanguageParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/SetPermissionLanguageParams.kt @@ -6,4 +6,5 @@ class SetPermissionLanguageParams( override var viewLanguages: Set? = null, override var stateChangeLanguages: Set? = null, override var suggestLanguages: Set? = null, + override var suggestManageLanguages: Set? = null, ) : RequestWithLanguagePermissions diff --git a/backend/data/src/main/kotlin/io/tolgee/facade/ProjectPermissionFacade.kt b/backend/data/src/main/kotlin/io/tolgee/facade/ProjectPermissionFacade.kt index f42779993f9..35abe1b8145 100644 --- a/backend/data/src/main/kotlin/io/tolgee/facade/ProjectPermissionFacade.kt +++ b/backend/data/src/main/kotlin/io/tolgee/facade/ProjectPermissionFacade.kt @@ -57,6 +57,11 @@ class ProjectPermissionFacade( params.suggestLanguages, projectId, ), + suggestManage = + this.getLanguagesAndCheckFromProject( + params.suggestManageLanguages, + projectId, + ), ) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt b/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt index 9beea37a0fb..6db9b142180 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt @@ -104,6 +104,13 @@ class Permission( @ManyToMany(fetch = FetchType.EAGER) var suggestLanguages: MutableSet = mutableSetOf() + /** + * Languages for TRANSLATION_SUGGESTIONS_MANAGE scope. + * When specified, user is restricted to moderate suggestions only for specific languages. + */ + @ManyToMany(fetch = FetchType.EAGER) + var suggestManageLanguages: MutableSet = mutableSetOf() + /** * Languages for TRANSLATIONS_VIEW scope. * When specified, user is restricted to view specific language translations. @@ -142,6 +149,7 @@ class Permission( this.translateLanguages = languagePermissions?.translate?.toMutableSet() ?: mutableSetOf() this.stateChangeLanguages = languagePermissions?.stateChange?.toMutableSet() ?: mutableSetOf() this.suggestLanguages = languagePermissions?.suggest?.toMutableSet() ?: mutableSetOf() + this.suggestManageLanguages = languagePermissions?.suggestManage?.toMutableSet() ?: mutableSetOf() this.agency = agency } @@ -164,6 +172,9 @@ class Permission( override val suggestLanguageIds: Set? get() = this.suggestLanguages.map { it.id }.toSet() + override val suggestManageLanguageIds: Set? + get() = this.suggestManageLanguages.map { it.id }.toSet() + companion object { @Configurable class PermissionListeners { @@ -180,7 +191,9 @@ class Permission( ( permission.viewLanguages.isNotEmpty() || permission.translateLanguages.isNotEmpty() || - permission.stateChangeLanguages.isNotEmpty() + permission.stateChangeLanguages.isNotEmpty() || + permission.suggestLanguages.isNotEmpty() || + permission.suggestManageLanguages.isNotEmpty() ) ) { throw IllegalStateException("Organization base permission cannot have language permissions") diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt index f01d565c5b3..8e04eed4b7a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/ProjectPermissionType.kt @@ -62,6 +62,7 @@ enum class ProjectPermissionType( Scope.TRANSLATIONS_COMMENTS_SET_STATE, Scope.TRANSLATIONS_COMMENTS_EDIT, Scope.TRANSLATIONS_STATE_EDIT, + Scope.TRANSLATION_SUGGESTIONS_MANAGE, Scope.BATCH_PRE_TRANSLATE_BY_TM, Scope.BATCH_MACHINE_TRANSLATE, Scope.BATCH_JOBS_VIEW, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt index b1d71eb5578..ce01f84fcb8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/Scope.kt @@ -12,6 +12,7 @@ enum class Scope( TRANSLATIONS_VIEW("translations.view"), TRANSLATIONS_EDIT("translations.edit"), TRANSLATIONS_SUGGEST("translations.suggest"), + TRANSLATION_SUGGESTIONS_MANAGE("translation-suggestions.manage"), KEYS_EDIT("keys.edit"), SCREENSHOTS_UPLOAD("screenshots.upload"), SCREENSHOTS_DELETE("screenshots.delete"), @@ -144,6 +145,10 @@ enum class Scope( TRANSLATIONS_SUGGEST, listOf(translationsView), ), + HierarchyItem( + TRANSLATION_SUGGESTIONS_MANAGE, + listOf(translationsView), + ), batchJobsView, HierarchyItem(BATCH_JOBS_CANCEL), HierarchyItem(BATCH_PRE_TRANSLATE_BY_TM, listOf(translationsEdit)), diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/PermissionRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/PermissionRepository.kt index 4a7c725540e..8c3ae732f6c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/PermissionRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/PermissionRepository.kt @@ -33,6 +33,7 @@ interface PermissionRepository : JpaRepository { left join fetch p.translateLanguages left join fetch p.stateChangeLanguages left join fetch p.suggestLanguages + left join fetch p.suggestManageLanguages where p.project.id = :projectId""", ) fun getByProjectWithFetchedLanguages(projectId: Long): List @@ -44,7 +45,8 @@ interface PermissionRepository : JpaRepository { left join p.viewLanguages vl on vl = :language left join p.stateChangeLanguages scl on scl = :language left join p.suggestLanguages sul on sul = :language - where tl.id is not null or vl.id is not null or scl.id is not null + left join p.suggestManageLanguages sml on sml = :language + where tl.id is not null or vl.id is not null or scl.id is not null or sul.id is not null or sml.id is not null """, ) fun findAllByPermittedLanguage(language: Language): List diff --git a/backend/data/src/main/kotlin/io/tolgee/service/CachedPermissionService.kt b/backend/data/src/main/kotlin/io/tolgee/service/CachedPermissionService.kt index adc52dd5867..205ad37cbd5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/CachedPermissionService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/CachedPermissionService.kt @@ -64,6 +64,7 @@ class CachedPermissionService( viewLanguageIds = permission.viewLanguageIds, stateChangeLanguageIds = permission.stateChangeLanguageIds, suggestLanguageIds = permission.suggestLanguageIds, + suggestManageLanguageIds = permission.suggestManageLanguageIds, type = permission.type, granular = permission.granular, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/LanguageDeletedPermissionUpdater.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/LanguageDeletedPermissionUpdater.kt index 36c32cf779b..70e00f6e44d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/LanguageDeletedPermissionUpdater.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/LanguageDeletedPermissionUpdater.kt @@ -19,6 +19,8 @@ class LanguageDeletedPermissionUpdater( operator fun invoke() { if (permission.scopes.isNotEmpty()) { handleStateChangeLanguagesGranular() + handleSuggestLanguagesGranular() + handleSuggestManageLanguagesGranular() handleTranslateLanguagesGranular() handleViewLanguagesGranular() } @@ -38,6 +40,8 @@ class LanguageDeletedPermissionUpdater( permission.translateLanguages, permission.viewLanguages, permission.stateChangeLanguages, + permission.suggestLanguages, + permission.suggestManageLanguages, ).forEach { languages -> languages.removeIf { it.id == language.id } } @@ -87,6 +91,18 @@ class LanguageDeletedPermissionUpdater( } } + private fun handleSuggestLanguagesGranular() { + if (shouldLowerPermissions(permission.suggestLanguages, Scope.TRANSLATIONS_SUGGEST)) { + permission.scopes = scopesWithout(Scope.TRANSLATIONS_SUGGEST) + } + } + + private fun handleSuggestManageLanguagesGranular() { + if (shouldLowerPermissions(permission.suggestManageLanguages, Scope.TRANSLATION_SUGGESTIONS_MANAGE)) { + permission.scopes = scopesWithout(Scope.TRANSLATION_SUGGESTIONS_MANAGE) + } + } + private fun shouldLowerPermissions( languages: MutableSet, type: ProjectPermissionType, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt index d92929bdbf5..a404e9d306c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt @@ -367,11 +367,13 @@ class PermissionService( permission.stateChangeLanguages = languagePermissions.stateChange.standardize() permission.viewLanguages = languagePermissions.view.standardize() permission.suggestLanguages = languagePermissions.suggest.standardize() + permission.suggestManageLanguages = languagePermissions.suggestManage.standardize() if (permission.viewLanguages.isNotEmpty()) { permission.viewLanguages.addAll(permission.translateLanguages) permission.viewLanguages.addAll(permission.stateChangeLanguages) permission.viewLanguages.addAll(permission.suggestLanguages) + permission.viewLanguages.addAll(permission.suggestManageLanguages) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt index 231b9b8300d..ffe2830038d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt @@ -231,6 +231,16 @@ class SecurityService( } } + fun checkLanguageViewSuggestionsPermission( + projectId: Long, + languageIds: Collection, + ) { + passIfAnyPermissionCheckSucceeds( + { checkLanguageSuggestPermission(projectId, languageIds) }, + { checkLanguageSuggestionsManagePermission(projectId, languageIds) }, + ) + } + fun checkLanguageSuggestPermission( projectId: Long, languageIds: Collection, @@ -243,6 +253,18 @@ class SecurityService( } } + fun checkLanguageSuggestionsManagePermission( + projectId: Long, + languageIds: Collection, + ) { + checkProjectPermission(projectId, Scope.TRANSLATION_SUGGESTIONS_MANAGE) + runIfUserNotServerAdmin { + checkLanguagePermission( + projectId, + ) { data -> data.checkSuggestManagePermitted(*languageIds.toLongArray()) } + } + } + fun checkLanguageViewPermission( projectId: Long, languageIds: Collection, diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 4a9d13ab97b..667041dbad8 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -5790,4 +5790,20 @@ DROP INDEX IF EXISTS project_is_public;
+ + + + + + + + + + + + + + + + diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/CommunityPermissionScopesTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/CommunityPermissionScopesTest.kt index 513d486ba7a..1dfd5533943 100644 --- a/backend/data/src/test/kotlin/io/tolgee/unit/CommunityPermissionScopesTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/CommunityPermissionScopesTest.kt @@ -37,6 +37,7 @@ class CommunityPermissionScopesTest { .doesNotContain( Scope.TRANSLATIONS_EDIT, Scope.TRANSLATIONS_STATE_EDIT, + Scope.TRANSLATION_SUGGESTIONS_MANAGE, Scope.KEYS_EDIT, Scope.KEYS_CREATE, Scope.KEYS_DELETE, @@ -56,9 +57,36 @@ class CommunityPermissionScopesTest { assertThat(permission.viewLanguageIds).isNull() assertThat(permission.translateLanguageIds).isNull() assertThat(permission.suggestLanguageIds).isNull() + assertThat(permission.suggestManageLanguageIds).isNull() assertThat(permission.stateChangeLanguageIds).isNull() } + @Test + fun `suggestions-manage scope expands to the implied read scopes`() { + assertThat(Scope.expand(Scope.TRANSLATION_SUGGESTIONS_MANAGE).toSet()) + .contains(Scope.TRANSLATION_SUGGESTIONS_MANAGE, Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW) + assertThat(Scope.expand(Scope.TRANSLATION_SUGGESTIONS_MANAGE).toSet()) + .doesNotContain(Scope.TRANSLATIONS_SUGGEST) + } + + @Test + fun `only EDIT and MANAGE presets grant the suggestions-manage scope`() { + assertThat(Scope.expand(ProjectPermissionType.EDIT.availableScopes).toSet()) + .contains(Scope.TRANSLATION_SUGGESTIONS_MANAGE) + assertThat(Scope.expand(ProjectPermissionType.MANAGE.availableScopes).toSet()) + .contains(Scope.TRANSLATION_SUGGESTIONS_MANAGE) + + listOf( + ProjectPermissionType.NONE, + ProjectPermissionType.VIEW, + ProjectPermissionType.TRANSLATE, + ProjectPermissionType.REVIEW, + ).forEach { type -> + assertThat(Scope.expand(type.availableScopes).toSet()) + .doesNotContain(Scope.TRANSLATION_SUGGESTIONS_MANAGE) + } + } + @Test fun `standard member permission types grant the organization-quotas scope`() { listOf( diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/PermissionOrgBaseLanguageGuardTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/PermissionOrgBaseLanguageGuardTest.kt new file mode 100644 index 00000000000..4f0caaee291 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/PermissionOrgBaseLanguageGuardTest.kt @@ -0,0 +1,43 @@ +package io.tolgee.unit + +import io.tolgee.model.Language +import io.tolgee.model.Organization +import io.tolgee.model.Permission +import org.assertj.core.api.Assertions.assertThatCode +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +class PermissionOrgBaseLanguageGuardTest { + private val listeners = Permission.Companion.PermissionListeners() + + private fun orgBasePermission() = + Permission().apply { + organization = Organization() + } + + @Test + fun `rejects org base permission with suggest languages`() { + val permission = + orgBasePermission().apply { + suggestLanguages = mutableSetOf(Language()) + } + assertThatThrownBy { listeners.prePersist(permission) } + .isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `rejects org base permission with suggest-manage languages`() { + val permission = + orgBasePermission().apply { + suggestManageLanguages = mutableSetOf(Language()) + } + assertThatThrownBy { listeners.prePersist(permission) } + .isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `allows org base permission without language restrictions`() { + assertThatCode { listeners.prePersist(orgBasePermission()) } + .doesNotThrowAnyException() + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionController.kt index f9b2555b183..203500febe3 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionController.kt @@ -4,9 +4,12 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.activity.RequestActivity import io.tolgee.activity.data.ActivityType +import io.tolgee.constants.Message import io.tolgee.dtos.request.suggestion.SuggestionFilters import io.tolgee.ee.data.translationSuggestion.CreateTranslationSuggestionRequest import io.tolgee.ee.service.TranslationSuggestionServiceEeImpl +import io.tolgee.exceptions.LanguageNotPermittedException +import io.tolgee.exceptions.PermissionException import io.tolgee.hateoas.translations.suggestions.TranslationSuggestionAcceptResponse import io.tolgee.hateoas.translations.suggestions.TranslationSuggestionModel import io.tolgee.hateoas.translations.suggestions.TranslationSuggestionModelAssembler @@ -65,11 +68,11 @@ class SuggestionController( @PathVariable languageId: Long, @PathVariable keyId: Long, ): PagedModel { - securityService.checkLanguageSuggestPermission( - projectHolder.project.id, + val projectId = projectHolder.project.id + securityService.checkLanguageViewSuggestionsPermission( + projectId, listOf(languageId), ) - val projectId = projectHolder.project.id val suggestions = translationSuggestionService.getSuggestionsPaged( pageable, @@ -112,12 +115,8 @@ class SuggestionController( } @DeleteMapping("/{suggestionId:[0-9]+}") - @Operation( - summary = "Delete suggestion", - description = "User can only delete suggestion created by them", - ) + @Operation(summary = "Delete suggestion") @AllowApiAccess - // user can only delete suggestion created by them; it's checked in the service @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @RequestActivity(ActivityType.DELETE_SUGGESTION) @OpenApiOrderExtension(4) @@ -127,13 +126,28 @@ class SuggestionController( @PathVariable suggestionId: Long, ) { val projectId = projectHolder.project.id - translationSuggestionService.deleteSuggestionCreatedByUser( - projectId, - languageId, - keyId, - suggestionId, - authenticationFacade.authenticatedUser.id, - ) + val suggestion = translationSuggestionService.getSuggestion(projectId, languageId, keyId, suggestionId) + checkCanDeleteSuggestion(projectId, suggestion, languageId) + translationSuggestionService.deleteSuggestion(suggestion) + } + + private fun checkCanDeleteSuggestion( + projectId: Long, + suggestion: TranslationSuggestion, + languageId: Long, + ) { + if (suggestion.author?.id == authenticationFacade.authenticatedUser.id) { + return + } + try { + securityService.checkLanguageSuggestionsManagePermission(projectId, listOf(languageId)) + } catch (e: LanguageNotPermittedException) { + // LanguageNotPermittedException is a PermissionException subtype — must be caught first and + // propagated as-is (LANGUAGE_NOT_PERMITTED); the generic arm below rewrites the message. + throw e + } catch (e: PermissionException) { + throw PermissionException(Message.USER_CAN_ONLY_DELETE_HIS_SUGGESTIONS) + } } @PutMapping("/{suggestionId:[0-9]+}/decline") diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt index 287aae43553..326a71d84af 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TranslationSuggestionServiceEeImpl.kt @@ -7,7 +7,6 @@ import io.tolgee.ee.data.translationSuggestion.CreateTranslationSuggestionReques import io.tolgee.ee.repository.TranslationSuggestionRepository import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException -import io.tolgee.exceptions.PermissionException import io.tolgee.formats.StringIsNotPluralException import io.tolgee.formats.normalizePlurals import io.tolgee.model.Project @@ -209,19 +208,7 @@ class TranslationSuggestionServiceEeImpl( ) } - fun deleteSuggestionCreatedByUser( - projectId: Long, - languageId: Long, - keyId: Long, - suggestionId: Long, - userId: Long, - ) { - val suggestion = getSuggestion(projectId, languageId, keyId, suggestionId) - - if (suggestion.author?.id != userId) { - throw PermissionException(Message.USER_CAN_ONLY_DELETE_HIS_SUGGESTIONS) - } - + fun deleteSuggestion(suggestion: TranslationSuggestion) { translationSuggestionRepository.delete(suggestion) } diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AdvancedPermissionControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AdvancedPermissionControllerTest.kt index 7041ed69da7..8c830b1d9ee 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AdvancedPermissionControllerTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AdvancedPermissionControllerTest.kt @@ -92,6 +92,33 @@ class AdvancedPermissionControllerTest : AuthorizedControllerTest() { .andHasErrorMessage(Message.CANNOT_SET_STATE_CHANGE_LANGUAGES_WITHOUT_TRANSLATIONS_STATE_EDIT_SCOPE) } + @Test + fun `folds suggestions-manage languages into view languages`() { + permissionTestUtil.checkSetPermissionsWithLanguages("", { getLang -> + "scopes=translations.view&scopes=translation-suggestions.manage&" + + "viewLanguages=${getLang("en")}&suggestManageLanguages=${getLang("de")}" + }) { data, getLangId -> + Assertions + .assertThat(data.computedPermissions.viewLanguageIds) + .contains(getLangId("en"), getLangId("de")) + } + } + + @Test + fun `sets suggestions-manage scope with its own language restriction`() { + permissionTestUtil.checkSetPermissionsWithLanguages("", { getLang -> + "scopes=translations.view&scopes=translation-suggestions.manage&" + + "suggestManageLanguages=${getLang("en")}" + }) { data, getLangId -> + Assertions + .assertThat(data.computedPermissions.scopes) + .contains(Scope.TRANSLATION_SUGGESTIONS_MANAGE) + Assertions + .assertThat(data.computedPermissions.suggestManageLanguageIds) + .containsExactlyInAnyOrder(getLangId("en")) + } + } + @Test fun `validates permissions (empty scopes)`() { permissionTestUtil diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt index a0d8e234af0..f523e3de8a5 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SuggestionControllerTest.kt @@ -1,11 +1,13 @@ package io.tolgee.ee.api.v2.controllers import io.tolgee.ProjectAuthControllerTest +import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.SuggestionsTestData import io.tolgee.dtos.request.key.ComplexEditKeyDto import io.tolgee.ee.data.translationSuggestion.CreateTranslationSuggestionRequest import io.tolgee.ee.repository.TranslationSuggestionRepository import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andHasErrorMessage import io.tolgee.fixtures.andIsBadRequest import io.tolgee.fixtures.andIsForbidden import io.tolgee.fixtures.andIsNotFound @@ -30,6 +32,40 @@ class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { @AfterEach fun resetClock() { clearForcedDate() + if (this::testData.isInitialized) { + testDataService.cleanTestData(testData.root) + } + } + + private fun assertSuggestionExists(suggestionId: Long) { + executeInNewTransaction { + translationSuggestionRepository + .findById(suggestionId) + .isPresent.assert + .isTrue() + } + } + + private fun assertSuggestionDeleted(suggestionId: Long) { + executeInNewTransaction { + translationSuggestionRepository + .findById(suggestionId) + .isPresent.assert + .isFalse() + } + } + + private fun assertSuggestionState( + suggestionId: Long, + state: TranslationSuggestionState, + ) { + executeInNewTransaction { + translationSuggestionRepository + .findById(suggestionId) + .get() + .state.assert + .isEqualTo(state) + } } fun initTestData(suggestionsMode: SuggestionsMode = SuggestionsMode.ENABLED) { @@ -257,6 +293,8 @@ class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { node("declined[0]").isEqualTo(testData.czechSuggestions[1].self.id) node("declined").isArray.hasSize(1) } + assertSuggestionState(testData.czechSuggestions[0].self.id, TranslationSuggestionState.ACCEPTED) + assertSuggestionState(testData.czechSuggestions[1].self.id, TranslationSuggestionState.DECLINED) } @Test @@ -292,9 +330,11 @@ class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { fun `can delete his own suggestion`() { initTestData() userAccount = testData.projectTranslator.self + val suggestionId = testData.czechSuggestions[0].self.id performProjectAuthDelete( - "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion/${testData.czechSuggestions[0].self.id}", + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion/$suggestionId", ).andIsOk + assertSuggestionDeleted(suggestionId) } @Test @@ -302,11 +342,159 @@ class SuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { fun `can't delete suggestion of someone else`() { initTestData() userAccount = testData.projectTranslator.self + val suggestionId = testData.czechSuggestions[1].self.id + performProjectAuthDelete( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion/$suggestionId", + ).andIsForbidden.andHasErrorMessage(Message.USER_CAN_ONLY_DELETE_HIS_SUGGESTIONS) + assertSuggestionExists(suggestionId) + } + + @Test + @ProjectJWTAuthTestMethod + fun `moderator with suggestions-manage scope can delete another user's suggestion`() { + initTestData() + userAccount = testData.suggestionModerator.self + val suggestionId = testData.czechSuggestions[1].self.id performProjectAuthDelete( - "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion/${testData.czechSuggestions[1].self.id}", + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion/$suggestionId", + ).andIsOk + assertSuggestionDeleted(suggestionId) + } + + @Test + @ProjectJWTAuthTestMethod + fun `granular suggestions-manage permission can list suggestions`() { + initTestData() + userAccount = testData.suggestionModerator.self + performProjectAuthGet( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion", + ).andIsOk.andAssertThatJson { + node("page.totalElements").isNumber.isEqualTo(BigDecimal(2)) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `view-only member cannot list suggestions`() { + initTestData() + userAccount = testData.viewOnlyUser.self + performProjectAuthGet( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion", ).andIsForbidden } + @Test + @ProjectJWTAuthTestMethod + fun `suggester without manage scope can list suggestions in permitted language`() { + initTestData() + userAccount = testData.czechTranslator.self + performProjectAuthGet( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion", + ).andIsOk + performProjectAuthGet( + "languages/${testData.englishLanguage.id}/key/${testData.keys[0].self.id}/suggestion", + ).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `language-restricted moderator lists suggestions only in permitted language`() { + initTestData() + userAccount = testData.czechSuggestionModerator.self + performProjectAuthGet( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion", + ).andIsOk.andAssertThatJson { + node("page.totalElements").isNumber.isEqualTo(BigDecimal(2)) + } + performProjectAuthGet( + "languages/${testData.englishLanguage.id}/key/${testData.keys[0].self.id}/suggestion", + ).andIsForbidden + } + + @Test + @ProjectJWTAuthTestMethod + fun `EDIT preset member can delete another user's suggestion`() { + initTestData() + userAccount = testData.projectEditor.self + val suggestionId = testData.czechSuggestions[0].self.id + performProjectAuthDelete( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion/$suggestionId", + ).andIsOk + assertSuggestionDeleted(suggestionId) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unrestricted moderator can delete another user's suggestion in any language`() { + initTestData() + userAccount = testData.suggestionModerator.self + val suggestionId = testData.englishSuggestions[0].self.id + performProjectAuthDelete( + "languages/${testData.englishLanguage.id}/key/${testData.keys[0].self.id}/suggestion/$suggestionId", + ).andIsOk + assertSuggestionDeleted(suggestionId) + } + + @Test + @ProjectJWTAuthTestMethod + fun `reviewer without suggestions-manage scope cannot delete another user's suggestion`() { + initTestData() + userAccount = testData.projectReviewer.self + val suggestionId = testData.czechSuggestions[0].self.id + performProjectAuthDelete( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion/$suggestionId", + ).andIsForbidden.andHasErrorMessage(Message.USER_CAN_ONLY_DELETE_HIS_SUGGESTIONS) + assertSuggestionExists(suggestionId) + } + + @Test + @ProjectJWTAuthTestMethod + fun `server admin can delete another user's suggestion in any language`() { + initTestData() + userAccount = testData.serverAdmin.self + val suggestionId = testData.englishSuggestions[1].self.id + performProjectAuthDelete( + "languages/${testData.englishLanguage.id}/key/${testData.keys[0].self.id}/suggestion/$suggestionId", + ).andIsOk + assertSuggestionDeleted(suggestionId) + } + + @Test + @ProjectJWTAuthTestMethod + fun `language-restricted moderator can delete suggestion in a permitted language`() { + initTestData() + userAccount = testData.czechSuggestionModerator.self + val suggestionId = testData.czechSuggestions[0].self.id + performProjectAuthDelete( + "languages/${testData.czechLanguage.id}/key/${testData.keys[0].self.id}/suggestion/$suggestionId", + ).andIsOk + assertSuggestionDeleted(suggestionId) + } + + @Test + @ProjectJWTAuthTestMethod + fun `language-restricted moderator cannot delete suggestion in another language`() { + initTestData() + userAccount = testData.czechSuggestionModerator.self + val suggestionId = testData.englishSuggestions[0].self.id + performProjectAuthDelete( + "languages/${testData.englishLanguage.id}/key/${testData.keys[0].self.id}/suggestion/$suggestionId", + ).andIsForbidden.andHasErrorMessage(Message.LANGUAGE_NOT_PERMITTED) + assertSuggestionExists(suggestionId) + } + + @Test + @ProjectJWTAuthTestMethod + fun `author can delete own suggestion even in a non-permitted language`() { + initTestData() + userAccount = testData.czechReviewer.self + val suggestionId = testData.czechReviewerEnglishSuggestion.self.id + performProjectAuthDelete( + "languages/${testData.englishLanguage.id}/key/${testData.keys[3].self.id}/suggestion/$suggestionId", + ).andIsOk + assertSuggestionDeleted(suggestionId) + } + @Test @ProjectJWTAuthTestMethod fun `czech reviewer can accept czech suggestion`() { diff --git a/webapp/src/component/PermissionsSettings/LanguagePermissionsSummary.tsx b/webapp/src/component/PermissionsSettings/LanguagePermissionsSummary.tsx index 284fc8d557e..13031eb8199 100644 --- a/webapp/src/component/PermissionsSettings/LanguagePermissionsSummary.tsx +++ b/webapp/src/component/PermissionsSettings/LanguagePermissionsSummary.tsx @@ -17,6 +17,7 @@ export function LanguagePermissionSummary({ permissions, allLangs }: Props) { translateLanguageIds, stateChangeLanguageIds, suggestLanguageIds, + suggestManageLanguageIds, } = permissions; const categories: LanguagePermissionCategory[] = [ @@ -35,6 +36,11 @@ export function LanguagePermissionSummary({ permissions, allLangs }: Props) { data: suggestLanguageIds, scope: 'translations.suggest', }, + { + label: t('permission_type_suggest_manage'), + data: suggestManageLanguageIds, + scope: 'translation-suggestions.manage', + }, { label: t('permission_type_view'), data: viewLanguageIds, @@ -48,9 +54,17 @@ export function LanguagePermissionSummary({ permissions, allLangs }: Props) { ...(viewLanguageIds || []), ...(translateLanguageIds || []), ...(stateChangeLanguageIds || []), + ...(suggestLanguageIds || []), + ...(suggestManageLanguageIds || []), ]) ); - }, [viewLanguageIds, translateLanguageIds, stateChangeLanguageIds]); + }, [ + viewLanguageIds, + translateLanguageIds, + stateChangeLanguageIds, + suggestLanguageIds, + suggestManageLanguageIds, + ]); const hintNeeded = Boolean(languagesUnion.length); diff --git a/webapp/src/component/PermissionsSettings/PermissionsSettings.tsx b/webapp/src/component/PermissionsSettings/PermissionsSettings.tsx index 2231bf0f450..e98feebd5f4 100644 --- a/webapp/src/component/PermissionsSettings/PermissionsSettings.tsx +++ b/webapp/src/component/PermissionsSettings/PermissionsSettings.tsx @@ -72,6 +72,7 @@ export const PermissionsSettings: React.FC> = ({ translateLanguages: permissions.translateLanguageIds || [], stateChangeLanguages: permissions.stateChangeLanguageIds || [], suggestLanguages: permissions.suggestLanguageIds || [], + suggestManageLanguages: permissions.suggestManageLanguageIds || [], }); } }, [dependenciesLoadable.data, rolesLoadable.data, advancedState]); diff --git a/webapp/src/component/PermissionsSettings/hierarchyTools.ts b/webapp/src/component/PermissionsSettings/hierarchyTools.ts index 84884db5520..055ba594561 100644 --- a/webapp/src/component/PermissionsSettings/hierarchyTools.ts +++ b/webapp/src/component/PermissionsSettings/hierarchyTools.ts @@ -102,6 +102,7 @@ const SCOPE_TO_LANG_PROPERTY_MAP = { 'translations.edit': 'translateLanguages', 'translations.state-edit': 'stateChangeLanguages', 'translations.suggest': 'suggestLanguages', + 'translation-suggestions.manage': 'suggestManageLanguages', } as const; type ValueOf = T[keyof T]; diff --git a/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts b/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts index 0af4e9e9230..c21a9f4d9b3 100644 --- a/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts +++ b/webapp/src/component/PermissionsSettings/usePermissionsStructure.ts @@ -65,7 +65,15 @@ export const usePermissionsStructure = () => { value: 'translations.state-edit', }, { - value: 'translations.suggest', + label: t('permissions_item_translations_suggestions'), + children: [ + { + value: 'translations.suggest', + }, + { + value: 'translation-suggestions.manage', + }, + ], }, { label: t('permissions_item_translations_comments'), diff --git a/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx b/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx index ae82bd0ceec..1f39367602d 100644 --- a/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx +++ b/webapp/src/component/PermissionsSettings/useScopeTranslations.tsx @@ -9,6 +9,9 @@ export const useScopeTranslations = () => { 'translations.view': t('permissions_item_translations_view'), 'translations.edit': t('permissions_item_translations_edit'), 'translations.suggest': t('permissions_item_translations_suggest'), + 'translation-suggestions.manage': t( + 'permissions_item_translations_suggestions_manage' + ), 'translation-comments.add': t('permissions_item_translations_comments_add'), 'translation-comments.edit': t( 'permissions_item_translations_comments_edit' diff --git a/webapp/src/fixtures/permissions.ts b/webapp/src/fixtures/permissions.ts index 1d4ef626959..adea0a34ce2 100644 --- a/webapp/src/fixtures/permissions.ts +++ b/webapp/src/fixtures/permissions.ts @@ -14,6 +14,7 @@ export const SCOPE_TO_LANG_PROPERTY_MAP = { 'translations.edit': 'translateLanguageIds', 'translations.state-edit': 'stateChangeLanguageIds', 'translations.suggest': 'suggestLanguageIds', + 'translation-suggestions.manage': 'suggestManageLanguageIds', }; export type ScopeWithLanguage = keyof typeof SCOPE_TO_LANG_PROPERTY_MAP; diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 7aa6dba0edb..5da7ac97527 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -811,7 +811,6 @@ export interface paths { post: operations["createSuggestion"]; }; "/v2/projects/{projectId}/languages/{languageId}/key/{keyId}/suggestion/{suggestionId}": { - /** User can only delete suggestion created by them */ delete: operations["deleteSuggestion"]; }; "/v2/projects/{projectId}/languages/{languageId}/key/{keyId}/suggestion/{suggestionId}/accept": { @@ -1406,6 +1405,7 @@ export interface components { | "translations.view" | "translations.edit" | "translations.suggest" + | "translation-suggestions.manage" | "keys.edit" | "screenshots.upload" | "screenshots.delete" @@ -1457,6 +1457,14 @@ export interface components { * ] */ suggestLanguageIds?: number[]; + /** + * @description List of languages user can manage suggestions for. If null, managing suggestions for all languages is permitted. + * @example [ + * 200001, + * 200004 + * ] + */ + suggestManageLanguageIds?: number[]; /** * @description List of languages user can translate to. If null, all languages editing is permitted. * @example [ @@ -2118,6 +2126,7 @@ export interface components { | "translations.view" | "translations.edit" | "translations.suggest" + | "translation-suggestions.manage" | "keys.edit" | "screenshots.upload" | "screenshots.delete" @@ -2169,6 +2178,14 @@ export interface components { * ] */ suggestLanguageIds?: number[]; + /** + * @description List of languages user can manage suggestions for. If null, managing suggestions for all languages is permitted. + * @example [ + * 200001, + * 200004 + * ] + */ + suggestManageLanguageIds?: number[]; /** * @description List of languages user can translate to. If null, all languages editing is permitted. * @example [ @@ -3398,6 +3415,7 @@ export interface components { | "translations.view" | "translations.edit" | "translations.suggest" + | "translation-suggestions.manage" | "keys.edit" | "screenshots.upload" | "screenshots.delete" @@ -4892,6 +4910,7 @@ export interface components { | "translations.view" | "translations.edit" | "translations.suggest" + | "translation-suggestions.manage" | "keys.edit" | "screenshots.upload" | "screenshots.delete" @@ -4943,6 +4962,14 @@ export interface components { * ] */ suggestLanguageIds?: number[]; + /** + * @description List of languages user can manage suggestions for. If null, managing suggestions for all languages is permitted. + * @example [ + * 200001, + * 200004 + * ] + */ + suggestManageLanguageIds?: number[]; /** * @description List of languages user can translate to. If null, all languages editing is permitted. * @example [ @@ -4989,6 +5016,7 @@ export interface components { | "translations.view" | "translations.edit" | "translations.suggest" + | "translation-suggestions.manage" | "keys.edit" | "screenshots.upload" | "screenshots.delete" @@ -5040,6 +5068,14 @@ export interface components { * ] */ suggestLanguageIds?: number[]; + /** + * @description List of languages user can manage suggestions for. If null, managing suggestions for all languages is permitted. + * @example [ + * 200001, + * 200004 + * ] + */ + suggestManageLanguageIds?: number[]; /** * @description List of languages user can translate to. If null, all languages editing is permitted. * @example [ @@ -5369,6 +5405,8 @@ export interface components { stateChangeLanguages?: number[]; /** @description Languages user can suggest translation */ suggestLanguages?: number[]; + /** @description Languages user can manage suggestions for */ + suggestManageLanguages?: number[]; /** @description Languages user can translate to */ translateLanguages?: number[]; /** @enum {string} */ @@ -18909,7 +18947,6 @@ export interface operations { }; }; }; - /** User can only delete suggestion created by them */ deleteSuggestion: { parameters: { path: { @@ -23958,6 +23995,7 @@ export interface operations { viewLanguages?: number[]; stateChangeLanguages?: number[]; suggestLanguages?: number[]; + suggestManageLanguages?: number[]; }; }; responses: { @@ -24008,6 +24046,7 @@ export interface operations { viewLanguages?: number[]; stateChangeLanguages?: number[]; suggestLanguages?: number[]; + suggestManageLanguages?: number[]; }; }; responses: { @@ -24600,6 +24639,7 @@ export interface operations { | "translations.view" | "translations.edit" | "translations.suggest" + | "translation-suggestions.manage" | "keys.edit" | "screenshots.upload" | "screenshots.delete" diff --git a/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx b/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx index 139ce2e3e6c..1919016ec74 100644 --- a/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx +++ b/webapp/src/views/projects/translations/Suggestions/SuggestionsList.tsx @@ -90,6 +90,10 @@ export const SuggestionsList = ({ 'translations.state-edit', languageId ); + const canModerateSuggestions = satisfiesLanguageAccess( + 'translation-suggestions.manage', + languageId + ); const user = useUser(); const projectId = project.id; @@ -142,7 +146,7 @@ export const SuggestionsList = ({ return { ...translation, suggestions: shown, - activeSuggestionCount: firstPage.page?.totalElements ?? 0, + activeSuggestionCount: firstPage?.page?.totalElements ?? 0, }; }, }); @@ -336,7 +340,7 @@ export const SuggestionsList = ({ canReview ? () => handleReverse(item.id) : undefined } onDelete={ - user?.id === item.author.id + user?.id === item.author.id || canModerateSuggestions ? () => handleDelete(item.id) : undefined } diff --git a/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx b/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx index 27d7b780f71..3188bab392a 100644 --- a/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx +++ b/webapp/src/views/projects/translations/Suggestions/TranslationSuggestion.tsx @@ -20,7 +20,6 @@ import { Trash01, XClose, } from '@untitled-ui/icons-react'; -import { useUser } from 'tg.globalContext/helpers'; import { SuggestionAction } from './SuggestionAction'; import { TranslationVisual } from '../translationVisual/TranslationVisual'; import { useRef, useState } from 'react'; @@ -123,7 +122,6 @@ export const TranslationSuggestion = ({ }: Props) => { const theme = useTheme(); const formatDate = useTimeDistance(); - const user = useUser(); const { t } = useTranslate(); const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); @@ -173,7 +171,7 @@ export const TranslationSuggestion = ({ disabled: isLoading, }); } - if (onDelete && suggestion.author.id === user?.id) { + if (onDelete) { menuItems.push({ action: 'delete', label: t('translation_suggestion_delete_tooltip'), @@ -202,7 +200,10 @@ export const TranslationSuggestion = ({ , }} />