Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class UserPreferencesController(
@PathVariable organizationId: Long,
) {
val organization = organizationService.get(organizationId)
organizationRoleService.checkUserCanView(organization.id)
organizationRoleService.checkUserCanViewOrPublic(organization.id)
userPreferencesService.setPreferredOrganization(organization, authenticationFacade.authenticatedUserEntity)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.activity.ActivityHolder
import io.tolgee.api.isMfaEnabled
import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider
import io.tolgee.component.PreferredOrganizationFacade
import io.tolgee.constants.Message
import io.tolgee.dtos.request.SuperTokenRequest
import io.tolgee.dtos.request.UserUpdatePasswordRequestDto
import io.tolgee.dtos.request.UserUpdateRequestDto
import io.tolgee.exceptions.AuthenticationException
import io.tolgee.hateoas.organization.PrivateOrganizationModel
import io.tolgee.hateoas.organization.PrivateOrganizationModelAssembler
import io.tolgee.hateoas.organization.SimpleOrganizationModel
import io.tolgee.hateoas.organization.SimpleOrganizationModelAssembler
import io.tolgee.hateoas.sso.PublicSsoTenantModel
Expand Down Expand Up @@ -67,14 +66,13 @@ class V2UserController(
private val publicSsoTenantModelAssembler: PublicSsoTenantModelAssembler,
private val imageUploadService: ImageUploadService,
private val organizationService: OrganizationService,
private val preferredOrganizationFacade: PreferredOrganizationFacade,
private val organizationRoleService: OrganizationRoleService,
private val privateOrganizationModelAssembler: PrivateOrganizationModelAssembler,
private val tenantService: TenantService,
private val simpleOrganizationModelAssembler: SimpleOrganizationModelAssembler,
private val passwordEncoder: PasswordEncoder,
private val jwtService: JwtService,
private val mfaService: MfaService,
private val enabledFeaturesProvider: EnabledFeaturesProvider,
private val emailVerificationService: EmailVerificationService,
@Qualifier("requestActivityHolder") private val request: ActivityHolder,
) {
Expand Down Expand Up @@ -246,15 +244,10 @@ class V2UserController(
fun getManagedBy(): ResponseEntity<PrivateOrganizationModel> {
val userAccount = authenticationFacade.authenticatedUser
val org = organizationRoleService.getManagedBy(userId = userAccount.id) ?: return ResponseEntity.noContent().build()
val view =
organizationService.findPrivateView(org.id, authenticationFacade.authenticatedUser.id)
val model =
preferredOrganizationFacade.getPrivateModel(org.id)
?: return ResponseEntity.noContent().build()
return ResponseEntity.ok(
privateOrganizationModelAssembler.toModel(
view,
enabledFeaturesProvider.get(view.organization.id),
),
)
return ResponseEntity.ok(model)
}

@PostMapping("")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.tolgee.hateoas.language.OrganizationLanguageModel
import io.tolgee.hateoas.language.OrganizationLanguageModelAssembler
import io.tolgee.security.authorization.UseDefaultPermissions
import io.tolgee.service.language.LanguageService
import io.tolgee.service.security.SecurityService
import org.springdoc.core.annotations.ParameterObject
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
Expand All @@ -29,6 +30,7 @@ class OrganizationLanguageController(
private val languageService: LanguageService,
private val organizationLanguageModelAssembler: OrganizationLanguageModelAssembler,
private val pagedOrganizationLanguageAssembler: PagedResourcesAssembler<OrganizationLanguageDto>,
private val securityService: SecurityService,
) {
@Operation(
summary = "Get all languages in use by projects owned by specified organization",
Expand All @@ -46,7 +48,13 @@ class OrganizationLanguageController(
@RequestParam("projectIds") projectIds: List<Long>?,
@PathVariable organizationId: Long,
): PagedModel<OrganizationLanguageModel> {
val languages = languageService.getPagedByOrganization(organizationId, projectIds, pageable, search)
val languages =
languageService.getPagedByOrganization(
organizationId,
securityService.getAccessibleProjectIdsInOrganization(organizationId, projectIds),
pageable,
search,
)
return pagedOrganizationLanguageAssembler.toModel(languages, organizationLanguageModelAssembler)
}

Expand All @@ -65,7 +73,13 @@ class OrganizationLanguageController(
@RequestParam("projectIds") projectIds: List<Long>?,
@PathVariable organizationId: Long,
): PagedModel<OrganizationLanguageModel> {
val languages = languageService.getBasePagedByOrganization(organizationId, projectIds, pageable, search)
val languages =
languageService.getBasePagedByOrganization(
organizationId,
securityService.getAccessibleProjectIdsInOrganization(organizationId, projectIds),
pageable,
search,
)
return pagedOrganizationLanguageAssembler.toModel(languages, organizationLanguageModelAssembler)
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package io.tolgee.component

import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider
import io.tolgee.dtos.cacheable.isSupporterOrAdmin
import io.tolgee.hateoas.organization.PrivateOrganizationModel
import io.tolgee.hateoas.organization.PrivateOrganizationModelAssembler
import io.tolgee.security.authentication.AuthenticationFacade
import io.tolgee.service.organization.OrganizationRoleService
import io.tolgee.service.organization.OrganizationService
import io.tolgee.service.security.UserPreferencesService
import org.springframework.stereotype.Component
Expand All @@ -16,19 +18,30 @@ class PreferredOrganizationFacade(
private val privateOrganizationModelAssembler: PrivateOrganizationModelAssembler,
private val enabledFeaturesProvider: EnabledFeaturesProvider,
private val organizationService: OrganizationService,
private val organizationRoleService: OrganizationRoleService,
) {
fun getPreferred(): PrivateOrganizationModel? {
val preferences = userPreferencesService.findOrCreate(authenticationFacade.authenticatedUser.id)
val preferredOrganization = preferences.preferredOrganization
if (preferredOrganization != null) {
val view =
organizationService.findPrivateView(preferredOrganization.id, authenticationFacade.authenticatedUser.id)
?: return null
return this.privateOrganizationModelAssembler.toModel(
view,
enabledFeaturesProvider.get(view.organization.id),
)
val user = authenticationFacade.authenticatedUser
val preferences = userPreferencesService.findOrCreate(user.id)
var preferred = preferences.preferredOrganization
if (preferred == null || !organizationRoleService.canUserViewOrPublic(user, preferred.id)) {
preferred = userPreferencesService.refreshPreferredOrganization(user.id) ?: return null
}
return null

return getPrivateModel(preferred.id)
}

fun getPrivateModel(organizationId: Long): PrivateOrganizationModel? {
val user = authenticationFacade.authenticatedUser
val view = organizationService.findPrivateView(organizationId, user.id) ?: return null
val isAtLeastMember = organizationRoleService.canUserViewAtLeastMember(user, organizationId)
val limitedView =
!user.isSupporterOrAdmin() && !organizationRoleService.canUserViewStrict(user.id, organizationId)
return privateOrganizationModelAssembler.toModel(
view,
enabledFeaturesProvider.get(view.organization.id),
isAtLeastMember,
limitedView,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,10 @@ open class PrivateOrganizationModel(
val quickStart: QuickStartModel?,
@get:Schema(example = "Current active subscription info")
val activeCloudSubscription: PublicCloudSubscriptionModel?,
@get:Schema(
example = "false",
description = "Whether the user views the organization purely via public-project access",
)
val limitedView: Boolean,
) : RepresentationModel<PrivateOrganizationModel>(),
IOrganizationModel by organizationModel
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import io.tolgee.constants.Feature
import io.tolgee.dtos.queryResults.organization.PrivateOrganizationView
import io.tolgee.hateoas.quickStart.QuickStartModelAssembler
import io.tolgee.publicBilling.CloudSubscriptionModelProvider
import io.tolgee.publicBilling.PublicCloudSubscriptionModel
import org.springframework.stereotype.Component

@Component
Expand All @@ -15,12 +16,27 @@ class PrivateOrganizationModelAssembler(
fun toModel(
view: PrivateOrganizationView,
features: Array<Feature>,
isAtLeastMember: Boolean,
limitedView: Boolean,
): PrivateOrganizationModel {
val organizationId = view.organization.id
val organizationModel = organizationModelAssembler.toModel(view.organization)
return PrivateOrganizationModel(
organizationModel = organizationModelAssembler.toModel(view.organization),
organizationModel = organizationModel,
enabledFeatures = features,
quickStart = view.quickStart?.let { quickStartModelAssembler.toModel(it) },
activeCloudSubscription = cloudSubscriptionModelProvider?.provide(view.organization.id),
activeCloudSubscription = activeCloudSubscription(isAtLeastMember, organizationId),
limitedView = limitedView,
)
}

private fun activeCloudSubscription(
isAtLeastMember: Boolean,
organizationId: Long,
): PublicCloudSubscriptionModel? {
if (!isAtLeastMember) {
return null
}
return cloudSubscriptionModelProvider?.provide(organizationId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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.model.UserAccount
import io.tolgee.testing.AuthorizedControllerTest
import io.tolgee.testing.assertions.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class PreferredOrganizationCommunityTest : AuthorizedControllerTest() {
lateinit var testData: PublicProjectsControllerTestData

@BeforeEach
fun setup() {
testData = PublicProjectsControllerTestData()
testDataService.saveTestData(testData.root)
}

@AfterEach
fun clean() {
testDataService.cleanTestData(testData.root)
}

@Test
fun `community user gets the reduced organization model`() {
setPreferred(testData.nonMember, testData.otherOrg.id)
userAccount = testData.nonMember
performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson {
node("name").isEqualTo("Vibrant translators")
node("currentUserRole").isEqualTo(null)
node("limitedView").isEqualTo(true)
node("basePermissions").isNotNull
node("activeCloudSubscription").isEqualTo(null)
}
}

@Test
fun `member gets the full organization model`() {
setPreferred(testData.otherOrgMember, testData.otherOrg.id)
userAccount = testData.otherOrgMember
performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson {
node("name").isEqualTo("Vibrant translators")
node("currentUserRole").isEqualTo("MEMBER")
node("limitedView").isEqualTo(false)
}
}

@Test
fun `direct project permission user gets the reduced model with no role but a non-limited view`() {
setPreferred(testData.directPermissionUser, testData.otherOrg.id)
userAccount = testData.directPermissionUser
performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson {
node("name").isEqualTo("Vibrant translators")
node("currentUserRole").isEqualTo(null)
node("limitedView").isEqualTo(false)
node("activeCloudSubscription").isEqualTo(null)
}
}

@Test
fun `stale preference heals to a member organization`() {
setPreferred(testData.user, testData.otherOrg.id)
unpublishOtherOrgProject()

userAccount = testData.user
val ownOrg = testData.userAccountBuilder.defaultOrganizationBuilder.self
performAuthGet("/v2/public/initial-data").andIsOk.andAssertThatJson {
node("preferredOrganization.id").isEqualTo(ownOrg.id)
node("preferredOrganization.currentUserRole").isEqualTo("OWNER")
}
assertStoredPreference(testData.user.id, ownOrg.id)
}

@Test
fun `stale preference with no other viewable organization heals to a created one`() {
setPreferred(testData.nonMember, testData.otherOrg.id)
unpublishOtherOrgProject()
executeInNewTransaction {
organizationService.delete(organizationService.get(testData.nonMemberPersonalOrg.id))
}

userAccount = testData.nonMember
performAuthGet("/v2/public/initial-data").andIsOk.andAssertThatJson {
node("preferredOrganization.name").isEqualTo("Non Member")
node("preferredOrganization.currentUserRole").isEqualTo("OWNER")
}
executeInNewTransaction {
val preferred = userPreferencesService.find(testData.nonMember.id)!!.preferredOrganization!!
assertThat(preferred.id).isNotEqualTo(testData.otherOrg.id)
assertThat(preferred.name).isEqualTo("Non Member")
}
}

private fun setPreferred(
user: UserAccount,
organizationId: Long,
) {
executeInNewTransaction {
userPreferencesService.setPreferredOrganization(
organizationService.get(organizationId),
userAccountService.get(user.id),
)
}
}

private fun unpublishOtherOrgProject() {
executeInNewTransaction {
projectService.get(testData.otherOrgPublicProject.id).public = false
}
}

private fun assertStoredPreference(
userId: Long,
organizationId: Long,
) {
executeInNewTransaction {
assertThat(userPreferencesService.find(userId)!!.preferredOrganization!!.id).isEqualTo(organizationId)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
Expand All @@ -20,26 +21,11 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() {
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()
}
}

@AfterEach
fun cleanup() {
testDataService.cleanTestData(testData.root)
}

@Test
Expand Down Expand Up @@ -187,6 +173,25 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() {
}
}

@Test
fun `excludes a public project of a soft-deleted organization`() {
performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects").isArray.hasSize(2)
}
}

@Test
fun `hasPublicProjects matches the public listing visibility exactly`() {
val defaultOrg = testData.userAccountBuilder.defaultOrganizationBuilder.self
projectService.hasPublicProjects(defaultOrg.id).assert.isTrue()
projectService.hasPublicProjects(testData.otherOrg.id).assert.isTrue()
projectService.hasPublicProjects(testData.noPublicOrg.id).assert.isFalse()
projectService.hasPublicProjects(testData.noBaseLangOnlyOrg.id).assert.isFalse()
projectService.hasPublicProjects(testData.softDeletedBaseLangOnlyOrg.id).assert.isFalse()
projectService.hasPublicProjects(testData.deletedProjectOnlyOrg.id).assert.isFalse()
projectService.hasPublicProjects(testData.softDeletedOrg.id).assert.isFalse()
}

private fun baseLanguageId(projectId: Long): Long? =
executeInNewTransaction {
(
Expand Down
Loading
Loading