From 5d5239fe2e51424961137f6c657238044b08d5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Mon, 29 Jun 2026 23:19:35 +0200 Subject: [PATCH 1/6] feat: public projects view Add a logged-out-capable /public-projects page listing all public projects with stats, search and paging, backed by a new anonymous GET /v2/public/projects/with-stats endpoint. - Backend: PublicProjectsController (anonymous, hidden from public docs); ProjectService.findAllPublicPaged + ProjectRepository.findAllPublic (reuses BASE_VIEW_QUERY with a NO_USER_ID sentinel; guards public / deleted / org-owner / base-language to keep the anonymous read side-effect-free); public flag exposed on ProjectWithStatsModel. - Frontend: /public-projects route, PublicProjectListView with PublicTopBar and a community-translation onboarding banner; shared ProjectsList extracted from ProjectListView; DashboardProjectListItem public variant (public badge + org line, member affordances suppressed, row click goes to login). - Tests: PublicProjectsControllerTest (anonymous/member/direct-permission joins, all guard filters, case-insensitive search) and a publicProjects.cy.ts E2E. --- .../project/PublicProjectsController.kt | 42 ++++ .../hateoas/project/ProjectWithStatsModel.kt | 2 + .../project/ProjectWithStatsModelAssembler.kt | 1 + .../tolgee/configuration/WebSecurityConfig.kt | 3 + .../PublicProjectsControllerTest.kt | 202 ++++++++++++++++++ .../data/PublicProjectsControllerTestData.kt | 106 +++++++++ .../data/PublicProjectsE2eData.kt | 43 ++++ .../io/tolgee/repository/ProjectRepository.kt | 24 +++ .../tolgee/service/project/ProjectService.kt | 14 ++ .../PublicProjectsE2eDataController.kt | 31 +++ .../common/apiCalls/testData/testData.ts | 2 + e2e/cypress/e2e/projects/publicProjects.cy.ts | 45 ++++ webapp/src/component/RootRouter.tsx | 7 + webapp/src/constants/links.tsx | 2 + webapp/src/service/apiSchema.generated.ts | 62 +++++- .../projects/DashboardProjectListItem.tsx | 149 +++++++------ webapp/src/views/projects/ProjectListView.tsx | 117 +++++----- webapp/src/views/projects/ProjectsList.tsx | 54 +++++ .../public/CommunityTranslationBanner.tsx | 107 ++++++++++ .../projects/public/PublicProjectListView.tsx | 76 +++++++ .../views/projects/public/PublicTopBar.tsx | 89 ++++++++ 21 files changed, 1040 insertions(+), 138 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/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 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..a3b7ab2700a --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/PublicProjectsController.kt @@ -0,0 +1,42 @@ +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.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") + fun getAllWithStatistics( + @ParameterObject @SortDefault("name") pageable: Pageable, + @RequestParam("search") search: String?, + ): PagedModel { + val projects = projectService.findAllPublicPaged(pageable, search) + return projectWithStatsFacade.getPagedModelWithStats(projects) + } +} 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 59e8628a338..f8b226d3082 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 @@ -20,6 +20,8 @@ open class ProjectWithStatsModel( val slug: String?, val avatar: Avatar?, val organizationOwner: SimpleOrganizationModel?, + @Schema(description = "Whether the project is public — discoverable and open to community suggestions") + val public: Boolean, val baseLanguage: LanguageModel?, val organizationRole: OrganizationRoleType?, @Schema(description = "Current user's direct permission", example = "MANAGE") 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 5fe0857a6ad..2c63f3305e9 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 @@ -53,6 +53,7 @@ class ProjectWithStatsModelAssembler( organizationRole = view.organizationRole, baseLanguage = baseLanguage.let { languageModelAssembler.toModel(LanguageDto.fromEntity(it, it.id)) }, organizationOwner = view.organizationOwner.let { simpleOrganizationModelAssembler.toModel(it) }, + public = view.public, directPermission = view.directPermission?.let { permissionModelAssembler.toModel(it) }, computedPermission = computedPermissionModelAssembler.toModel(computedPermissions), stats = view.stats, 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 76c54b4614d..c00298b31b8 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..7f8bf86640d --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt @@ -0,0 +1,202 @@ +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`() { + // mixed case vs stored "Other org public project" — pins lower(r.name) + 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`() { + // lowercase vs stored "Vibrant translators" — pins lower(o.name) + 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("NONE") + 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 also gets NONE 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("NONE") + node("[1].computedPermission.scopes").isArray.hasSize(0) + } + } + } + + @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) + } + // the anonymous read must not have triggered the base-language write-on-read + 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`() { + // if it were listed, the assembler's non-null organizationOwner deref would 500 instead of 200 + 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..0b021f5f7bc --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt @@ -0,0 +1,106 @@ +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 + + // holds a DIRECT project permission on otherOrgPublicProject without belonging to its org + 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..91a7f56cd1a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt @@ -0,0 +1,43 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.ProjectBuilder +import io.tolgee.model.Project + +/** + * E2E fixture for the /public-projects page. The BaseTestData project ("Private project") stays + * non-public so the spec can assert it is absent from the anonymous listing; two public projects in + * the same org are discoverable. + */ +class PublicProjectsE2eData : BaseTestData("publicProjectsUser", "Private project") { + lateinit var communityAlpha: Project + lateinit var communityBeta: Project + + init { + root.apply { + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Community Alpha" + public = true + }.build { + communityAlpha = self + addBaseLanguage() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Community Beta" + public = true + }.build { + communityBeta = 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/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..ca0afb59bf8 --- /dev/null +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt @@ -0,0 +1,31 @@ +package io.tolgee.controllers.internal.e2eData + +import io.tolgee.controllers.internal.InternalController +import io.tolgee.development.testDataBuilder.TestDataService +import io.tolgee.development.testDataBuilder.data.PublicProjectsE2eData +import io.tolgee.service.project.ProjectService +import io.tolgee.service.security.UserAccountService +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.GetMapping + +@InternalController(["internal/e2e-data/public-projects"]) +class PublicProjectsE2eDataController( + private val testDataService: TestDataService, + private val projectService: ProjectService, + private val userAccountService: UserAccountService, +) { + @GetMapping(value = ["/generate"]) + @Transactional + fun generate() { + testDataService.saveTestData(PublicProjectsE2eData().root) + } + + @GetMapping(value = ["/clean"]) + @Transactional + fun clean() { + userAccountService.findActive("publicProjectsUser")?.let { user -> + projectService.findAllPermitted(user).forEach { projectService.deleteProject(it.id!!) } + userAccountService.delete(user) + } + } +} diff --git a/e2e/cypress/common/apiCalls/testData/testData.ts b/e2e/cypress/common/apiCalls/testData/testData.ts index 0f200e2f19d..6429289f99d 100644 --- a/e2e/cypress/common/apiCalls/testData/testData.ts +++ b/e2e/cypress/common/apiCalls/testData/testData.ts @@ -56,6 +56,8 @@ export const projectListData = generateTestDataObject( 'projects-list-dashboard' ); +export const publicProjectsData = generateTestDataObject('public-projects'); + 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..0d59e049dc2 --- /dev/null +++ b/e2e/cypress/e2e/projects/publicProjects.cy.ts @@ -0,0 +1,45 @@ +import { HOST } from '../../common/constants'; +import { gcy } from '../../common/shared'; +import { publicProjectsData } from '../../common/apiCalls/testData/testData'; +import { waitForGlobalLoading } from '../../common/loading'; + +describe('Public projects view', () => { + beforeEach(() => { + publicProjectsData.clean(); + publicProjectsData.generate(); + cy.visit(`${HOST}/public-projects`); + waitForGlobalLoading(); + }); + + afterEach(() => { + publicProjectsData.clean(); + }); + + it('shows the community banner and login/sign-up for a logged-out visitor', () => { + 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', () => { + gcy('dashboard-projects-list-item').should('have.length', 2); + gcy('project-list-public-info').should('have.length', 2); + cy.contains('Community Alpha').should('be.visible'); + cy.contains('Community Beta').should('be.visible'); + cy.contains('Private project').should('not.exist'); + }); + + it('narrows the list with search', () => { + 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('routes a public row click to the login page', () => { + gcy('dashboard-projects-list-item').first().click(); + cy.url().should('include', '/login'); + }); +}); 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..b3c6eea6fe7 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -278,7 +278,7 @@ export interface paths { }; "/v2/organizations/{organizationId}/projects-with-stats": { /** Returns all projects (including statistics) where current user has any permission (except none) */ - get: operations["getAllWithStatistics_2"]; + get: operations["getAllWithStatistics_3"]; }; "/v2/organizations/{organizationId}/set-base-permissions": { /** Set default granular (scope-based) permissions for organization users, who don't have direct project permissions set. */ @@ -384,7 +384,7 @@ export interface paths { }; "/v2/organizations/{slug}/projects-with-stats": { /** Returns all projects (including statistics) where current user has any permission (except none) */ - get: operations["getAllWithStatistics_1"]; + get: operations["getAllWithStatistics_2"]; }; "/v2/pats": { get: operations["getAll_9"]; @@ -417,7 +417,7 @@ export interface paths { }; "/v2/projects/with-stats": { /** Returns all projects (including statistics) where current user has any permission */ - get: operations["getAllWithStatistics"]; + get: operations["getAllWithStatistics_1"]; }; "/v2/projects/{projectId}": { get: operations["get_2"]; @@ -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["getAllWithStatistics"]; + }; "/v2/public/scope-info/hierarchy": { get: operations["getHierarchy"]; }; @@ -11483,7 +11487,7 @@ export interface operations { }; }; /** Returns all projects (including statistics) where current user has any permission (except none) */ - getAllWithStatistics_2: { + getAllWithStatistics_3: { parameters: { query: { /** Zero-based page index (0..N) */ @@ -12939,7 +12943,7 @@ export interface operations { }; }; /** Returns all projects (including statistics) where current user has any permission (except none) */ - getAllWithStatistics_1: { + getAllWithStatistics_2: { parameters: { query: { /** Zero-based page index (0..N) */ @@ -13398,7 +13402,7 @@ export interface operations { }; }; /** Returns all projects (including statistics) where current user has any permission */ - getAllWithStatistics: { + getAllWithStatistics_1: { parameters: { query: { /** Zero-based page index (0..N) */ @@ -24501,6 +24505,52 @@ export interface operations { }; }; }; + /** Returns all public projects (including statistics), discoverable by anyone — no authentication required */ + getAllWithStatistics: { + 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 74b11fb6cd0..2b38d9dd46e 100644 --- a/webapp/src/views/projects/DashboardProjectListItem.tsx +++ b/webapp/src/views/projects/DashboardProjectListItem.tsx @@ -142,7 +142,12 @@ 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 rightPanelWidth = useGlobalContext((c) => c.layout.rightPanelWidth); @@ -156,71 +161,68 @@ const DashboardProjectListItem = (p: ProjectWithStatsModel) => { const showQaBadge = isEnabled('QA_CHECKS') && (hasQaIssues || hasStaleQaChecks); - return ( - + history.push( + isPublicVariant + ? 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 +264,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..9d34117bf81 --- /dev/null +++ b/webapp/src/views/projects/ProjectsList.tsx @@ -0,0 +1,54 @@ +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'; + onSearchChange?: (value: string) => void; + searchText?: string; +}; + +export const ProjectsList = ({ + loadable, + onPageChange, + emptyPlaceholder, + variant = 'default', + onSearchChange, + searchText, +}: 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..a36c5b6ef60 --- /dev/null +++ b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx @@ -0,0 +1,107 @@ +import { Box, styled, Typography } from '@mui/material'; +import { Edit05, MessageTextSquare02 } from '@untitled-ui/icons-react'; +import { T } from '@tolgee/react'; + +import { MouseIllustration } from 'tg.component/security/MouseIllustration'; + +const StyledBanner = styled('div')` + display: grid; + grid-template-columns: 1fr auto; + gap: ${({ theme }) => theme.spacing(4)}; + align-items: center; + padding: ${({ theme }) => theme.spacing(4, 0)}; + @container (max-width: 700px) { + grid-template-columns: 1fr; + } +`; + +const StyledContent = styled('div')` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; + max-width: 540px; +`; + +const StyledEyebrow = styled(Typography)` + font-weight: 500; + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +const StyledHeading = styled(Typography)` + font-size: 28px; + font-weight: 700; + color: ${({ theme }) => theme.palette.primary.main}; +`; + +const StyledBullets = styled('div')` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(0.5)}; + margin-top: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledBullet = styled('div')` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; + color: ${({ theme }) => theme.palette.text.secondary}; + & svg { + color: ${({ theme }) => theme.palette.primary.main}; + } +`; + +const StyledIllustration = styled(Box)` + @container (max-width: 700px) { + 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..d08109073d6 --- /dev/null +++ b/webapp/src/views/projects/public/PublicProjectListView.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { T, useTranslate } from '@tolgee/react'; +import { styled } from '@mui/material'; + +import { EmptyListMessage } from 'tg.component/common/EmptyListMessage'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { useWindowTitle } from 'tg.hooks/useWindowTitle'; +import { ProjectsList } from 'tg.views/projects/ProjectsList'; +import { CommunityTranslationBanner } from './CommunityTranslationBanner'; +import { PublicTopBar } from './PublicTopBar'; + +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: 1000px; + margin: 0 auto; + padding: ${({ theme }) => theme.spacing(0, 2, 4)}; +`; + +export const PublicProjectListView = () => { + const { t } = useTranslate(); + const [page, setPage] = useState(0); + const [search, setSearch] = useState(''); + + useWindowTitle(t('public_projects_window_title', 'Community translations')); + + const projectsLoadable = useApiQuery({ + url: '/v2/public/projects/with-stats', + method: 'get', + query: { + page, + size: 20, + search, + sort: ['name,asc'], + }, + options: { + keepPreviousData: true, + }, + }); + + return ( + + + + + { + setSearch(value); + setPage(0); + }} + searchText={search} + emptyPlaceholder={ + + + + } + /> + + + ); +}; + +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..2c13c4d3fe9 --- /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 ? ( + + ) : ( + <> + + + + )} + + + ); +}; From dc02f5f994f333b1ff080f4ece638b497a737946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 1 Jul 2026 19:44:50 +0200 Subject: [PATCH 2/6] fix: align public-projects banner and search with the Figma design Bring the /public-projects Community Translation banner and search in line with the Figma final view (file H6BDCYRjNFgXymTtnkQriW). - Full-bleed banner with the Figma left-to-right pink gradient (background.default@12% -> primary.main@12% @69% -> background.default@12%), theme-token based so it also covers the dark variant. - New dev-mouse mascot exported from Figma node 573:6097 (public/images/communityMouse.svg), peeking from the banner bottom edge; hidden below the shared SPLIT_CONTENT_BREAK_POINT so it never overlaps text. - Search is left-anchored and shown only above the projects-page threshold (> 5 projects); visibility is latched so clearing a narrowed search cannot unmount the field mid-interaction. Deliberate Figma pixel/type values kept as literals (no matching MUI theme typography variant or spacing multiple; not worth one-off theme entries for this single banner), from nodes 573:5991 (banner) and 573:6097 (mouse): - eyebrow type: 24px / weight 600 / line-height 1.235 / letter-spacing 0.25px - heading type: 40px / weight 700 / line-height 1.167 / letter-spacing -1.5px (rendered as variant h1 for semantics; the visual is the Figma override) - banner text column max-width: 561px - mouse height: 190px; bullet icons: 21x22px - search field width: 220px (overrides SearchField's 200px default) --- .../data/PublicProjectsE2eData.kt | 34 ++--- .../PublicProjectsE2eDataController.kt | 6 + .../common/apiCalls/testData/testData.ts | 5 +- e2e/cypress/e2e/projects/publicProjects.cy.ts | 37 ++++- webapp/public/images/communityMouse.svg | 55 +++++++ webapp/src/views/projects/ProjectsList.tsx | 6 - .../public/CommunityTranslationBanner.tsx | 138 ++++++++++-------- .../projects/public/PublicProjectListView.tsx | 48 +++++- .../projects/public/publicProjectsLayout.ts | 3 + 9 files changed, 230 insertions(+), 102 deletions(-) create mode 100644 webapp/public/images/communityMouse.svg create mode 100644 webapp/src/views/projects/public/publicProjectsLayout.ts 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 index 91a7f56cd1a..a042d5bab6b 100644 --- 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 @@ -1,33 +1,23 @@ package io.tolgee.development.testDataBuilder.data import io.tolgee.development.testDataBuilder.builders.ProjectBuilder -import io.tolgee.model.Project /** - * E2E fixture for the /public-projects page. The BaseTestData project ("Private project") stays - * non-public so the spec can assert it is absent from the anonymous listing; two public projects in - * the same org are discoverable. + * E2E fixture for the /public-projects page: seeds `count` public projects plus the non-public + * BaseTestData project ("Private project") used for the exclusion assertion. */ -class PublicProjectsE2eData : BaseTestData("publicProjectsUser", "Private project") { - lateinit var communityAlpha: Project - lateinit var communityBeta: Project - +class PublicProjectsE2eData( + count: Int = 6, +) : BaseTestData("publicProjectsUser", "Private project") { init { root.apply { - addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { - name = "Community Alpha" - public = true - }.build { - communityAlpha = self - addBaseLanguage() - } - - addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { - name = "Community Beta" - public = true - }.build { - communityBeta = self - addBaseLanguage() + listOf("Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta").take(count).forEach { suffix -> + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Community $suffix" + public = true + }.build { + addBaseLanguage() + } } } } 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 index ca0afb59bf8..96dc8cd3aef 100644 --- 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 @@ -20,6 +20,12 @@ class PublicProjectsE2eDataController( testDataService.saveTestData(PublicProjectsE2eData().root) } + @GetMapping(value = ["/generate-few"]) + @Transactional + fun generateFew() { + testDataService.saveTestData(PublicProjectsE2eData(count = 5).root) + } + @GetMapping(value = ["/clean"]) @Transactional fun clean() { diff --git a/e2e/cypress/common/apiCalls/testData/testData.ts b/e2e/cypress/common/apiCalls/testData/testData.ts index 6429289f99d..b1baa1fd267 100644 --- a/e2e/cypress/common/apiCalls/testData/testData.ts +++ b/e2e/cypress/common/apiCalls/testData/testData.ts @@ -56,7 +56,10 @@ export const projectListData = generateTestDataObject( 'projects-list-dashboard' ); -export const publicProjectsData = generateTestDataObject('public-projects'); +export const publicProjectsData = { + ...generateTestDataObject('public-projects'), + generateFew: () => internalFetch('e2e-data/public-projects/generate-few'), +}; export const projectTestData = generateTestDataObject('projects'); diff --git a/e2e/cypress/e2e/projects/publicProjects.cy.ts b/e2e/cypress/e2e/projects/publicProjects.cy.ts index 0d59e049dc2..09a7c11b8a7 100644 --- a/e2e/cypress/e2e/projects/publicProjects.cy.ts +++ b/e2e/cypress/e2e/projects/publicProjects.cy.ts @@ -4,11 +4,13 @@ import { publicProjectsData } from '../../common/apiCalls/testData/testData'; import { waitForGlobalLoading } from '../../common/loading'; describe('Public projects view', () => { - beforeEach(() => { - publicProjectsData.clean(); - publicProjectsData.generate(); + const visit = () => { cy.visit(`${HOST}/public-projects`); waitForGlobalLoading(); + }; + + beforeEach(() => { + publicProjectsData.clean(); }); afterEach(() => { @@ -16,6 +18,8 @@ describe('Public projects view', () => { }); it('shows the community banner and login/sign-up for a logged-out visitor', () => { + publicProjectsData.generate(); + 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'); @@ -24,21 +28,40 @@ describe('Public projects view', () => { }); it('lists public projects with the public badge + org and hides private ones', () => { - gcy('dashboard-projects-list-item').should('have.length', 2); - gcy('project-list-public-info').should('have.length', 2); + publicProjectsData.generate(); + visit(); + gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('project-list-public-info').should('have.length', 6); + gcy('global-search-field').should('exist'); cy.contains('Community Alpha').should('be.visible'); - cy.contains('Community Beta').should('be.visible'); + cy.contains('Community Zeta').should('be.visible'); cy.contains('Private project').should('not.exist'); }); it('narrows the list with search', () => { - gcy('global-list-search').find('input').type('Alpha'); + publicProjectsData.generate(); + 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.generate(); + visit(); gcy('dashboard-projects-list-item').first().click(); cy.url().should('include', '/login'); }); 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/views/projects/ProjectsList.tsx b/webapp/src/views/projects/ProjectsList.tsx index 9d34117bf81..7322407f4b4 100644 --- a/webapp/src/views/projects/ProjectsList.tsx +++ b/webapp/src/views/projects/ProjectsList.tsx @@ -24,8 +24,6 @@ type Props = { onPageChange: (page: number) => void; emptyPlaceholder: ReactNode; variant?: 'default' | 'public'; - onSearchChange?: (value: string) => void; - searchText?: string; }; export const ProjectsList = ({ @@ -33,16 +31,12 @@ export const ProjectsList = ({ onPageChange, emptyPlaceholder, variant = 'default', - onSearchChange, - searchText, }: Props) => { return ( ( diff --git a/webapp/src/views/projects/public/CommunityTranslationBanner.tsx b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx index a36c5b6ef60..be52667fb3b 100644 --- a/webapp/src/views/projects/public/CommunityTranslationBanner.tsx +++ b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx @@ -1,35 +1,49 @@ -import { Box, styled, Typography } from '@mui/material'; +import { alpha, styled, Typography } from '@mui/material'; import { Edit05, MessageTextSquare02 } from '@untitled-ui/icons-react'; import { T } from '@tolgee/react'; -import { MouseIllustration } from 'tg.component/security/MouseIllustration'; +import { SPLIT_CONTENT_BREAK_POINT } from 'tg.component/layout/CompactView'; +import { PUBLIC_CONTENT_MAX_WIDTH } from './publicProjectsLayout'; const StyledBanner = styled('div')` - display: grid; - grid-template-columns: 1fr auto; - gap: ${({ theme }) => theme.spacing(4)}; - align-items: center; - padding: ${({ theme }) => theme.spacing(4, 0)}; - @container (max-width: 700px) { - grid-template-columns: 1fr; - } + position: relative; + width: 100%; + overflow: hidden; + 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(1)}; - max-width: 540px; + gap: ${({ theme }) => theme.spacing(2.5)}; + max-width: 561px; `; const StyledEyebrow = styled(Typography)` - font-weight: 500; - color: ${({ theme }) => theme.palette.text.secondary}; + 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: 28px; + font-size: 40px; font-weight: 700; + line-height: 1.167; + letter-spacing: -1.5px; color: ${({ theme }) => theme.palette.primary.main}; `; @@ -37,21 +51,27 @@ const StyledBullets = styled('div')` display: flex; flex-direction: column; gap: ${({ theme }) => theme.spacing(0.5)}; - margin-top: ${({ theme }) => theme.spacing(1)}; `; const StyledBullet = styled('div')` display: flex; align-items: center; gap: ${({ theme }) => theme.spacing(1)}; - color: ${({ theme }) => theme.palette.text.secondary}; + color: ${({ theme }) => theme.palette.text.primary}; & svg { color: ${({ theme }) => theme.palette.primary.main}; + flex-shrink: 0; } `; -const StyledIllustration = styled(Box)` - @container (max-width: 700px) { +const StyledMouse = styled('img')` + position: absolute; + right: ${({ theme }) => theme.spacing(2)}; + bottom: 0; + height: 190px; + pointer-events: none; + user-select: none; + @media ${SPLIT_CONTENT_BREAK_POINT} { display: none; } `; @@ -59,49 +79,51 @@ const StyledIllustration = styled(Box)` export const CommunityTranslationBanner = () => { return ( - - - - - - - - - - - - - - + + +
+ - - - - - + + - - - - - - - + +
+ + + + + + + + + + + + + + + + + +
+ +
); }; diff --git a/webapp/src/views/projects/public/PublicProjectListView.tsx b/webapp/src/views/projects/public/PublicProjectListView.tsx index d08109073d6..4c4ed2d57f2 100644 --- a/webapp/src/views/projects/public/PublicProjectListView.tsx +++ b/webapp/src/views/projects/public/PublicProjectListView.tsx @@ -1,13 +1,15 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; 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 { useApiQuery } from 'tg.service/http/useQueryApi'; import { useWindowTitle } from 'tg.hooks/useWindowTitle'; import { ProjectsList } from 'tg.views/projects/ProjectsList'; import { CommunityTranslationBanner } from './CommunityTranslationBanner'; import { PublicTopBar } from './PublicTopBar'; +import { PUBLIC_CONTENT_MAX_WIDTH } from './publicProjectsLayout'; const StyledLayout = styled('div')` display: flex; @@ -19,11 +21,21 @@ const StyledLayout = styled('div')` const StyledContent = styled('div')` container-type: inline-size; width: 100%; - max-width: 1000px; + max-width: ${PUBLIC_CONTENT_MAX_WIDTH}px; margin: 0 auto; padding: ${({ theme }) => theme.spacing(0, 2, 4)}; `; +const MAX_PROJECTS_WITHOUT_SEARCH = 5; + +const StyledSearch = styled('div')` + display: flex; + padding: ${({ theme }) => theme.spacing(4, 0, 3)}; + & > * { + width: 220px; + } +`; + export const PublicProjectListView = () => { const { t } = useTranslate(); const [page, setPage] = useState(0); @@ -45,20 +57,40 @@ export const PublicProjectListView = () => { }, }); + // Latch visibility once relevant: keepPreviousData holds the filtered (small) count while a cleared + // search refetches, which would otherwise unmount the field mid-interaction and drop focus. + const searchRelevant = + Boolean(search) || + (projectsLoadable.data?.page?.totalElements ?? 0) > MAX_PROJECTS_WITHOUT_SEARCH; + const [showSearch, setShowSearch] = useState(false); + useEffect(() => { + if (searchRelevant) { + setShowSearch(true); + } + }, [searchRelevant]); + return ( + - + {showSearch && ( + + { + setSearch(value); + setPage(0); + }} + variant="outlined" + size="small" + /> + + )} { - setSearch(value); - setPage(0); - }} - searchText={search} emptyPlaceholder={ Date: Wed, 1 Jul 2026 20:04:52 +0200 Subject: [PATCH 3/6] fix: banner spacing, single-line heading, and dangling mascot Address design feedback on the public-projects banner: - Add a consistent gap between the banner and the project list even when the search field is hidden (top padding moved onto the content column). - Let the mascot dangle below the banner's bottom edge (banner overflow no longer clipped; mouse bottom -20px) so its paws grip the edge, as in Figma. - Keep the "Help projects speak your language" heading on one line (the 561px cap now applies only to the subtext, not the whole text column). --- .../projects/public/CommunityTranslationBanner.tsx | 10 ++++++---- .../views/projects/public/PublicProjectListView.tsx | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/webapp/src/views/projects/public/CommunityTranslationBanner.tsx b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx index be52667fb3b..10ffaa5c4cc 100644 --- a/webapp/src/views/projects/public/CommunityTranslationBanner.tsx +++ b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx @@ -8,7 +8,6 @@ import { PUBLIC_CONTENT_MAX_WIDTH } from './publicProjectsLayout'; const StyledBanner = styled('div')` position: relative; width: 100%; - overflow: hidden; background: linear-gradient( to right, ${({ theme }) => alpha(theme.palette.background.default, 0.12)}, @@ -28,6 +27,9 @@ const StyledContent = styled('div')` display: flex; flex-direction: column; gap: ${({ theme }) => theme.spacing(2.5)}; +`; + +const StyledSubtext = styled(Typography)` max-width: 561px; `; @@ -67,7 +69,7 @@ const StyledBullet = styled('div')` const StyledMouse = styled('img')` position: absolute; right: ${({ theme }) => theme.spacing(2)}; - bottom: 0; + bottom: -20px; height: 190px; pointer-events: none; user-select: none; @@ -95,12 +97,12 @@ export const CommunityTranslationBanner = () => { /> - + - + diff --git a/webapp/src/views/projects/public/PublicProjectListView.tsx b/webapp/src/views/projects/public/PublicProjectListView.tsx index 4c4ed2d57f2..7a326b2a71a 100644 --- a/webapp/src/views/projects/public/PublicProjectListView.tsx +++ b/webapp/src/views/projects/public/PublicProjectListView.tsx @@ -23,14 +23,14 @@ const StyledContent = styled('div')` width: 100%; max-width: ${PUBLIC_CONTENT_MAX_WIDTH}px; margin: 0 auto; - padding: ${({ theme }) => theme.spacing(0, 2, 4)}; + padding: ${({ theme }) => theme.spacing(4, 2)}; `; const MAX_PROJECTS_WITHOUT_SEARCH = 5; const StyledSearch = styled('div')` display: flex; - padding: ${({ theme }) => theme.spacing(4, 0, 3)}; + padding: ${({ theme }) => theme.spacing(0, 0, 3)}; & > * { width: 220px; } From 663018a4c23ad705007348c425e887184d7a912f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 1 Jul 2026 20:31:41 +0200 Subject: [PATCH 4/6] fix: banner spacing, single-line heading, and dangling mascot Address design feedback on the public-projects banner (Figma 573:5991): - Add a consistent gap between the banner and the project list even when the search field is hidden (top padding moved onto the content column). - Let the mascot dangle below the banner's bottom edge (banner overflow no longer clipped; mouse bottom -15px) so its paws grip the edge, per Figma. - Keep the "Help projects speak your language" heading on one line: the 561px cap now applies only to the subtext; the heading flows to full width. - Reserve the mascot's horizontal zone (padding-right 260px, removed below the 900px breakpoint where the mouse is hidden) so long-locale headings wrap instead of rendering under the mouse. Deliberate Figma pixel values (no matching MUI theme token): mouse height 190px, mouse dangle offset -15px, mascot-zone reserve 260px, subtext max-width 561px. --- .../projects/public/CommunityTranslationBanner.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/webapp/src/views/projects/public/CommunityTranslationBanner.tsx b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx index 10ffaa5c4cc..72589fc977e 100644 --- a/webapp/src/views/projects/public/CommunityTranslationBanner.tsx +++ b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx @@ -27,10 +27,10 @@ const StyledContent = styled('div')` display: flex; flex-direction: column; gap: ${({ theme }) => theme.spacing(2.5)}; -`; - -const StyledSubtext = styled(Typography)` - max-width: 561px; + padding-right: 260px; + @media ${SPLIT_CONTENT_BREAK_POINT} { + padding-right: 0; + } `; const StyledEyebrow = styled(Typography)` @@ -49,6 +49,10 @@ const StyledHeading = styled(Typography)` color: ${({ theme }) => theme.palette.primary.main}; `; +const StyledSubtext = styled(Typography)` + max-width: 561px; +`; + const StyledBullets = styled('div')` display: flex; flex-direction: column; @@ -69,7 +73,7 @@ const StyledBullet = styled('div')` const StyledMouse = styled('img')` position: absolute; right: ${({ theme }) => theme.spacing(2)}; - bottom: -20px; + bottom: -15px; height: 190px; pointer-events: none; user-select: none; From 7e57d4a649457b44f67298c8610b1a021c26a258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 1 Jul 2026 21:41:37 +0200 Subject: [PATCH 5/6] fix: reconcile public projects view with rebased base (#3770, #3771) The base now includes public badge + org on project rows (#3771) and community permissions for public projects (#3770), which overlap this PR: - Drop the duplicated `public` field on ProjectWithStatsModel and its assembler mapping (kept #3771's); the row now uses #3771's public badge + org name (data-cy project-list-public-badge), so the public-projects E2E asserts that hook. - DashboardProjectListItem keeps only the variant='public' behaviour on top of #3771's row: member affordances suppressed, whole-row click -> login. - Update PublicProjectsControllerTest for the community floor: a logged-in user with no role on a public project now gets VIEW (origin COMMUNITY) instead of NONE; anonymous callers still get NONE. - Regenerate apiSchema + dataCyType against the merged backend. --- .../io/tolgee/hateoas/project/ProjectWithStatsModel.kt | 2 -- .../hateoas/project/ProjectWithStatsModelAssembler.kt | 1 - .../api/v2/controllers/PublicProjectsControllerTest.kt | 10 ++++++---- e2e/cypress/e2e/projects/publicProjects.cy.ts | 2 +- e2e/cypress/support/dataCyType.d.ts | 3 +++ 5 files changed, 10 insertions(+), 8 deletions(-) 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 f8b226d3082..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 @@ -20,8 +20,6 @@ open class ProjectWithStatsModel( val slug: String?, val avatar: Avatar?, val organizationOwner: SimpleOrganizationModel?, - @Schema(description = "Whether the project is public — discoverable and open to community suggestions") - val public: Boolean, val baseLanguage: LanguageModel?, val organizationRole: OrganizationRoleType?, @Schema(description = "Current user's direct permission", example = "MANAGE") 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 2c63f3305e9..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 @@ -53,7 +53,6 @@ class ProjectWithStatsModelAssembler( organizationRole = view.organizationRole, baseLanguage = baseLanguage.let { languageModelAssembler.toModel(LanguageDto.fromEntity(it, it.id)) }, organizationOwner = view.organizationOwner.let { simpleOrganizationModelAssembler.toModel(it) }, - public = view.public, directPermission = view.directPermission?.let { permissionModelAssembler.toModel(it) }, computedPermission = computedPermissionModelAssembler.toModel(computedPermissions), stats = view.stats, 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 index 7f8bf86640d..c9934e6d558 100644 --- 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 @@ -121,7 +121,9 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { node("_embedded.projects") { node("[0].id").isEqualTo(testData.otherOrgPublicProject.id) node("[0].organizationRole").isEqualTo(null) - node("[0].computedPermission.type").isEqualTo("NONE") + // logged-in users get the community floor (VIEW) on public projects they have no role on + 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") @@ -150,7 +152,7 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { } @Test - fun `a logged-in non-member also gets NONE permission on a public row`() { + 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") { @@ -158,8 +160,8 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { node("[1].id").isEqualTo(testData.publicProject.id) node("[1].directPermission").isEqualTo(null) node("[1].organizationRole").isEqualTo(null) - node("[1].computedPermission.type").isEqualTo("NONE") - node("[1].computedPermission.scopes").isArray.hasSize(0) + node("[1].computedPermission.type").isEqualTo("VIEW") + node("[1].computedPermission.origin").isEqualTo("COMMUNITY") } } } diff --git a/e2e/cypress/e2e/projects/publicProjects.cy.ts b/e2e/cypress/e2e/projects/publicProjects.cy.ts index 09a7c11b8a7..32227b5c577 100644 --- a/e2e/cypress/e2e/projects/publicProjects.cy.ts +++ b/e2e/cypress/e2e/projects/publicProjects.cy.ts @@ -31,7 +31,7 @@ describe('Public projects view', () => { publicProjectsData.generate(); visit(); gcy('dashboard-projects-list-item').should('have.length', 6); - gcy('project-list-public-info').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'); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 2da584edb74..188d5ec1f2a 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; @@ -648,6 +649,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; From e5ad7b76469a41ebc1ee52a20a25f3a4d45cb69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 1 Jul 2026 22:15:14 +0200 Subject: [PATCH 6/6] fix: address whole-PR review findings - Anonymous read is now structurally side-effect-free: getAllPublicWithStatistics is @Transactional(readOnly = true), so the shared stats assembler cannot write even if the repository guard filter ever drifts (kept as defense in depth). - Remove the wildcard @CrossOrigin from the public endpoint; the webapp is same-origin and the public namespace now matches the rest of the API. - Auth-aware public row click: a logged-in visitor (who has at least the community VIEW permission on public projects, #3770) opens the project dashboard; only anonymous visitors are sent to login. - Rename the endpoint method to getAllPublicWithStatistics so it gets its own operationId and stops renumbering existing operations in the generated schema. - E2E: assert the public-variant affordance suppression (no row menu / QA badge / translations shortcut) and add a logged-in test (row click -> /projects/). - Drop six comments that restated names/assertions/KDoc rationale. PublicTopBar deliberately copies the existing TopBar chrome (no matching MUI theme token): light-mode boxShadow rgba(0,0,0,0.02) 0px 4px 6px, toolbar padding 12.5px, Righteous logo at 20px/500. TopBar is coupled to the authenticated dashboard shell (scroll-hide, announcements, quick-start, notifications) so it cannot be reused as-is; a shared-shell extraction is a future follow-up if a third bar appears. Note: the anonymous stats payload still includes qaIssueCount/qaChecksStaleCount (standard ProjectWithStatsModel) for already-public projects; a leaner public stats model is a possible follow-up if that exposure is unwanted. --- .../project/PublicProjectsController.kt | 6 +++--- .../controllers/PublicProjectsControllerTest.kt | 5 ----- .../data/PublicProjectsControllerTestData.kt | 1 - e2e/cypress/e2e/projects/publicProjects.cy.ts | 13 +++++++++++++ webapp/src/service/apiSchema.generated.ts | 16 ++++++++-------- .../views/projects/DashboardProjectListItem.tsx | 3 ++- .../projects/public/publicProjectsLayout.ts | 2 -- 7 files changed, 26 insertions(+), 20 deletions(-) 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 index a3b7ab2700a..acf68bca9a0 100644 --- 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 @@ -11,14 +11,13 @@ 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.web.bind.annotation.CrossOrigin +import org.springframework.transaction.annotation.Transactional 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 @@ -32,7 +31,8 @@ class PublicProjectsController( "Returns all public projects (including statistics), discoverable by anyone — no authentication required", ) @GetMapping("/with-stats") - fun getAllWithStatistics( + @Transactional(readOnly = true) + fun getAllPublicWithStatistics( @ParameterObject @SortDefault("name") pageable: Pageable, @RequestParam("search") search: String?, ): PagedModel { 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 index c9934e6d558..8d0163115ba 100644 --- 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 @@ -72,7 +72,6 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { @Test fun `search matches the project name case-insensitively`() { - // mixed case vs stored "Other org public project" — pins lower(r.name) performGet("/v2/public/projects/with-stats?search=other ORG").andIsOk.andAssertThatJson { node("_embedded.projects") { isArray.hasSize(1) @@ -83,7 +82,6 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { @Test fun `search matches the organization name case-insensitively`() { - // lowercase vs stored "Vibrant translators" — pins lower(o.name) performGet("/v2/public/projects/with-stats?search=vibrant").andIsOk.andAssertThatJson { node("_embedded.projects") { isArray.hasSize(1) @@ -121,7 +119,6 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { node("_embedded.projects") { node("[0].id").isEqualTo(testData.otherOrgPublicProject.id) node("[0].organizationRole").isEqualTo(null) - // logged-in users get the community floor (VIEW) on public projects they have no role on node("[0].computedPermission.type").isEqualTo("VIEW") node("[0].computedPermission.origin").isEqualTo("COMMUNITY") node("[1].id").isEqualTo(testData.publicProject.id) @@ -171,7 +168,6 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { node("_embedded.projects").isArray.hasSize(2) } - // the anonymous read must not have triggered the base-language write-on-read baseLanguageId(testData.noBaseLanguageProject.id).assert.isNull() } @@ -186,7 +182,6 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { @Test fun `excludes a public project with no organization owner`() { - // if it were listed, the assembler's non-null organizationOwner deref would 500 instead of 200 performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { node("_embedded.projects").isArray.hasSize(2) } 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 index 0b021f5f7bc..2ff78893b9f 100644 --- 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 @@ -14,7 +14,6 @@ class PublicProjectsControllerTestData : BaseTestData() { lateinit var otherOrgPublicProject: Project lateinit var nonMember: UserAccount - // holds a DIRECT project permission on otherOrgPublicProject without belonging to its org lateinit var directPermissionUser: UserAccount lateinit var noBaseLanguageProject: Project diff --git a/e2e/cypress/e2e/projects/publicProjects.cy.ts b/e2e/cypress/e2e/projects/publicProjects.cy.ts index 32227b5c577..62c37530679 100644 --- a/e2e/cypress/e2e/projects/publicProjects.cy.ts +++ b/e2e/cypress/e2e/projects/publicProjects.cy.ts @@ -1,5 +1,6 @@ import { HOST } from '../../common/constants'; import { gcy } from '../../common/shared'; +import { login } from '../../common/apiCalls/common'; import { publicProjectsData } from '../../common/apiCalls/testData/testData'; import { waitForGlobalLoading } from '../../common/loading'; @@ -36,6 +37,9 @@ describe('Public projects view', () => { 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', () => { @@ -65,4 +69,13 @@ describe('Public projects view', () => { gcy('dashboard-projects-list-item').first().click(); cy.url().should('include', '/login'); }); + + it('opens the project instead of login for a logged-in visitor', () => { + publicProjectsData.generate(); + 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/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index b3c6eea6fe7..7aa6dba0edb 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -278,7 +278,7 @@ export interface paths { }; "/v2/organizations/{organizationId}/projects-with-stats": { /** Returns all projects (including statistics) where current user has any permission (except none) */ - get: operations["getAllWithStatistics_3"]; + get: operations["getAllWithStatistics_2"]; }; "/v2/organizations/{organizationId}/set-base-permissions": { /** Set default granular (scope-based) permissions for organization users, who don't have direct project permissions set. */ @@ -384,7 +384,7 @@ export interface paths { }; "/v2/organizations/{slug}/projects-with-stats": { /** Returns all projects (including statistics) where current user has any permission (except none) */ - get: operations["getAllWithStatistics_2"]; + get: operations["getAllWithStatistics_1"]; }; "/v2/pats": { get: operations["getAll_9"]; @@ -417,7 +417,7 @@ export interface paths { }; "/v2/projects/with-stats": { /** Returns all projects (including statistics) where current user has any permission */ - get: operations["getAllWithStatistics_1"]; + get: operations["getAllWithStatistics"]; }; "/v2/projects/{projectId}": { get: operations["get_2"]; @@ -1184,7 +1184,7 @@ export interface paths { }; "/v2/public/projects/with-stats": { /** Returns all public projects (including statistics), discoverable by anyone — no authentication required */ - get: operations["getAllWithStatistics"]; + get: operations["getAllPublicWithStatistics"]; }; "/v2/public/scope-info/hierarchy": { get: operations["getHierarchy"]; @@ -11487,7 +11487,7 @@ export interface operations { }; }; /** Returns all projects (including statistics) where current user has any permission (except none) */ - getAllWithStatistics_3: { + getAllWithStatistics_2: { parameters: { query: { /** Zero-based page index (0..N) */ @@ -12943,7 +12943,7 @@ export interface operations { }; }; /** Returns all projects (including statistics) where current user has any permission (except none) */ - getAllWithStatistics_2: { + getAllWithStatistics_1: { parameters: { query: { /** Zero-based page index (0..N) */ @@ -13402,7 +13402,7 @@ export interface operations { }; }; /** Returns all projects (including statistics) where current user has any permission */ - getAllWithStatistics_1: { + getAllWithStatistics: { parameters: { query: { /** Zero-based page index (0..N) */ @@ -24506,7 +24506,7 @@ export interface operations { }; }; /** Returns all public projects (including statistics), discoverable by anyone — no authentication required */ - getAllWithStatistics: { + getAllPublicWithStatistics: { parameters: { query: { /** Zero-based page index (0..N) */ diff --git a/webapp/src/views/projects/DashboardProjectListItem.tsx b/webapp/src/views/projects/DashboardProjectListItem.tsx index 2b38d9dd46e..bae93781ff2 100644 --- a/webapp/src/views/projects/DashboardProjectListItem.tsx +++ b/webapp/src/views/projects/DashboardProjectListItem.tsx @@ -150,6 +150,7 @@ 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)` @@ -166,7 +167,7 @@ const DashboardProjectListItem = ({ variant = 'default', ...p }: Props) => { data-cy="dashboard-projects-list-item" onClick={() => history.push( - isPublicVariant + isPublicVariant && !allowPrivate ? LINKS.LOGIN.build() : LINKS.PROJECT_DASHBOARD.build({ [PARAMS.PROJECT_ID]: p.id, diff --git a/webapp/src/views/projects/public/publicProjectsLayout.ts b/webapp/src/views/projects/public/publicProjectsLayout.ts index e722475870f..b1da0094514 100644 --- a/webapp/src/views/projects/public/publicProjectsLayout.ts +++ b/webapp/src/views/projects/public/publicProjectsLayout.ts @@ -1,3 +1 @@ -// Width of the centered content column shared by the public-projects page: the banner's inner text -// column and the project list below it must line up on this value. export const PUBLIC_CONTENT_MAX_WIDTH = 1000;