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..acf68bca9a0 --- /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.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 +@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 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..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..a042d5bab6b --- /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 + +/** + * 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( + 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..96dc8cd3aef --- /dev/null +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt @@ -0,0 +1,37 @@ +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 = ["/generate-few"]) + @Transactional + fun generateFew() { + testDataService.saveTestData(PublicProjectsE2eData(count = 5).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..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..62c37530679 --- /dev/null +++ b/e2e/cypress/e2e/projects/publicProjects.cy.ts @@ -0,0 +1,81 @@ +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'; + +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.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'); + 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.generate(); + 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.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'); + }); + + 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/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; 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 74b11fb6cd0..bae93781ff2 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..72589fc977e --- /dev/null +++ b/webapp/src/views/projects/public/CommunityTranslationBanner.tsx @@ -0,0 +1,135 @@ +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..7a326b2a71a --- /dev/null +++ b/webapp/src/views/projects/public/PublicProjectListView.tsx @@ -0,0 +1,108 @@ +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; + 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 MAX_PROJECTS_WITHOUT_SEARCH = 5; + +const StyledSearch = styled('div')` + display: flex; + padding: ${({ theme }) => theme.spacing(0, 0, 3)}; + & > * { + width: 220px; + } +`; + +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, + }, + }); + + // 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" + /> + + )} + + + + } + /> + + + ); +}; + +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 ? ( + + ) : ( + <> + + + + )} + + + ); +}; 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;