diff --git a/backend/app/src/main/kotlin/io/deck/app/controller/MyWorkspaceController.kt b/backend/app/src/main/kotlin/io/deck/app/controller/MyWorkspaceController.kt index b2ed001ee..d7f269b7d 100644 --- a/backend/app/src/main/kotlin/io/deck/app/controller/MyWorkspaceController.kt +++ b/backend/app/src/main/kotlin/io/deck/app/controller/MyWorkspaceController.kt @@ -1,9 +1,9 @@ package io.deck.app.controller +import io.deck.iam.ManagementType import io.deck.iam.api.PublicEmailDomainPolicy import io.deck.iam.api.WorkspaceDirectory import io.deck.iam.api.WorkspaceInvitationManager -import io.deck.iam.api.WorkspaceManagedType import io.deck.iam.api.WorkspaceRoster import io.deck.iam.api.WorkspaceUserLookup import jakarta.validation.Valid @@ -37,18 +37,18 @@ class MyWorkspaceController( private val publicEmailDomainPolicy: PublicEmailDomainPolicy, ) { private fun ensureUserManagedEnabled() { - workspaceDirectory.ensureManagedTypeEnabled(WorkspaceManagedType.USER_MANAGED) + workspaceDirectory.ensureManagedTypeEnabled(ManagementType.USER_MANAGED) } - private fun ensureUserManagedAccessible(workspaceId: UUID) { - workspaceDirectory.ensureAccessible(workspaceId, WorkspaceManagedType.USER_MANAGED) + private fun ensureReadableWorkspace(workspaceId: UUID) { + workspaceDirectory.ensureAccessible(workspaceId) } private fun verifyUserManagedOwner( workspaceId: UUID, userId: UUID, ) { - workspaceDirectory.verifyOwner(workspaceId, userId, WorkspaceManagedType.USER_MANAGED) + workspaceDirectory.verifyOwner(workspaceId, userId, ManagementType.USER_MANAGED) } @GetMapping @@ -74,7 +74,7 @@ class MyWorkspaceController( principal: Principal, ): ResponseEntity { val userId = UUID.fromString(principal.name) - val workspace = workspaceDirectory.createForUser(request.name, request.description, userId, request.allowedDomains) + val workspace = workspaceDirectory.createForUser(request.name, request.description, userId, request.autoJoinDomains) val memberCount = workspaceRoster.countByWorkspaceId(workspace.id).toInt() return ResponseEntity.status(HttpStatus.CREATED).body(workspace.toMyWorkspaceDto(memberCount, true, loadOwnersByWorkspaceIds(listOf(workspace.id))[workspace.id].orEmpty())) } @@ -93,8 +93,8 @@ class MyWorkspaceController( name = request.name, description = request.description, requestedBy = userId, - managedType = WorkspaceManagedType.USER_MANAGED, - allowedDomains = request.allowedDomains, + managedType = ManagementType.USER_MANAGED, + autoJoinDomains = request.autoJoinDomains, ) val memberCount = workspaceRoster.countByWorkspaceId(id).toInt() return ResponseEntity.ok(updated.toMyWorkspaceDto(memberCount, true, loadOwnersByWorkspaceIds(listOf(id))[id].orEmpty())) @@ -107,7 +107,7 @@ class MyWorkspaceController( principal: Principal, ): ResponseEntity { val userId = UUID.fromString(principal.name) - workspaceDirectory.deleteBatch(request.workspaceIds, userId, WorkspaceManagedType.USER_MANAGED) + workspaceDirectory.deleteBatch(request.workspaceIds, userId, ManagementType.USER_MANAGED) return ResponseEntity.noContent().build() } @@ -120,7 +120,7 @@ class MyWorkspaceController( principal: Principal, ): ResponseEntity> { val userId = UUID.fromString(principal.name) - ensureUserManagedAccessible(id) + ensureReadableWorkspace(id) val members = workspaceRoster.listMembersIfMember(id, userId) val userIds = members.map { it.userId }.distinct() val users = workspaceUserLookup.findAllByIds(userIds).associateBy { it.id } diff --git a/backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceController.kt b/backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceController.kt index 108ddae4d..050bf597c 100644 --- a/backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceController.kt +++ b/backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceController.kt @@ -1,8 +1,8 @@ package io.deck.app.controller +import io.deck.iam.ManagementType import io.deck.iam.api.WorkspaceDirectory import io.deck.iam.api.WorkspaceInvitationManager -import io.deck.iam.api.WorkspaceManagedType import io.deck.iam.api.WorkspaceRoster import io.deck.iam.api.WorkspaceUserLookup import jakarta.validation.Valid @@ -32,15 +32,19 @@ class WorkspaceController( private val workspaceInvitations: WorkspaceInvitationManager, private val workspaceUserLookup: WorkspaceUserLookup, ) { - private fun ensureOwnerManagedEnabled() { - workspaceDirectory.ensureManagedTypeEnabled(WorkspaceManagedType.SYSTEM_MANAGED) + private fun ensurePlatformManagedEnabled() { + workspaceDirectory.ensureManagedTypeEnabled(ManagementType.PLATFORM_MANAGED) + } + + private fun requirePlatformManagedWorkspace(workspaceId: UUID) { + workspaceDirectory.getPlatformManaged(workspaceId) } @GetMapping @PreAuthorize("hasAuthority(@P.WORKSPACE_MANAGEMENT_READ)") fun list(): ResponseEntity> { - ensureOwnerManagedEnabled() - val workspaces = workspaceDirectory.listAll() + ensurePlatformManagedEnabled() + val workspaces = workspaceDirectory.listPlatformManaged() val memberCounts = workspaceRoster.countByWorkspaceIds(workspaces.map { it.id }) val ownersByWorkspaceId = loadOwnersByWorkspaceIds(workspaces.map { it.id }) val dtos = @@ -57,14 +61,13 @@ class WorkspaceController( @Valid @RequestBody request: CreateWorkspaceRequest, principal: Principal, ): ResponseEntity { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() val workspace = - workspaceDirectory.create( + workspaceDirectory.createPlatformManaged( name = request.name, description = request.description, initialOwnerId = UUID.fromString(principal.name), - managedType = request.managedType ?: WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = request.allowedDomains, + autoJoinDomains = request.autoJoinDomains, ) val memberCount = workspaceRoster.countByWorkspaceId(workspace.id).toInt() return ResponseEntity.status(HttpStatus.CREATED).body(workspace.toWorkspaceDto(memberCount, loadOwners(workspace.id))) @@ -77,15 +80,14 @@ class WorkspaceController( @Valid @RequestBody request: UpdateWorkspaceRequest, principal: Principal, ): ResponseEntity { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() val updated = - workspaceDirectory.updateByAdmin( + workspaceDirectory.updatePlatformManaged( workspaceId = id, name = request.name, description = request.description, updatedBy = UUID.fromString(principal.name), - managedType = request.managedType ?: workspaceDirectory.get(id).managedType, - allowedDomains = request.allowedDomains, + autoJoinDomains = request.autoJoinDomains, ) val memberCount = workspaceRoster.countByWorkspaceId(id).toInt() return ResponseEntity.ok(updated.toWorkspaceDto(memberCount, loadOwners(id))) @@ -97,8 +99,8 @@ class WorkspaceController( @Valid @RequestBody request: BatchDeleteWorkspaceRequest, principal: Principal, ): ResponseEntity { - ensureOwnerManagedEnabled() - workspaceDirectory.deleteByAdminBatch(request.workspaceIds, UUID.fromString(principal.name)) + ensurePlatformManagedEnabled() + workspaceDirectory.deletePlatformManagedBatch(request.workspaceIds, UUID.fromString(principal.name)) return ResponseEntity.noContent().build() } @@ -111,7 +113,7 @@ class WorkspaceController( @RequestParam(required = false) excludeUserIds: List?, pageable: Pageable, ): ResponseEntity> { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() val excludeIds = excludeUserIds?.toSet() ?: emptySet() val page = workspaceUserLookup.search(keyword = keyword, excludeIds = excludeIds, pageable = pageable) return ResponseEntity.ok(page.map { UserSearchResult(it.id, it.name, it.email) }) @@ -124,7 +126,8 @@ class WorkspaceController( fun listMembers( @PathVariable id: UUID, ): ResponseEntity> { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() + requirePlatformManagedWorkspace(id) val members = workspaceRoster.listMembers(id) val userIds = members.map { it.userId }.distinct() val users = workspaceUserLookup.findAllByIds(userIds).associateBy { it.id } @@ -139,9 +142,9 @@ class WorkspaceController( @Valid @RequestBody request: AddMemberRequest, principal: Principal, ): ResponseEntity { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() val currentUserId = UUID.fromString(principal.name) - workspaceDirectory.get(id) + requirePlatformManagedWorkspace(id) workspaceRoster.addMember(id, request.userId, currentUserId) return ResponseEntity.status(HttpStatus.CREATED).build() } @@ -153,9 +156,9 @@ class WorkspaceController( @Valid @RequestBody request: BatchAddMemberRequest, principal: Principal, ): ResponseEntity { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() val currentUserId = UUID.fromString(principal.name) - workspaceDirectory.get(id) + requirePlatformManagedWorkspace(id) request.userIds.forEach { userId -> workspaceRoster.addMember(id, userId, currentUserId) } @@ -169,7 +172,8 @@ class WorkspaceController( @Valid @RequestBody request: BatchRemoveMemberRequest, principal: Principal, ): ResponseEntity { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() + requirePlatformManagedWorkspace(id) workspaceRoster.removeMembers(id, request.userIds, UUID.fromString(principal.name)) return ResponseEntity.noContent().build() } @@ -180,8 +184,8 @@ class WorkspaceController( @PathVariable id: UUID, @Valid @RequestBody request: ReplaceWorkspaceOwnersRequest, ): ResponseEntity { - ensureOwnerManagedEnabled() - workspaceDirectory.get(id) + ensurePlatformManagedEnabled() + requirePlatformManagedWorkspace(id) workspaceRoster.replaceOwners(id, request.userIds) return ResponseEntity.noContent().build() } @@ -193,7 +197,8 @@ class WorkspaceController( fun listInvites( @PathVariable id: UUID, ): ResponseEntity> { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() + requirePlatformManagedWorkspace(id) val invites = workspaceInvitations.listByWorkspace(id) return ResponseEntity.ok(invites.map { it.toWorkspaceInviteDto() }) } @@ -205,7 +210,8 @@ class WorkspaceController( @Valid @RequestBody request: CreateWorkspaceInviteRequest, principal: Principal, ): ResponseEntity { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() + requirePlatformManagedWorkspace(id) val userId = UUID.fromString(principal.name) val invite = workspaceInvitations.invite(id, request.email, request.message, userId) return ResponseEntity.status(HttpStatus.CREATED).body(invite.toWorkspaceInviteDto()) @@ -218,7 +224,8 @@ class WorkspaceController( @Valid @RequestBody request: BatchInviteRequest, principal: Principal, ): ResponseEntity { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() + requirePlatformManagedWorkspace(id) val userId = UUID.fromString(principal.name) request.emails.forEach { email -> workspaceInvitations.invite(id, email, request.message, userId) @@ -232,7 +239,8 @@ class WorkspaceController( @PathVariable id: UUID, @Valid @RequestBody request: BatchWorkspaceInviteActionRequest, ): ResponseEntity { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() + requirePlatformManagedWorkspace(id) workspaceInvitations.cancelBatch(id, request.inviteIds) return ResponseEntity.noContent().build() } @@ -244,7 +252,8 @@ class WorkspaceController( @Valid @RequestBody request: BatchWorkspaceInviteActionRequest, principal: Principal, ): ResponseEntity { - ensureOwnerManagedEnabled() + ensurePlatformManagedEnabled() + requirePlatformManagedWorkspace(id) workspaceInvitations.resendBatch(id, UUID.fromString(principal.name), request.inviteIds) return ResponseEntity.noContent().build() } diff --git a/backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceDtos.kt b/backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceDtos.kt index 5281ed292..f83a05ef5 100644 --- a/backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceDtos.kt +++ b/backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceDtos.kt @@ -1,12 +1,16 @@ package io.deck.app.controller +import io.deck.iam.ManagementType +import io.deck.iam.api.ExternalReferenceRecord import io.deck.iam.api.InviteStatus import io.deck.iam.api.WorkspaceInviteRecord -import io.deck.iam.api.WorkspaceManagedType import io.deck.iam.api.WorkspaceMemberRecord import io.deck.iam.api.WorkspaceRecord import io.deck.iam.api.WorkspaceUserRecord +import io.deck.iam.domain.ExternalSource import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID data class WorkspaceOwnerDto( @@ -19,26 +23,30 @@ data class WorkspaceDto( val id: UUID, val name: String, val description: String?, - val allowedDomains: List, + val autoJoinDomains: List, val owners: List, val memberCount: Int, - val managedType: WorkspaceManagedType, + val managedType: ManagementType, + val externalReference: ExternalReferenceDto?, val createdAt: Instant?, val updatedAt: Instant?, ) +data class ExternalReferenceDto( + val source: ExternalSource, + val externalId: String, +) + data class CreateWorkspaceRequest( val name: String, val description: String? = null, - val allowedDomains: List = emptyList(), - val managedType: WorkspaceManagedType? = null, + val autoJoinDomains: List = emptyList(), ) data class UpdateWorkspaceRequest( val name: String, val description: String? = null, - val allowedDomains: List = emptyList(), - val managedType: WorkspaceManagedType? = null, + val autoJoinDomains: List = emptyList(), ) data class WorkspaceMemberDto( @@ -72,10 +80,11 @@ data class MyWorkspaceDto( val id: UUID, val name: String, val description: String?, - val allowedDomains: List, + val autoJoinDomains: List, val owners: List, val memberCount: Int, - val managedType: WorkspaceManagedType, + val managedType: ManagementType, + val externalReference: ExternalReferenceDto?, val role: String, val createdAt: Instant?, val updatedAt: Instant?, @@ -120,12 +129,13 @@ internal fun WorkspaceRecord.toWorkspaceDto( id = id, name = name, description = description, - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, owners = owners, memberCount = memberCount, managedType = managedType, - createdAt = createdAt, - updatedAt = updatedAt, + externalReference = externalReference?.toDto(), + createdAt = createdAt?.toUtcInstant(), + updatedAt = updatedAt?.toUtcInstant(), ) internal fun WorkspaceRecord.toMyWorkspaceDto( @@ -137,13 +147,14 @@ internal fun WorkspaceRecord.toMyWorkspaceDto( id = id, name = name, description = description, - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, owners = owners, memberCount = memberCount, managedType = managedType, + externalReference = externalReference?.toDto(), role = if (isOwnerMember) "OWNER" else "MEMBER", - createdAt = createdAt, - updatedAt = updatedAt, + createdAt = createdAt?.toUtcInstant(), + updatedAt = updatedAt?.toUtcInstant(), ) internal fun WorkspaceMemberRecord.toWorkspaceMemberDto(users: Map): WorkspaceMemberDto = @@ -165,3 +176,7 @@ internal fun WorkspaceInviteRecord.toWorkspaceInviteDto(): WorkspaceInviteDto = message = message, createdAt = createdAt, ) + +private fun LocalDateTime.toUtcInstant(): Instant = toInstant(ZoneOffset.UTC) + +private fun ExternalReferenceRecord.toDto(): ExternalReferenceDto = ExternalReferenceDto(source = source, externalId = externalId) diff --git a/backend/app/src/main/kotlin/io/deck/app/init/DevDataSeeder.kt b/backend/app/src/main/kotlin/io/deck/app/init/DevDataSeeder.kt index a2cb1ab49..427720e8c 100644 --- a/backend/app/src/main/kotlin/io/deck/app/init/DevDataSeeder.kt +++ b/backend/app/src/main/kotlin/io/deck/app/init/DevDataSeeder.kt @@ -12,6 +12,7 @@ import io.deck.iam.api.LoginHistorySeeder import io.deck.iam.api.MenuSeedCommand import io.deck.iam.api.OAuthProviderSeeder import io.deck.iam.api.SeedMenuDefinition +import io.deck.iam.api.WorkspaceProvisioningCommand import io.deck.iam.api.WorkspaceSeedRecord import io.deck.iam.api.WorkspaceSeeder import io.deck.notification.api.NotificationChannelProviderType @@ -46,6 +47,7 @@ class DevDataSeeder( private val menuSeedCommand: MenuSeedCommand, private val notificationSeeder: NotificationSeeder, private val workspaceSeeder: WorkspaceSeeder, + private val workspaceProvisioningCommand: WorkspaceProvisioningCommand, private val errorLogSeeder: ErrorLogSeeder, private val loginHistorySeeder: LoginHistorySeeder, private val oauthProviderSeeder: OAuthProviderSeeder, @@ -71,6 +73,10 @@ class DevDataSeeder( private fun normalizeAdminPasswordPolicy() { val adminUser = devSeedUserManager.findUserByEmail(DEFAULT_ADMIN_EMAIL) ?: return + if (adminUser.name != DEFAULT_ADMIN_NAME) { + devSeedUserManager.updateUserName(adminUser.id, DEFAULT_ADMIN_NAME) + logger.info("Default admin user display name normalized for local/dev") + } if (!adminUser.passwordMustChange) return devSeedUserManager.clearPasswordMustChange(adminUser.id) @@ -162,6 +168,11 @@ class DevDataSeeder( requirePasswordChange = false, ) + workspaceProvisioningCommand.createPersonalWorkspace( + userId = createdUser, + userName = seed.name, + ) + if (seed.status != "ACTIVE") { devSeedUserManager.changeStatus(createdUser, seed.status) } @@ -384,10 +395,10 @@ class DevDataSeeder( createdAt = now.minusMinutes(45), ), ActivityLogSeedRecord( - activityType = "SYSTEM_SETTINGS_GENERAL_UPDATED", + activityType = "PLATFORM_SETTINGS_GENERAL_UPDATED", actorType = "USER", actorId = adminUser.id, - targetType = "SYSTEM_SETTINGS", + targetType = "PLATFORM_SETTING", targetId = "general", metadata = """{"field":"timezone","value":"Asia/Seoul"}""", occurredAt = now.minusMinutes(10), @@ -464,7 +475,7 @@ class DevDataSeeder( logger = "io.deck.audit.service.ApiAuditService", message = "Database connection pool exhausted", method = "GET", - path = "/api/v1/audit-logs", + path = "/api/v1/api-audit-logs", statusCode = 503, durationMs = 30000, userId = adminUser.id, @@ -493,7 +504,7 @@ class DevDataSeeder( logger = "io.deck.crypto.service.KeyRotationService", message = "KEK rotation approaching: current key expires in 7 days", method = "GET", - path = "/api/v1/system/health", + path = "/api/v1/platform/health", statusCode = 200, durationMs = 150, userId = adminUser.id, @@ -535,7 +546,7 @@ class DevDataSeeder( logger = "window.onerror", message = "TypeError: Cannot read properties of undefined (reading 'map')", method = "GET", - path = "/system/notification-channels/", + path = "/console/notification-channels/", userId = adminUser.id, username = "admin", email = DEFAULT_ADMIN_EMAIL, @@ -549,7 +560,7 @@ class DevDataSeeder( logger = "window.onerror", message = "ChunkLoadError: Loading chunk vendors-node_modules_tabulator failed", method = "GET", - path = "/system/audit-logs/", + path = "/console/api-audit-logs/", userId = adminUser.id, username = "admin", email = DEFAULT_ADMIN_EMAIL, @@ -574,7 +585,7 @@ class DevDataSeeder( logger = "console.warn", message = "AbortError: The user aborted a request (navigation during fetch)", method = "GET", - path = "/system/users/", + path = "/console/users/", userId = adminUser.id, username = "admin", email = DEFAULT_ADMIN_EMAIL, @@ -751,6 +762,7 @@ class DevDataSeeder( } companion object { + private const val DEFAULT_ADMIN_NAME = "플랫폼 관리자" private const val DEFAULT_ADMIN_EMAIL = "admin@deck.io" private const val DEV_PASSWORD = "DeckSeed!45" } diff --git a/backend/app/src/main/kotlin/io/deck/app/listener/UserNotificationDispatchResolver.kt b/backend/app/src/main/kotlin/io/deck/app/listener/UserNotificationDispatchResolver.kt index 4bbbfa5ec..62e8a101a 100644 --- a/backend/app/src/main/kotlin/io/deck/app/listener/UserNotificationDispatchResolver.kt +++ b/backend/app/src/main/kotlin/io/deck/app/listener/UserNotificationDispatchResolver.kt @@ -28,7 +28,7 @@ class UserNotificationDispatchResolver( "email" to event.email, "roles" to event.roleLabels.toSortedSet().joinToString(", "), "loginUrl" to baseUrl, - "userUrl" to "$baseUrl/system/users?userId=${event.targetUserId}", + "userUrl" to "$baseUrl/console/users?userId=${event.targetUserId}", ), ) } @@ -63,7 +63,7 @@ class UserNotificationDispatchResolver( mapOf( "userName" to event.userName, "email" to event.email, - "userUrl" to "$baseUrl/system/users?userId=${event.acceptedUserId}", + "userUrl" to "$baseUrl/console/users?userId=${event.acceptedUserId}", ), ) } @@ -92,7 +92,7 @@ class UserNotificationDispatchResolver( "userName" to event.userName, "email" to event.email, "provider" to event.provider, - "approvalUrl" to "$baseUrl/system/users?userId=${event.targetUserId}", + "approvalUrl" to "$baseUrl/console/users?userId=${event.targetUserId}", ), ) } diff --git a/backend/app/src/main/kotlin/io/deck/app/listener/WorkspaceAutoCreateListener.kt b/backend/app/src/main/kotlin/io/deck/app/listener/WorkspaceAutoCreateListener.kt index 00f0f0ca6..b1f1ca29a 100644 --- a/backend/app/src/main/kotlin/io/deck/app/listener/WorkspaceAutoCreateListener.kt +++ b/backend/app/src/main/kotlin/io/deck/app/listener/WorkspaceAutoCreateListener.kt @@ -10,7 +10,7 @@ import org.springframework.transaction.event.TransactionalEventListener class WorkspaceAutoCreateListener( private val workspaceProvisioningCommand: WorkspaceProvisioningCommand, ) { - @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun onUserCreated(event: UserCreatedEvent) { workspaceProvisioningCommand.createPersonalWorkspace( userId = event.targetUserId, diff --git a/backend/app/src/main/kotlin/io/deck/app/service/DashboardService.kt b/backend/app/src/main/kotlin/io/deck/app/service/DashboardService.kt index a67e189cd..dc539b1c3 100644 --- a/backend/app/src/main/kotlin/io/deck/app/service/DashboardService.kt +++ b/backend/app/src/main/kotlin/io/deck/app/service/DashboardService.kt @@ -63,9 +63,9 @@ class DashboardService( val pendingInvitesCount = if (isOwner) dashboardInsightsQuery.countPendingInvites().toInt() else null - val systemStatus = + val platformStatus = if (isOwner) { - SystemStatusDto( + PlatformStatusDto( emailEnabled = channelAvailabilityQuery.isEmailActive(), slackEnabled = channelAvailabilityQuery.isSlackActive(), activeNotificationChannels = channelAvailabilityQuery.countEnabledChannels(), @@ -112,7 +112,7 @@ class DashboardService( activeUsersCount = activeUsersCount, errorStats = errorStats, pendingInvitesCount = pendingInvitesCount, - systemStatus = systemStatus, + platformStatus = platformStatus, roleDistribution = roleDistribution, recentApiAuditLogs = recentApiAuditLogs, ) @@ -193,7 +193,7 @@ data class DashboardResponse( val errorStats: List?, // Owner 전용 추가 val pendingInvitesCount: Int?, - val systemStatus: SystemStatusDto?, + val platformStatus: PlatformStatusDto?, val roleDistribution: List?, val recentApiAuditLogs: List?, ) @@ -218,7 +218,7 @@ data class SecurityStatusDto( val lastPasswordChangedAt: Instant?, ) -data class SystemStatusDto( +data class PlatformStatusDto( val emailEnabled: Boolean, val slackEnabled: Boolean, val activeNotificationChannels: Int, diff --git a/backend/app/src/main/resources/db/migration/app/V1__init.sql b/backend/app/src/main/resources/db/migration/app/V1__init.sql index b71b3309a..0f8810f46 100644 --- a/backend/app/src/main/resources/db/migration/app/V1__init.sql +++ b/backend/app/src/main/resources/db/migration/app/V1__init.sql @@ -276,6 +276,7 @@ CREATE TABLE menus name VARCHAR(100) NOT NULL, icon VARCHAR(50), program_type VARCHAR(100) NOT NULL, + managed_type VARCHAR(30) NOT NULL DEFAULT 'USER_MANAGED', parent_id UUID REFERENCES menus (id) ON DELETE CASCADE, sort_order INT NOT NULL DEFAULT 0, permissions JSONB NOT NULL DEFAULT '[]'::jsonb, @@ -420,9 +421,9 @@ CREATE INDEX idx_activity_logs_workspaceid_createdat ON activity_logs (workspace CREATE INDEX idx_activity_logs_targettype_targetid_createdat ON activity_logs (target_type, target_id, created_at DESC); -- ============================================= --- System Settings (Owner 전용) +-- Platform Settings -- ============================================= -CREATE TABLE system_settings +CREATE TABLE platform_settings ( id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), brand_name VARCHAR(100) NOT NULL DEFAULT 'Deck', @@ -448,9 +449,10 @@ CREATE TABLE system_settings okta_client_id VARCHAR(200), okta_client_secret TEXT, -- 워크스페이스 설정 - workspace_use_user_managed BOOLEAN, - workspace_use_system_managed BOOLEAN, - workspace_use_selector BOOLEAN, + workspace_use_user_managed BOOLEAN, + workspace_use_platform_managed BOOLEAN, + workspace_use_external_sync BOOLEAN, + workspace_use_selector BOOLEAN, country_enabled_country_codes JSONB NOT NULL DEFAULT '["KR"]'::jsonb, country_default_country_code VARCHAR(2) NOT NULL DEFAULT 'KR', currency_default_currency_code VARCHAR(3) NOT NULL DEFAULT 'KRW', @@ -459,18 +461,20 @@ CREATE TABLE system_settings updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- 기본 시스템 설정 레코드 -INSERT INTO system_settings ( +-- 기본 플랫폼 설정 레코드 +INSERT INTO platform_settings ( id, brand_name, workspace_use_user_managed, - workspace_use_system_managed, + workspace_use_platform_managed, + workspace_use_external_sync, workspace_use_selector ) VALUES ( uuid_generate_v7(), 'Deck', true, true, + true, true ); @@ -496,16 +500,16 @@ VALUES ('app.login.internal.enabled', 'true', '내부 로그인 활성화'), -- Admin Party Profile INSERT INTO party_profiles (id, party_type, display_name, normalized_name, primary_country_code) -VALUES ('019d3a29-0000-7000-8000-000000000301', 'PERSON', '시스템 관리자', '시스템 관리자', 'KR'); +VALUES ('019d3a29-0000-7000-8000-000000000301', 'PERSON', '플랫폼 관리자', '플랫폼 관리자', 'KR'); INSERT INTO party_person_profiles (party_id, full_name) -VALUES ('019d3a29-0000-7000-8000-000000000301', '시스템 관리자'); +VALUES ('019d3a29-0000-7000-8000-000000000301', '플랫폼 관리자'); -- Admin User (admin / Deck@dm1n!) -- is_owner = true, role_ids에는 ADMIN role UUID 포함 INSERT INTO users (id, name, email, status, is_owner, role_ids, password_must_change, party_id) VALUES ('019bca88-0000-7000-8000-000000000301', - '시스템 관리자', + '플랫폼 관리자', 'admin@deck.io', 'ACTIVE', TRUE, @@ -528,7 +532,7 @@ VALUES ('019bca88-0000-7000-8000-000000000301', -- Structure: -- Dashboard -- My Workspace --- System +-- Platform -- ├─ Users -- ├─ Menus -- ├─ Workspaces @@ -540,7 +544,8 @@ VALUES ('019bca88-0000-7000-8000-000000000301', -- └─ Logs (group) -- ├─ API Audits -- ├─ Activity --- └─ Errors +-- ├─ Errors +-- └─ Login History -- ============================================= -- Dashboard @@ -556,13 +561,13 @@ VALUES ('019bca88-0000-7000-8000-000000000014', '019bca88-0000-7000-8000-0000000 'briefcase', 'MY_WORKSPACE', 1, '["MY_WORKSPACE_READ","MY_WORKSPACE_WRITE"]'::jsonb); --- System (group) +-- Platform (group) INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, sort_order) VALUES ('019bca88-0000-7000-8000-000000000009', '019bca88-0000-7000-8000-000000000201', - 'System', '{"en":"System","ko":"시스템","ja":"システム"}'::jsonb, + 'Platform', '{"en":"Platform","ko":"플랫폼","ja":"プラットフォーム"}'::jsonb, 'settings', 'NONE', 2); --- System > Users +-- Platform > Users INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000010', '019bca88-0000-7000-8000-000000000201', 'Users', '{"en":"Users","ko":"사용자","ja":"ユーザー"}'::jsonb, @@ -570,7 +575,7 @@ VALUES ('019bca88-0000-7000-8000-000000000010', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000009', 0, '["USER_MANAGEMENT_READ","USER_MANAGEMENT_WRITE"]'::jsonb); --- System > Menus +-- Platform > Menus INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000011', '019bca88-0000-7000-8000-000000000201', 'Menus', '{"en":"Menus","ko":"메뉴","ja":"メニュー"}'::jsonb, @@ -578,7 +583,7 @@ VALUES ('019bca88-0000-7000-8000-000000000011', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000009', 1, '["MENU_MANAGEMENT_READ","MENU_MANAGEMENT_WRITE"]'::jsonb); --- System > Workspaces +-- Platform > Workspaces INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000013', '019bca88-0000-7000-8000-000000000201', 'Workspaces', '{"en":"Workspaces","ko":"워크스페이스","ja":"ワークスペース"}'::jsonb, @@ -586,14 +591,14 @@ VALUES ('019bca88-0000-7000-8000-000000000013', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000009', 2, '["WORKSPACE_MANAGEMENT_READ","WORKSPACE_MANAGEMENT_WRITE"]'::jsonb); --- System > Notifications (group) +-- Platform > Notifications (group) INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order) VALUES ('019bca88-0000-7000-8000-000000000002', '019bca88-0000-7000-8000-000000000201', 'Notifications', '{"en":"Notifications","ko":"알림","ja":"通知"}'::jsonb, 'bell', 'NONE', '019bca88-0000-7000-8000-000000000009', 3); --- System > Notifications > Channels +-- Platform > Notifications > Channels INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000003', '019bca88-0000-7000-8000-000000000201', 'Channels', '{"en":"Channels","ko":"채널","ja":"チャネル"}'::jsonb, @@ -601,7 +606,7 @@ VALUES ('019bca88-0000-7000-8000-000000000003', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000002', 0, '["NOTIFICATION_MANAGEMENT_READ","NOTIFICATION_MANAGEMENT_WRITE"]'::jsonb); --- System > Notifications > Email +-- Platform > Notifications > Email INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000004', '019bca88-0000-7000-8000-000000000201', 'Email', '{"en":"Email","ko":"이메일","ja":"メール"}'::jsonb, @@ -609,7 +614,7 @@ VALUES ('019bca88-0000-7000-8000-000000000004', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000002', 1, '["EMAIL_TEMPLATE_MANAGEMENT_READ","EMAIL_TEMPLATE_MANAGEMENT_WRITE"]'::jsonb); --- System > Notifications > Slack +-- Platform > Notifications > Slack INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000005', '019bca88-0000-7000-8000-000000000201', 'Slack', '{"en":"Slack","ko":"Slack","ja":"Slack"}'::jsonb, @@ -617,7 +622,7 @@ VALUES ('019bca88-0000-7000-8000-000000000005', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000002', 2, '["SLACK_TEMPLATE_MANAGEMENT_READ","SLACK_TEMPLATE_MANAGEMENT_WRITE"]'::jsonb); --- System > Notifications > Rules +-- Platform > Notifications > Rules INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000012', '019bca88-0000-7000-8000-000000000201', 'Rules', '{"en":"Rules","ko":"규칙","ja":"ルール"}'::jsonb, @@ -625,14 +630,14 @@ VALUES ('019bca88-0000-7000-8000-000000000012', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000002', 3, '["NOTIFICATION_MANAGEMENT_READ","NOTIFICATION_MANAGEMENT_WRITE"]'::jsonb); --- System > Logs (group) +-- Platform > Logs (group) INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order) VALUES ('019bca88-0000-7000-8000-000000000006', '019bca88-0000-7000-8000-000000000201', 'Logs', '{"en":"Logs","ko":"로그","ja":"ログ"}'::jsonb, 'shield', 'NONE', '019bca88-0000-7000-8000-000000000009', 4); --- System > Logs > API Audits +-- Platform > Logs > API Audits INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000007', '019bca88-0000-7000-8000-000000000201', 'API Audits', '{"en":"API Audits","ko":"API 감사 로그","ja":"API監査ログ"}'::jsonb, @@ -640,7 +645,7 @@ VALUES ('019bca88-0000-7000-8000-000000000007', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000006', 0, '["API_AUDIT_LOG_READ","API_AUDIT_LOG_WRITE"]'::jsonb); --- System > Logs > Activity +-- Platform > Logs > Activity INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000015', '019bca88-0000-7000-8000-000000000201', 'Activity', '{"en":"Activity","ko":"활동 로그","ja":"アクティビティログ"}'::jsonb, @@ -648,7 +653,7 @@ VALUES ('019bca88-0000-7000-8000-000000000015', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000006', 1, '["ACTIVITY_LOG_READ"]'::jsonb); --- System > Logs > Errors +-- Platform > Logs > Errors INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) VALUES ('019bca88-0000-7000-8000-000000000008', '019bca88-0000-7000-8000-000000000201', 'Errors', '{"en":"Errors","ko":"에러 로그","ja":"エラーログ"}'::jsonb, @@ -656,12 +661,20 @@ VALUES ('019bca88-0000-7000-8000-000000000008', '019bca88-0000-7000-8000-0000000 '019bca88-0000-7000-8000-000000000006', 2, '["ERROR_LOG_READ","ERROR_LOG_WRITE"]'::jsonb); +-- Platform > Logs > Login History +INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order, permissions) +VALUES ('019bca88-0000-7000-8000-000000000016', '019bca88-0000-7000-8000-000000000201', + 'Login History', '{"en":"Login History","ko":"로그인 이력","ja":"ログイン履歴"}'::jsonb, + 'log-in', 'LOGIN_HISTORY', + '019bca88-0000-7000-8000-000000000006', 3, + '["LOGIN_HISTORY_READ"]'::jsonb); + -- MANAGER 기본 메뉴 -- Role ID: 019bca88-0000-7000-8000-000000000202 (MANAGER) -- Structure: -- Dashboard -- My Workspace --- System +-- Platform -- └─ Notifications (group) -- ├─ Channels -- ├─ Email @@ -680,7 +693,7 @@ VALUES ('019bca88-0000-7000-8000-000000000102', '019bca88-0000-7000-8000-0000000 INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, sort_order) VALUES ('019bca88-0000-7000-8000-000000000104', '019bca88-0000-7000-8000-000000000202', - 'System', '{"en":"System","ko":"시스템","ja":"システム"}'::jsonb, + 'Platform', '{"en":"Platform","ko":"플랫폼","ja":"プラットフォーム"}'::jsonb, 'settings', 'NONE', 2); INSERT INTO menus (id, role_id, name, names_i18n, icon, program_type, parent_id, sort_order) @@ -730,6 +743,31 @@ VALUES ('019bca88-0000-7000-8000-000000000103', '019bca88-0000-7000-8000-0000000 'briefcase', 'MY_WORKSPACE', 1, '["MY_WORKSPACE_READ","MY_WORKSPACE_WRITE"]'::jsonb); +WITH RECURSIVE platform_menu_tree AS ( + SELECT id, parent_id + FROM menus + WHERE program_type IN ( + 'USER_MANAGEMENT', + 'MENU_MANAGEMENT', + 'WORKSPACE_MANAGEMENT', + 'NOTIFICATION_CHANNEL_MANAGEMENT', + 'EMAIL_TEMPLATE_MANAGEMENT', + 'SLACK_TEMPLATE_MANAGEMENT', + 'NOTIFICATION_RULE_MANAGEMENT', + 'API_AUDIT_LOG', + 'ACTIVITY_LOG', + 'ERROR_LOG', + 'LOGIN_HISTORY' + ) + UNION + SELECT parent.id, parent.parent_id + FROM menus parent + JOIN platform_menu_tree child ON child.parent_id = parent.id +) +UPDATE menus +SET managed_type = 'PLATFORM_MANAGED' +WHERE id IN (SELECT DISTINCT id FROM platform_menu_tree); + -- ============================================= -- Notification System -- ============================================= @@ -1211,16 +1249,31 @@ CREATE TABLE workspaces id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), name VARCHAR(100) NOT NULL, description VARCHAR(500), - allowed_domains JSONB NOT NULL DEFAULT '[]'::jsonb, - managed_type VARCHAR(30) NOT NULL DEFAULT 'USER_MANAGED', + auto_join_domains JSONB NOT NULL DEFAULT '[]'::jsonb, + managed_type VARCHAR(30) NOT NULL DEFAULT 'USER_MANAGED', + external_source VARCHAR(50), + external_id VARCHAR(255), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by UUID, deleted_at TIMESTAMPTZ, - deleted_by UUID + deleted_by UUID, + + CONSTRAINT chk_workspaces_external_reference_pair + CHECK ( + (external_source IS NULL AND external_id IS NULL) OR + (external_source IS NOT NULL AND external_id IS NOT NULL) + ), + CONSTRAINT chk_workspaces_external_platform_managed + CHECK ( + external_source IS NULL OR managed_type = 'PLATFORM_MANAGED' + ) ); +CREATE UNIQUE INDEX udx_workspaces_external_reference ON workspaces (external_source, external_id) + WHERE external_source IS NOT NULL AND external_id IS NOT NULL AND deleted_at IS NULL; + -- ============================================= -- Workspace Members (hard delete, no soft delete) -- ============================================= @@ -1254,9 +1307,11 @@ CREATE TABLE workspace_invites status VARCHAR(20) NOT NULL DEFAULT 'PENDING', expires_at TIMESTAMPTZ NOT NULL, message VARCHAR(500), + inviter_id UUID NOT NULL, accepted_user_id UUID, accepted_at TIMESTAMPTZ, cancelled_at TIMESTAMPTZ, + version BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -1271,6 +1326,9 @@ CREATE TABLE workspace_invites CREATE INDEX idx_workspace_invites_workspaceid ON workspace_invites (workspace_id); CREATE INDEX idx_workspace_invites_email_status ON workspace_invites (email, status); CREATE INDEX idx_workspace_invites_expiresat ON workspace_invites (expires_at); +CREATE UNIQUE INDEX uq_workspace_invites_pending_email + ON workspace_invites (workspace_id, email) + WHERE status = 'PENDING' AND deleted_at IS NULL; -- ============================================= -- Codebook diff --git a/backend/app/src/test/kotlin/io/deck/app/controller/DashboardControllerTest.kt b/backend/app/src/test/kotlin/io/deck/app/controller/DashboardControllerTest.kt index c59442243..a0e0fcf7d 100644 --- a/backend/app/src/test/kotlin/io/deck/app/controller/DashboardControllerTest.kt +++ b/backend/app/src/test/kotlin/io/deck/app/controller/DashboardControllerTest.kt @@ -60,7 +60,7 @@ class DashboardControllerTest : activeUsersCount = null, errorStats = null, pendingInvitesCount = null, - systemStatus = null, + platformStatus = null, roleDistribution = null, recentApiAuditLogs = null, ) @@ -98,7 +98,7 @@ class DashboardControllerTest : DailyErrorStat(LocalDate.now().minusDays(1), 3), ), pendingInvitesCount = 3, - systemStatus = null, + platformStatus = null, roleDistribution = null, recentApiAuditLogs = null, ) @@ -182,7 +182,7 @@ class DashboardControllerTest : activeUsersCount = null, errorStats = null, pendingInvitesCount = null, - systemStatus = null, + platformStatus = null, roleDistribution = null, recentApiAuditLogs = null, ) diff --git a/backend/app/src/test/kotlin/io/deck/app/controller/MyWorkspaceControllerTest.kt b/backend/app/src/test/kotlin/io/deck/app/controller/MyWorkspaceControllerTest.kt index 3f64303bb..526096e8a 100644 --- a/backend/app/src/test/kotlin/io/deck/app/controller/MyWorkspaceControllerTest.kt +++ b/backend/app/src/test/kotlin/io/deck/app/controller/MyWorkspaceControllerTest.kt @@ -1,10 +1,10 @@ package io.deck.app.controller import io.deck.common.exception.BadRequestException +import io.deck.iam.ManagementType import io.deck.iam.api.PublicEmailDomainPolicy import io.deck.iam.api.WorkspaceDirectory import io.deck.iam.api.WorkspaceInvitationManager -import io.deck.iam.api.WorkspaceManagedType import io.deck.iam.api.WorkspaceMemberRecord import io.deck.iam.api.WorkspaceRecord import io.deck.iam.api.WorkspaceRoster @@ -46,15 +46,16 @@ class MyWorkspaceControllerTest : fun workspace( name: String = "Workspace", - managedType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, + managedType: ManagementType = ManagementType.USER_MANAGED, id: UUID = UUID.randomUUID(), - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ) = object : WorkspaceRecord { override val id = id override val name = name override val description: String? = null - override val allowedDomains: List = allowedDomains + override val autoJoinDomains: List = autoJoinDomains override val managedType = managedType + override val externalReference = null override val createdAt = null override val updatedAt = null } @@ -91,7 +92,7 @@ class MyWorkspaceControllerTest : it("owner와 member role을 계산하고 owners 목록을 반환한다") { val userId = UUID.randomUUID() val ownedWorkspace = workspace(name = "Owned") - val sharedWorkspace = workspace(name = "Shared", managedType = WorkspaceManagedType.SYSTEM_MANAGED) + val sharedWorkspace = workspace(name = "Shared", managedType = ManagementType.PLATFORM_MANAGED) val ownerId1 = UUID.randomUUID() val ownerId2 = UUID.randomUUID() every { workspaceDirectory.listVisibleByUser(userId) } returns listOf(ownedWorkspace, sharedWorkspace) @@ -127,7 +128,7 @@ class MyWorkspaceControllerTest : describe("create") { it("내 workspace를 만들면 owners에 생성자 자신이 들어간다") { val userId = UUID.randomUUID() - val workspace = workspace(name = "My Workspace", allowedDomains = listOf("acme.com")) + val workspace = workspace(name = "My Workspace", autoJoinDomains = listOf("acme.com")) every { workspaceDirectory.createForUser("My Workspace", "desc", userId, listOf("acme.com")) } returns workspace @@ -137,7 +138,7 @@ class MyWorkspaceControllerTest : val result = controller.create( - CreateWorkspaceRequest("My Workspace", "desc", allowedDomains = listOf("acme.com")), + CreateWorkspaceRequest("My Workspace", "desc", autoJoinDomains = listOf("acme.com")), principal(userId), ) @@ -147,15 +148,15 @@ class MyWorkspaceControllerTest : .single() .id shouldBe userId result.body!!.role shouldBe "OWNER" - result.body!!.allowedDomains shouldBe listOf("acme.com") + result.body!!.autoJoinDomains shouldBe listOf("acme.com") } } describe("update") { - it("owner가 수정하면 allowedDomains를 그대로 반영한다") { + it("owner가 수정하면 autoJoinDomains를 그대로 반영한다") { val workspaceId = UUID.randomUUID() val userId = UUID.randomUUID() - val updatedWorkspace = workspace(id = workspaceId, name = "Updated", allowedDomains = listOf("acme.com", "dev.acme.com")) + val updatedWorkspace = workspace(id = workspaceId, name = "Updated", autoJoinDomains = listOf("acme.com", "dev.acme.com")) every { workspaceDirectory.updateForUser( @@ -163,8 +164,8 @@ class MyWorkspaceControllerTest : name = "Updated", description = "desc", requestedBy = userId, - managedType = WorkspaceManagedType.USER_MANAGED, - allowedDomains = listOf("acme.com", "dev.acme.com"), + managedType = ManagementType.USER_MANAGED, + autoJoinDomains = listOf("acme.com", "dev.acme.com"), ) } returns updatedWorkspace every { workspaceRoster.countByWorkspaceId(workspaceId) } returns 1L @@ -177,13 +178,61 @@ class MyWorkspaceControllerTest : UpdateWorkspaceRequest( name = "Updated", description = "desc", - allowedDomains = listOf("acme.com", "dev.acme.com"), + autoJoinDomains = listOf("acme.com", "dev.acme.com"), ), principal(userId), ) response.statusCode shouldBe HttpStatus.OK - response.body!!.allowedDomains shouldBe listOf("acme.com", "dev.acme.com") + response.body!!.autoJoinDomains shouldBe listOf("acme.com", "dev.acme.com") + } + + it("external workspace 수정은 external_locked 예외를 그대로 올린다") { + val workspaceId = UUID.randomUUID() + val userId = UUID.randomUUID() + + every { + workspaceDirectory.updateForUser( + workspaceId = workspaceId, + name = "External Workspace", + description = "desc", + requestedBy = userId, + managedType = ManagementType.USER_MANAGED, + autoJoinDomains = emptyList(), + ) + } throws BadRequestException("iam.workspace.external_locked") + + val error = + io.kotest.assertions.throwables.shouldThrow { + controller.update( + workspaceId, + UpdateWorkspaceRequest( + name = "External Workspace", + description = "desc", + ), + principal(userId), + ) + } + + error.messageCode shouldBe "iam.workspace.external_locked" + } + } + + describe("listMembers") { + it("platform-managed workspace도 member read는 허용한다") { + val workspaceId = UUID.randomUUID() + val currentUserId = UUID.randomUUID() + val memberId = UUID.randomUUID() + val members = listOf(ownerMember(workspaceId, memberId)) + every { workspaceDirectory.ensureAccessible(workspaceId, null) } just runs + every { workspaceRoster.listMembersIfMember(workspaceId, currentUserId) } returns members + every { workspaceUserLookup.findAllByIds(listOf(memberId)) } returns listOf(user(memberId)) + + val response = controller.listMembers(workspaceId, principal(currentUserId)) + + response.statusCode shouldBe HttpStatus.OK + response.body!!.single().userId shouldBe memberId + verify(exactly = 1) { workspaceDirectory.ensureAccessible(workspaceId, null) } } } @@ -192,7 +241,7 @@ class MyWorkspaceControllerTest : val workspaceId = UUID.randomUUID() val currentUserId = UUID.randomUUID() val userIds = listOf(UUID.randomUUID(), UUID.randomUUID()) - every { workspaceDirectory.verifyOwner(workspaceId, currentUserId, WorkspaceManagedType.USER_MANAGED) } just runs + every { workspaceDirectory.verifyOwner(workspaceId, currentUserId, ManagementType.USER_MANAGED) } just runs every { workspaceRoster.removeMembers(workspaceId, userIds, currentUserId) } just runs val response = @@ -214,7 +263,7 @@ class MyWorkspaceControllerTest : val workspaceId = UUID.randomUUID() val currentUserId = UUID.randomUUID() val ownerIds = listOf(UUID.randomUUID(), UUID.randomUUID()) - every { workspaceDirectory.verifyOwner(workspaceId, currentUserId, WorkspaceManagedType.USER_MANAGED) } just runs + every { workspaceDirectory.verifyOwner(workspaceId, currentUserId, ManagementType.USER_MANAGED) } just runs every { workspaceRoster.replaceOwners(workspaceId, ownerIds) } just runs val response = @@ -237,7 +286,7 @@ class MyWorkspaceControllerTest : workspaceDirectory.deleteBatch( workspaceIds, currentUserId, - WorkspaceManagedType.USER_MANAGED, + ManagementType.USER_MANAGED, ) } just runs @@ -252,7 +301,7 @@ class MyWorkspaceControllerTest : workspaceDirectory.deleteBatch( workspaceIds, currentUserId, - WorkspaceManagedType.USER_MANAGED, + ManagementType.USER_MANAGED, ) } } @@ -281,7 +330,7 @@ class MyWorkspaceControllerTest : val workspaceId = UUID.randomUUID() val currentUserId = UUID.randomUUID() val inviteIds = listOf(UUID.randomUUID(), UUID.randomUUID()) - every { workspaceDirectory.verifyOwner(workspaceId, currentUserId, WorkspaceManagedType.USER_MANAGED) } just runs + every { workspaceDirectory.verifyOwner(workspaceId, currentUserId, ManagementType.USER_MANAGED) } just runs every { workspaceInvitations.cancelBatch(workspaceId, inviteIds) } just runs val response = @@ -301,7 +350,7 @@ class MyWorkspaceControllerTest : val workspaceId = UUID.randomUUID() val currentUserId = UUID.randomUUID() val inviteIds = listOf(UUID.randomUUID(), UUID.randomUUID()) - every { workspaceDirectory.verifyOwner(workspaceId, currentUserId, WorkspaceManagedType.USER_MANAGED) } just runs + every { workspaceDirectory.verifyOwner(workspaceId, currentUserId, ManagementType.USER_MANAGED) } just runs every { workspaceInvitations.resendBatch(workspaceId, currentUserId, inviteIds) } just runs val response = diff --git a/backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceControllerTest.kt b/backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceControllerTest.kt index cd75184b6..0c0dc1858 100644 --- a/backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceControllerTest.kt +++ b/backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceControllerTest.kt @@ -1,11 +1,12 @@ package io.deck.app.controller +import io.deck.common.exception.BadRequestException import io.deck.common.exception.NotFoundException +import io.deck.iam.ManagementType import io.deck.iam.api.InviteStatus import io.deck.iam.api.WorkspaceDirectory import io.deck.iam.api.WorkspaceInvitationManager import io.deck.iam.api.WorkspaceInviteRecord -import io.deck.iam.api.WorkspaceManagedType import io.deck.iam.api.WorkspaceMemberRecord import io.deck.iam.api.WorkspaceRecord import io.deck.iam.api.WorkspaceRoster @@ -46,15 +47,16 @@ class WorkspaceControllerTest : fun workspace( name: String = "Workspace", - managedType: WorkspaceManagedType = WorkspaceManagedType.SYSTEM_MANAGED, + managedType: ManagementType = ManagementType.PLATFORM_MANAGED, id: UUID = UUID.randomUUID(), - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ) = object : WorkspaceRecord { override val id = id override val name = name override val description: String? = null override val managedType = managedType - override val allowedDomains = allowedDomains + override val autoJoinDomains = autoJoinDomains + override val externalReference = null override val createdAt = null override val updatedAt = null } @@ -88,8 +90,8 @@ class WorkspaceControllerTest : } describe("list") { - it("system-managed 정책이 꺼져 있으면 예외를 그대로 던진다") { - every { workspaceDirectory.ensureManagedTypeEnabled(WorkspaceManagedType.SYSTEM_MANAGED) } throws + it("platform-managed 정책이 꺼져 있으면 예외를 그대로 던진다") { + every { workspaceDirectory.ensureManagedTypeEnabled(ManagementType.PLATFORM_MANAGED) } throws NotFoundException("iam.workspace.not_found") shouldThrow { @@ -101,7 +103,7 @@ class WorkspaceControllerTest : val workspace = workspace() val ownerId1 = UUID.randomUUID() val ownerId2 = UUID.randomUUID() - every { workspaceDirectory.listAll() } returns listOf(workspace) + every { workspaceDirectory.listPlatformManaged() } returns listOf(workspace) every { workspaceRoster.countByWorkspaceIds(listOf(workspace.id)) } returns mapOf(workspace.id to 3L) every { workspaceRoster.findOwnersByWorkspaceIds(listOf(workspace.id)) } returns listOf(ownerMember(workspace.id, ownerId1), ownerMember(workspace.id, ownerId2)) @@ -122,14 +124,13 @@ class WorkspaceControllerTest : it("생성 요청자는 초기 owner membership으로 생성된다") { val userId = UUID.randomUUID() val principal = principal(userId) - val workspace = workspace(name = "New Workspace", allowedDomains = listOf("acme.com")) + val workspace = workspace(name = "New Workspace", autoJoinDomains = listOf("acme.com")) every { - workspaceDirectory.create( + workspaceDirectory.createPlatformManaged( name = "New Workspace", description = "desc", initialOwnerId = userId, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = listOf("acme.com"), + autoJoinDomains = listOf("acme.com"), ) } returns workspace every { workspaceRoster.countByWorkspaceId(workspace.id) } returns 1L @@ -138,7 +139,7 @@ class WorkspaceControllerTest : val result = controller.create( - CreateWorkspaceRequest("New Workspace", "desc", allowedDomains = listOf("acme.com")), + CreateWorkspaceRequest("New Workspace", "desc", autoJoinDomains = listOf("acme.com")), principal, ) @@ -147,24 +148,22 @@ class WorkspaceControllerTest : .owners .single() .id shouldBe userId - result.body!!.allowedDomains shouldBe listOf("acme.com") + result.body!!.autoJoinDomains shouldBe listOf("acme.com") } } describe("update") { - it("관리자 update 응답에 allowedDomains를 포함한다") { + it("관리자 update 응답에 autoJoinDomains를 포함한다") { val workspaceId = UUID.randomUUID() val currentUserId = UUID.randomUUID() - val updatedWorkspace = workspace(id = workspaceId, name = "Updated Workspace", allowedDomains = listOf("acme.com", "dev.acme.com")) - every { workspaceDirectory.get(workspaceId) } returns workspace(id = workspaceId) + val updatedWorkspace = workspace(id = workspaceId, name = "Updated Workspace", autoJoinDomains = listOf("acme.com", "dev.acme.com")) every { - workspaceDirectory.updateByAdmin( + workspaceDirectory.updatePlatformManaged( workspaceId = workspaceId, name = "Updated Workspace", description = "desc", updatedBy = currentUserId, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = listOf("acme.com", "dev.acme.com"), + autoJoinDomains = listOf("acme.com", "dev.acme.com"), ) } returns updatedWorkspace every { workspaceRoster.countByWorkspaceId(workspaceId) } returns 1L @@ -177,13 +176,41 @@ class WorkspaceControllerTest : UpdateWorkspaceRequest( name = "Updated Workspace", description = "desc", - allowedDomains = listOf("acme.com", "dev.acme.com"), + autoJoinDomains = listOf("acme.com", "dev.acme.com"), ), principal(currentUserId), ) response.statusCode shouldBe HttpStatus.OK - response.body!!.allowedDomains shouldBe listOf("acme.com", "dev.acme.com") + response.body!!.autoJoinDomains shouldBe listOf("acme.com", "dev.acme.com") + } + + it("external workspace 수정은 external_locked 예외를 그대로 올린다") { + val workspaceId = UUID.randomUUID() + val currentUserId = UUID.randomUUID() + every { + workspaceDirectory.updatePlatformManaged( + workspaceId = workspaceId, + name = "External Workspace", + description = "desc", + updatedBy = currentUserId, + autoJoinDomains = emptyList(), + ) + } throws BadRequestException("iam.workspace.external_locked") + + val error = + shouldThrow { + controller.update( + workspaceId, + UpdateWorkspaceRequest( + name = "External Workspace", + description = "desc", + ), + principal(currentUserId), + ) + } + + error.messageCode shouldBe "iam.workspace.external_locked" } } @@ -192,6 +219,7 @@ class WorkspaceControllerTest : val workspaceId = UUID.randomUUID() val currentUserId = UUID.randomUUID() val userIds = listOf(UUID.randomUUID(), UUID.randomUUID()) + every { workspaceDirectory.getPlatformManaged(workspaceId) } returns workspace(id = workspaceId) every { workspaceRoster.removeMembers(workspaceId, userIds, currentUserId) } just runs val response = @@ -202,6 +230,7 @@ class WorkspaceControllerTest : ) response.statusCode shouldBe HttpStatus.NO_CONTENT + verify(exactly = 1) { workspaceDirectory.getPlatformManaged(workspaceId) } verify(exactly = 1) { workspaceRoster.removeMembers(workspaceId, userIds, currentUserId) } } } @@ -222,6 +251,7 @@ class WorkspaceControllerTest : override val createdAt = null }, ) + every { workspaceDirectory.getPlatformManaged(workspaceId) } returns workspace(id = workspaceId) every { workspaceRoster.listMembers(workspaceId) } returns members every { workspaceUserLookup.findAllByIds(listOf(ownerId, memberId)) } returns listOf( @@ -233,6 +263,7 @@ class WorkspaceControllerTest : response.statusCode shouldBe HttpStatus.OK response.body!!.map { it.userId } shouldBe listOf(ownerId, memberId) + verify(exactly = 1) { workspaceDirectory.getPlatformManaged(workspaceId) } verify(exactly = 1) { workspaceRoster.listMembers(workspaceId) } verify(exactly = 0) { workspaceRoster.listMembersIfMember(any(), any()) } } @@ -242,12 +273,13 @@ class WorkspaceControllerTest : it("owner 일괄 교체를 member service에 위임한다") { val workspaceId = UUID.randomUUID() val ownerIds = listOf(UUID.randomUUID(), UUID.randomUUID()) - every { workspaceDirectory.get(workspaceId) } returns workspace(id = workspaceId) + every { workspaceDirectory.getPlatformManaged(workspaceId) } returns workspace(id = workspaceId) every { workspaceRoster.replaceOwners(workspaceId, ownerIds) } just runs val response = controller.replaceOwners(workspaceId, ReplaceWorkspaceOwnersRequest(ownerIds)) response.statusCode shouldBe HttpStatus.NO_CONTENT + verify(exactly = 1) { workspaceDirectory.getPlatformManaged(workspaceId) } verify(exactly = 1) { workspaceRoster.replaceOwners(workspaceId, ownerIds) } } } @@ -256,7 +288,7 @@ class WorkspaceControllerTest : it("workspace batch 삭제를 workspace service에 위임한다") { val currentUserId = UUID.randomUUID() val workspaceIds = listOf(UUID.randomUUID(), UUID.randomUUID()) - every { workspaceDirectory.deleteByAdminBatch(workspaceIds, currentUserId) } just runs + every { workspaceDirectory.deletePlatformManagedBatch(workspaceIds, currentUserId) } just runs val response = controller.deleteBatch( @@ -265,7 +297,7 @@ class WorkspaceControllerTest : ) response.statusCode shouldBe HttpStatus.NO_CONTENT - verify(exactly = 1) { workspaceDirectory.deleteByAdminBatch(workspaceIds, currentUserId) } + verify(exactly = 1) { workspaceDirectory.deletePlatformManagedBatch(workspaceIds, currentUserId) } } } @@ -273,6 +305,7 @@ class WorkspaceControllerTest : it("초대 batch 취소를 invite service에 위임한다") { val workspaceId = UUID.randomUUID() val inviteIds = listOf(UUID.randomUUID(), UUID.randomUUID()) + every { workspaceDirectory.getPlatformManaged(workspaceId) } returns workspace(id = workspaceId) every { workspaceInvitations.cancelBatch(workspaceId, inviteIds) } just runs val response = @@ -282,6 +315,7 @@ class WorkspaceControllerTest : ) response.statusCode shouldBe HttpStatus.NO_CONTENT + verify(exactly = 1) { workspaceDirectory.getPlatformManaged(workspaceId) } verify(exactly = 1) { workspaceInvitations.cancelBatch(workspaceId, inviteIds) } } } @@ -291,6 +325,7 @@ class WorkspaceControllerTest : val workspaceId = UUID.randomUUID() val currentUserId = UUID.randomUUID() val inviteIds = listOf(UUID.randomUUID(), UUID.randomUUID()) + every { workspaceDirectory.getPlatformManaged(workspaceId) } returns workspace(id = workspaceId) every { workspaceInvitations.resendBatch(workspaceId, currentUserId, inviteIds) } just runs val response = @@ -301,6 +336,7 @@ class WorkspaceControllerTest : ) response.statusCode shouldBe HttpStatus.NO_CONTENT + verify(exactly = 1) { workspaceDirectory.getPlatformManaged(workspaceId) } verify(exactly = 1) { workspaceInvitations.resendBatch(workspaceId, currentUserId, inviteIds) } } } diff --git a/backend/app/src/test/kotlin/io/deck/app/init/DevDataSeederTest.kt b/backend/app/src/test/kotlin/io/deck/app/init/DevDataSeederTest.kt index 845202c2a..53a266148 100644 --- a/backend/app/src/test/kotlin/io/deck/app/init/DevDataSeederTest.kt +++ b/backend/app/src/test/kotlin/io/deck/app/init/DevDataSeederTest.kt @@ -10,6 +10,7 @@ import io.deck.iam.api.OAuthProviderSeeder import io.deck.iam.api.SeedMenuDefinition import io.deck.iam.api.SeedRoleRecord import io.deck.iam.api.SeedUserSummary +import io.deck.iam.api.WorkspaceProvisioningCommand import io.deck.iam.api.WorkspaceSeeder import io.deck.notification.api.NotificationSeeder import io.kotest.core.spec.style.DescribeSpec @@ -28,6 +29,7 @@ class DevDataSeederTest : lateinit var menuSeedCommand: MenuSeedCommand lateinit var notificationSeeder: NotificationSeeder lateinit var workspaceSeeder: WorkspaceSeeder + lateinit var workspaceProvisioningCommand: WorkspaceProvisioningCommand lateinit var errorLogSeeder: ErrorLogSeeder lateinit var loginHistorySeeder: LoginHistorySeeder lateinit var oauthProviderSeeder: OAuthProviderSeeder @@ -55,6 +57,7 @@ class DevDataSeederTest : menuSeedCommand = mockk(relaxed = true) notificationSeeder = mockk(relaxed = true) workspaceSeeder = mockk(relaxed = true) + workspaceProvisioningCommand = mockk(relaxed = true) errorLogSeeder = mockk(relaxed = true) loginHistorySeeder = mockk(relaxed = true) oauthProviderSeeder = mockk(relaxed = true) @@ -65,6 +68,7 @@ class DevDataSeederTest : menuSeedCommand, notificationSeeder, workspaceSeeder, + workspaceProvisioningCommand, errorLogSeeder, loginHistorySeeder, oauthProviderSeeder, @@ -74,6 +78,7 @@ class DevDataSeederTest : adminUser = SeedUserSummary( id = UUID.randomUUID(), + name = "시스템 관리자", email = "admin@deck.io", passwordMustChange = true, ) @@ -112,6 +117,7 @@ class DevDataSeederTest : verify(exactly = 1) { devSeedUserManager.changeStatus(any(), "INACTIVE") } verify(exactly = 1) { devSeedUserManager.changeStatus(any(), "LOCKED") } verify(exactly = 1) { devSeedUserManager.changeStatus(any(), "DORMANT") } + verify(exactly = 5) { workspaceProvisioningCommand.createPersonalWorkspace(any(), any()) } verify(exactly = 1) { devSeedUserManager.createPendingSeedUser( name = "승인대기 사용자", @@ -136,7 +142,7 @@ class DevDataSeederTest : logs.size == 10 && logs.any { it.activityType == "USER_CREATED" } && logs.any { it.activityType == "WORKSPACE_CREATED" } && - logs.any { it.activityType == "SYSTEM_SETTINGS_GENERAL_UPDATED" } + logs.any { it.activityType == "PLATFORM_SETTINGS_GENERAL_UPDATED" } }, ) } @@ -243,6 +249,7 @@ class DevDataSeederTest : menuSeedCommand, notificationSeeder, workspaceSeeder, + workspaceProvisioningCommand, errorLogSeeder, loginHistorySeeder, oauthProviderSeeder, @@ -267,6 +274,7 @@ class DevDataSeederTest : menuSeedCommand, notificationSeeder, workspaceSeeder, + workspaceProvisioningCommand, errorLogSeeder, loginHistorySeeder, oauthProviderSeeder, @@ -286,12 +294,26 @@ class DevDataSeederTest : it("local/dev에서는 admin 비밀번호 변경 요구를 해제한다") { every { devSeedUserManager.findUserByEmail("admin@deck.io") } returns adminUser every { devSeedUserManager.existsByEmail(any()) } returns true + every { devSeedUserManager.updateUserName(adminUser.id, "플랫폼 관리자") } just runs every { devSeedUserManager.clearPasswordMustChange(adminUser.id) } just runs every { activityLogSeeder.count() } returns 5 seeder.run(mockk(relaxed = true)) + verify(exactly = 1) { devSeedUserManager.updateUserName(adminUser.id, "플랫폼 관리자") } verify(exactly = 1) { devSeedUserManager.clearPasswordMustChange(adminUser.id) } } + + it("local/dev에서는 기존 admin 표시명을 플랫폼 관리자로 정규화한다") { + every { devSeedUserManager.findUserByEmail("admin@deck.io") } returns adminUser.copy(passwordMustChange = false) + every { devSeedUserManager.existsByEmail(any()) } returns true + every { devSeedUserManager.updateUserName(adminUser.id, "플랫폼 관리자") } just runs + every { activityLogSeeder.count() } returns 5 + + seeder.run(mockk(relaxed = true)) + + verify(exactly = 1) { devSeedUserManager.updateUserName(adminUser.id, "플랫폼 관리자") } + verify(exactly = 0) { devSeedUserManager.clearPasswordMustChange(adminUser.id) } + } } }) diff --git a/backend/app/src/test/kotlin/io/deck/app/listener/UserNotificationDispatchResolverTest.kt b/backend/app/src/test/kotlin/io/deck/app/listener/UserNotificationDispatchResolverTest.kt index 9524d0768..213f60a8b 100644 --- a/backend/app/src/test/kotlin/io/deck/app/listener/UserNotificationDispatchResolverTest.kt +++ b/backend/app/src/test/kotlin/io/deck/app/listener/UserNotificationDispatchResolverTest.kt @@ -46,7 +46,7 @@ class UserNotificationDispatchResolverTest : "email" to "hong@example.com", "roles" to "Admin", "loginUrl" to baseUrl, - "userUrl" to "$baseUrl/system/users?userId=$userId", + "userUrl" to "$baseUrl/console/users?userId=$userId", ), ) } @@ -72,7 +72,7 @@ class UserNotificationDispatchResolverTest : "userName" to "신규 사용자", "email" to "new-user@example.com", "provider" to "google", - "approvalUrl" to "$baseUrl/system/users?userId=$userId", + "approvalUrl" to "$baseUrl/console/users?userId=$userId", ), ) } @@ -108,7 +108,7 @@ class UserNotificationDispatchResolverTest : mapOf( "userName" to "초대 수락자", "email" to "accepted@example.com", - "userUrl" to "$baseUrl/system/users?userId=$acceptedUserId", + "userUrl" to "$baseUrl/console/users?userId=$acceptedUserId", ), ) } diff --git a/backend/app/src/test/kotlin/io/deck/app/migration/AppMigrationMenuPermissionsTest.kt b/backend/app/src/test/kotlin/io/deck/app/migration/AppMigrationMenuPermissionsTest.kt index 254ae72f9..ed357ae18 100644 --- a/backend/app/src/test/kotlin/io/deck/app/migration/AppMigrationMenuPermissionsTest.kt +++ b/backend/app/src/test/kotlin/io/deck/app/migration/AppMigrationMenuPermissionsTest.kt @@ -56,9 +56,9 @@ class AppMigrationMenuPermissionsTest : it("ADMIN 기본 메뉴 시드의 정렬이 현재 기본 메뉴 구조와 일치한다") { val migrationSql = Files.readString(Path.of("src/main/resources/db/migration/app/V1__init.sql")) - val adminSystemSortOrderMatches = + val adminPlatformSortOrderMatches = Regex( - """(?s)VALUES\s*\('019bca88-0000-7000-8000-000000000009',\s*'019bca88-0000-7000-8000-000000000201',\s*'System'.*'settings', 'NONE', 2\);""", + """(?s)VALUES\s*\('019bca88-0000-7000-8000-000000000009',\s*'019bca88-0000-7000-8000-000000000201',\s*'Platform'.*'settings', 'NONE', 2\);""", ).containsMatchIn(migrationSql) val adminNotificationChildrenOrderMatches = @@ -66,16 +66,16 @@ class AppMigrationMenuPermissionsTest : """(?s)VALUES\s*\('019bca88-0000-7000-8000-000000000003'.*'Channels'.*'019bca88-0000-7000-8000-000000000002', 0,.*?VALUES\s*\('019bca88-0000-7000-8000-000000000004'.*'Email'.*'019bca88-0000-7000-8000-000000000002', 1,.*?VALUES\s*\('019bca88-0000-7000-8000-000000000005'.*'Slack'.*'019bca88-0000-7000-8000-000000000002', 2,.*?VALUES\s*\('019bca88-0000-7000-8000-000000000012'.*'Rules'.*'019bca88-0000-7000-8000-000000000002', 3,""", ).containsMatchIn(migrationSql) - adminSystemSortOrderMatches shouldBe true + adminPlatformSortOrderMatches shouldBe true adminNotificationChildrenOrderMatches shouldBe true } - it("MANAGER 기본 메뉴 시드에 seed 메뉴를 제외한 System Notifications 트리가 포함된다") { + it("MANAGER 기본 메뉴 시드에 seed 메뉴를 제외한 Platform Notifications 트리가 포함된다") { val migrationSql = Files.readString(Path.of("src/main/resources/db/migration/app/V1__init.sql")) - val managerHasSystemGroup = + val managerHasPlatformGroup = Regex( - """(?s)VALUES\s*\('019bca88-0000-7000-8000-000000000104',\s*'019bca88-0000-7000-8000-000000000202',\s*'System'.*'settings', 'NONE', 2\);""", + """(?s)VALUES\s*\('019bca88-0000-7000-8000-000000000104',\s*'019bca88-0000-7000-8000-000000000202',\s*'Platform'.*'settings', 'NONE', 2\);""", ).containsMatchIn(migrationSql) val managerHasNotificationTree = @@ -88,11 +88,19 @@ class AppMigrationMenuPermissionsTest : """(?s)VALUES\s*\('019bca88-0000-7000-8000-00000000011\d',\s*'019bca88-0000-7000-8000-000000000202',\s*'Logs'""", ).containsMatchIn(migrationSql) - managerHasSystemGroup shouldBe true + managerHasPlatformGroup shouldBe true managerHasNotificationTree shouldBe true managerLogsAreNotInV1 shouldBe false } + it("V1 초기 시드는 platform 메뉴 그룹과 플랫폼 관리자 표시명을 포함한다") { + val migrationSql = Files.readString(Path.of("src/main/resources/db/migration/app/V1__init.sql")) + + Regex("""\{"en":"Platform","ko":"플랫폼","ja":"プラットフォーム"\}""") + .containsMatchIn(migrationSql) shouldBe true + Regex("""'플랫폼 관리자'""").containsMatchIn(migrationSql) shouldBe true + } + it("V1 초기 스키마에 soft delete 전환이 반영된다") { val v1Sql = Files.readString(Path.of("src/main/resources/db/migration/app/V1__init.sql")) val userIdentitiesBlock = @@ -143,18 +151,45 @@ class AppMigrationMenuPermissionsTest : Regex("""'API_AUDIT_LOG'""").containsMatchIn(v1Sql) shouldBe true Regex("""'ACTIVITY_LOG'""").containsMatchIn(v1Sql) shouldBe true + Regex("""'LOGIN_HISTORY'""").containsMatchIn(v1Sql) shouldBe true Regex("""\["API_AUDIT_LOG_READ","API_AUDIT_LOG_WRITE"\]""").containsMatchIn(v1Sql) shouldBe true Regex("""\["ACTIVITY_LOG_READ"\]""").containsMatchIn(v1Sql) shouldBe true + Regex("""\["LOGIN_HISTORY_READ"\]""").containsMatchIn(v1Sql) shouldBe true } - it("workspace allowed domains는 V1 초기 스키마에 흡수되고 별도 migration 파일은 없다") { + it("workspace/platform/menu managed 컬럼은 V1 초기 스키마에 반영된다") { val v1Sql = Files.readString(Path.of("src/main/resources/db/migration/app/V1__init.sql")) val workspacesBlock = Regex("""(?s)CREATE TABLE workspaces\s*\(.*?\n\);""").find(v1Sql)?.value.orEmpty() + val settingsBlock = + Regex("""(?s)CREATE TABLE platform_settings\s*\(.*?\n\);""").find(v1Sql)?.value.orEmpty() + val menusBlock = + Regex("""(?s)CREATE TABLE menus\s*\(.*?\n\);""").find(v1Sql)?.value.orEmpty() - Regex("""allowed_domains\s+JSONB\s+NOT\s+NULL\s+DEFAULT\s+'\[\]'::jsonb""") + Regex("""auto_join_domains\s+JSONB\s+NOT\s+NULL\s+DEFAULT\s+'\[\]'::jsonb""") .containsMatchIn(workspacesBlock) shouldBe true - Files.exists(Path.of("src/main/resources/db/migration/app/V202603301300__workspace_allowed_domains.sql")) shouldBe false + Regex("""external_source\s+VARCHAR\(50\)""").containsMatchIn(workspacesBlock) shouldBe true + Regex("""external_id\s+VARCHAR\(255\)""").containsMatchIn(workspacesBlock) shouldBe true + Regex("""CONSTRAINT chk_workspaces_external_reference_pair""").containsMatchIn(workspacesBlock) shouldBe true + Regex("""CONSTRAINT chk_workspaces_external_platform_managed""").containsMatchIn(workspacesBlock) shouldBe true + Regex("""CREATE UNIQUE INDEX udx_workspaces_external_reference\s+ON workspaces \(external_source, external_id\)""") + .containsMatchIn(v1Sql) shouldBe true + Regex("""workspace_use_platform_managed\s+BOOLEAN""").containsMatchIn(settingsBlock) shouldBe true + Regex("""managed_type\s+VARCHAR\(30\)\s+NOT\s+NULL\s+DEFAULT\s+'USER_MANAGED'""") + .containsMatchIn(menusBlock) shouldBe true + Regex("""WITH RECURSIVE platform_menu_tree AS""").containsMatchIn(v1Sql) shouldBe true + Regex("""SET managed_type = 'PLATFORM_MANAGED'""").containsMatchIn(v1Sql) shouldBe true + } + + it("workspace invite provenance와 concurrency 컬럼은 V1 초기 스키마에 반영된다") { + val v1Sql = Files.readString(Path.of("src/main/resources/db/migration/app/V1__init.sql")) + val workspaceInvitesBlock = + Regex("""(?s)CREATE TABLE workspace_invites\s*\(.*?\n\);""").find(v1Sql)?.value.orEmpty() + + Regex("""inviter_id\s+UUID\s+NOT\s+NULL""").containsMatchIn(workspaceInvitesBlock) shouldBe true + Regex("""version\s+BIGINT\s+NOT\s+NULL\s+DEFAULT\s+0""").containsMatchIn(workspaceInvitesBlock) shouldBe true + Regex("""CREATE UNIQUE INDEX uq_workspace_invites_pending_email\s+ON workspace_invites \(workspace_id, email\)\s+WHERE status = 'PENDING' AND deleted_at IS NULL;""") + .containsMatchIn(v1Sql) shouldBe true } } }) diff --git a/backend/app/src/test/kotlin/io/deck/app/service/DashboardServiceTest.kt b/backend/app/src/test/kotlin/io/deck/app/service/DashboardServiceTest.kt index 7dad1a4cf..c04b8ec31 100644 --- a/backend/app/src/test/kotlin/io/deck/app/service/DashboardServiceTest.kt +++ b/backend/app/src/test/kotlin/io/deck/app/service/DashboardServiceTest.kt @@ -82,7 +82,7 @@ class DashboardServiceTest : response.activeUsersCount shouldBe null response.errorStats shouldBe null response.pendingInvitesCount shouldBe null - response.systemStatus shouldBe null + response.platformStatus shouldBe null response.roleDistribution shouldBe null response.recentApiAuditLogs shouldBe null } @@ -151,9 +151,9 @@ class DashboardServiceTest : response.activeUsersCount shouldBe 50 response.errorStats?.size shouldBe 2 response.pendingInvitesCount shouldBe 3 - response.systemStatus?.emailEnabled shouldBe true - response.systemStatus?.slackEnabled shouldBe false - response.systemStatus?.activeNotificationChannels shouldBe 2 + response.platformStatus?.emailEnabled shouldBe true + response.platformStatus?.slackEnabled shouldBe false + response.platformStatus?.activeNotificationChannels shouldBe 2 response.roleDistribution?.map { it.roleLabel } shouldBe listOf("Administrator") response.recentApiAuditLogs?.size shouldBe 1 } diff --git a/backend/audit/src/main/kotlin/io/deck/audit/service/ApiAuditLogService.kt b/backend/audit/src/main/kotlin/io/deck/audit/service/ApiAuditLogService.kt index 3f7d780ff..eb141bb16 100644 --- a/backend/audit/src/main/kotlin/io/deck/audit/service/ApiAuditLogService.kt +++ b/backend/audit/src/main/kotlin/io/deck/audit/service/ApiAuditLogService.kt @@ -191,7 +191,7 @@ class ApiAuditLogService( method = it.method, path = it.path, statusCode = it.statusCode, - performedBy = it.userName ?: it.email ?: "System", + performedBy = it.userName ?: it.email ?: "Platform", createdAt = it.createdAt, ) } diff --git a/backend/common/src/main/kotlin/io/deck/common/BrandingProvider.kt b/backend/common/src/main/kotlin/io/deck/common/BrandingProvider.kt index 216c3d58f..0728f6c6a 100644 --- a/backend/common/src/main/kotlin/io/deck/common/BrandingProvider.kt +++ b/backend/common/src/main/kotlin/io/deck/common/BrandingProvider.kt @@ -3,7 +3,7 @@ package io.deck.common /** * 브랜딩 정보 제공 인터페이스 * - * iam 모듈의 SystemSettingService에 의해 구현됩니다. + * iam 모듈의 PlatformSettingService에 의해 구현됩니다. * integration 모듈에서 이메일 템플릿 등에 브랜드명을 사용할 때 활용합니다. */ interface BrandingProvider { diff --git a/backend/common/src/main/kotlin/io/deck/common/CacheNames.kt b/backend/common/src/main/kotlin/io/deck/common/CacheNames.kt index db307cf2a..d2711101c 100644 --- a/backend/common/src/main/kotlin/io/deck/common/CacheNames.kt +++ b/backend/common/src/main/kotlin/io/deck/common/CacheNames.kt @@ -2,7 +2,7 @@ package io.deck.common object CacheNames { const val USERS = "users" - const val SYSTEM_SETTINGS = "system_settings" + const val PLATFORM_SETTINGS = "platform_settings" const val HOLIDAYS = "holidays" const val MENU_PERMISSIONS = "menu_permissions" } diff --git a/backend/common/src/main/kotlin/io/deck/common/api/event/ActivityTargetType.kt b/backend/common/src/main/kotlin/io/deck/common/api/event/ActivityTargetType.kt index 9a6bc7c32..fe944e4ca 100644 --- a/backend/common/src/main/kotlin/io/deck/common/api/event/ActivityTargetType.kt +++ b/backend/common/src/main/kotlin/io/deck/common/api/event/ActivityTargetType.kt @@ -34,7 +34,7 @@ enum class ActivityTargetType { SESSION, SESSION_BATCH, SLACK_TEMPLATE, - SYSTEM_SETTING, + PLATFORM_SETTING, WORKSPACE, WORKSPACE_INVITE, WORKSPACE_MEMBER, diff --git a/backend/deskpie/src/main/kotlin/io/deck/deskpie/crm/shared/internal/service/CrmContactProfileService.kt b/backend/deskpie/src/main/kotlin/io/deck/deskpie/crm/shared/internal/service/CrmContactProfileService.kt index 9ab4812be..9691d8967 100644 --- a/backend/deskpie/src/main/kotlin/io/deck/deskpie/crm/shared/internal/service/CrmContactProfileService.kt +++ b/backend/deskpie/src/main/kotlin/io/deck/deskpie/crm/shared/internal/service/CrmContactProfileService.kt @@ -3,7 +3,7 @@ package io.deck.deskpie.crm.shared.internal.service import io.deck.common.api.exception.BadRequestException import io.deck.globalization.businessregistration.api.BusinessRegistrationNumberNormalizer import io.deck.globalization.contactfield.api.ContactPhoneNumberNormalizer -import io.deck.iam.api.SystemSettingQuery +import io.deck.iam.api.PlatformSettingQuery import io.deck.party.api.OrganizationPartyView import io.deck.party.api.PartyAddressCommand import io.deck.party.api.PartyAddressKindValue @@ -109,7 +109,7 @@ class CrmContactProfileService( private val partyQuery: PartyQuery, private val contactPhoneNumberNormalizer: ContactPhoneNumberNormalizer, private val businessRegistrationNumberNormalizer: BusinessRegistrationNumberNormalizer, - private val systemSettingQuery: SystemSettingQuery, + private val platformSettingQuery: PlatformSettingQuery, ) { fun upsertOrganizationProfile( partyId: UUID?, @@ -320,7 +320,7 @@ class CrmContactProfileService( private fun normalizeNullable(value: String?): String? = value?.trim()?.takeIf { it.isNotBlank() } - private fun resolveDefaultCountryCode(): String = systemSettingQuery.getDefaultCountryCode() + private fun resolveDefaultCountryCode(): String = platformSettingQuery.getDefaultCountryCode() private fun toPartyVerificationStatus(value: String): PartyVerificationStatusValue = runCatching { PartyVerificationStatusValue.valueOf(value.trim().uppercase()) } diff --git a/backend/deskpie/src/main/kotlin/io/deck/deskpie/init/DeskPieDevDataSeeder.kt b/backend/deskpie/src/main/kotlin/io/deck/deskpie/init/DeskPieDevDataSeeder.kt index d3dfd8678..1b864ced8 100644 --- a/backend/deskpie/src/main/kotlin/io/deck/deskpie/init/DeskPieDevDataSeeder.kt +++ b/backend/deskpie/src/main/kotlin/io/deck/deskpie/init/DeskPieDevDataSeeder.kt @@ -497,7 +497,7 @@ class DeskPieDevDataSeeder( private fun resolveDefaultCountryCode(): String = jdbcTemplate.queryForObject( - "SELECT country_default_country_code FROM system_settings LIMIT 1", + "SELECT country_default_country_code FROM platform_settings LIMIT 1", String::class.java, ) ?: throw IllegalStateException("Default country code is not configured") diff --git a/backend/deskpie/src/main/kotlin/io/deck/deskpie/registry/ProgramRegistrar.kt b/backend/deskpie/src/main/kotlin/io/deck/deskpie/registry/ProgramRegistrar.kt index b861652c6..f47504b2f 100644 --- a/backend/deskpie/src/main/kotlin/io/deck/deskpie/registry/ProgramRegistrar.kt +++ b/backend/deskpie/src/main/kotlin/io/deck/deskpie/registry/ProgramRegistrar.kt @@ -2,91 +2,84 @@ package io.deck.deskpie.registry import io.deck.iam.api.ProgramDefinition import io.deck.iam.api.ProgramRegistrar +import io.deck.iam.api.consoleProgramPath import org.springframework.stereotype.Component @Component class ProgramRegistrar : ProgramRegistrar { - private val workspacePolicy = ProgramDefinition.WorkspacePolicy(required = true) + private val workspacePolicy = + ProgramDefinition.WorkspacePolicy( + required = true, + selectionRequired = true, + ) override fun programs() = listOf( ProgramDefinition( "CRM_PIPELINE_MANAGEMENT", - "/pipelines", + consoleProgramPath("/pipelines"), setOf("CRM_PIPELINE_MANAGEMENT_READ", "CRM_PIPELINE_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_COMPANY_MANAGEMENT", - "/companies", + consoleProgramPath("/companies"), setOf("CRM_COMPANY_MANAGEMENT_READ", "CRM_COMPANY_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_CONTRACTING_PARTY_MANAGEMENT", - "/contracting-parties", + consoleProgramPath("/contracting-parties"), setOf("CRM_CONTRACTING_PARTY_MANAGEMENT_READ", "CRM_CONTRACTING_PARTY_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_CONTACT_MANAGEMENT", - "/contacts", + consoleProgramPath("/contacts"), setOf("CRM_CONTACT_MANAGEMENT_READ", "CRM_CONTACT_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_DEAL_MANAGEMENT", - "/deals", + consoleProgramPath("/deals"), setOf("CRM_DEAL_MANAGEMENT_READ", "CRM_DEAL_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_LEAD_MANAGEMENT", - "/leads", + consoleProgramPath("/leads"), setOf("CRM_LEAD_MANAGEMENT_READ", "CRM_LEAD_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_PRODUCT_MANAGEMENT", - "/products", + consoleProgramPath("/products"), setOf("CRM_PRODUCT_MANAGEMENT_READ", "CRM_PRODUCT_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_QUOTE_MANAGEMENT", - "/quotes", + consoleProgramPath("/quotes"), setOf("CRM_QUOTE_MANAGEMENT_READ", "CRM_QUOTE_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_CONTRACT_MANAGEMENT", - "/contracts", + consoleProgramPath("/contracts"), setOf("CRM_CONTRACT_MANAGEMENT_READ", "CRM_CONTRACT_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_LICENSE_REQUEST_MANAGEMENT", - "/license-requests", + consoleProgramPath("/license-requests"), setOf("CRM_LICENSE_REQUEST_MANAGEMENT_READ", "CRM_LICENSE_REQUEST_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "CRM_LICENSE_MANAGEMENT", - "/licenses", + consoleProgramPath("/licenses"), setOf("CRM_LICENSE_MANAGEMENT_READ", "CRM_LICENSE_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), - ProgramDefinition( - "CRM_ACTIVITY_MANAGEMENT", - "/activities", - setOf("CRM_ACTIVITY_MANAGEMENT_READ", "CRM_ACTIVITY_MANAGEMENT_WRITE"), - workspace = workspacePolicy, - ), - ProgramDefinition( - "CRM_NOTE_MANAGEMENT", - "/notes", - setOf("CRM_NOTE_MANAGEMENT_READ", "CRM_NOTE_MANAGEMENT_WRITE"), - workspace = workspacePolicy, - ), ) } diff --git a/backend/deskpie/src/test/kotlin/io/deck/deskpie/crm/shared/CrmContactProfileServiceTest.kt b/backend/deskpie/src/test/kotlin/io/deck/deskpie/crm/shared/CrmContactProfileServiceTest.kt index bd720e434..46b31c950 100644 --- a/backend/deskpie/src/test/kotlin/io/deck/deskpie/crm/shared/CrmContactProfileServiceTest.kt +++ b/backend/deskpie/src/test/kotlin/io/deck/deskpie/crm/shared/CrmContactProfileServiceTest.kt @@ -8,7 +8,7 @@ import io.deck.deskpie.crm.shared.internal.service.CrmIdentifierInput import io.deck.globalization.businessregistration.api.BusinessRegistrationNumberNormalizer import io.deck.globalization.businessregistration.api.KrBusinessRegistrationNumberValue import io.deck.globalization.contactfield.api.ContactPhoneNumberNormalizer -import io.deck.iam.api.SystemSettingQuery +import io.deck.iam.api.PlatformSettingQuery import io.deck.party.api.OrganizationPartyView import io.deck.party.api.PartyCommand import io.deck.party.api.PartyQuery @@ -30,18 +30,18 @@ class CrmContactProfileServiceTest : val partyQuery = mockk(relaxed = true) val phoneNumberNormalizer = mockk(relaxed = true) val businessRegistrationNumberNormalizer = mockk(relaxed = true) - val systemSettingQuery = mockk() + val platformSettingQuery = mockk() val service = CrmContactProfileService( partyCommand = partyCommand, partyQuery = partyQuery, contactPhoneNumberNormalizer = phoneNumberNormalizer, businessRegistrationNumberNormalizer = businessRegistrationNumberNormalizer, - systemSettingQuery = systemSettingQuery, + platformSettingQuery = platformSettingQuery, ) beforeTest { - every { systemSettingQuery.getDefaultCountryCode() } returns "US" + every { platformSettingQuery.getDefaultCountryCode() } returns "US" every { businessRegistrationNumberNormalizer.normalize(any(), any()) } answers { KrBusinessRegistrationNumberValue( rawValue = secondArg(), diff --git a/backend/deskpie/src/test/kotlin/io/deck/deskpie/init/DeskPieDevDataSeederTest.kt b/backend/deskpie/src/test/kotlin/io/deck/deskpie/init/DeskPieDevDataSeederTest.kt index 43ca6548d..06d061ed2 100644 --- a/backend/deskpie/src/test/kotlin/io/deck/deskpie/init/DeskPieDevDataSeederTest.kt +++ b/backend/deskpie/src/test/kotlin/io/deck/deskpie/init/DeskPieDevDataSeederTest.kt @@ -15,10 +15,11 @@ import io.deck.deskpie.crm.pipeline.internal.repository.PipelineRepository import io.deck.deskpie.crm.pipeline.internal.repository.PipelineStageRepository import io.deck.deskpie.crm.shared.internal.service.CrmContactProfileInput import io.deck.deskpie.crm.shared.internal.service.CrmContactProfileService +import io.deck.iam.ManagementType +import io.deck.iam.api.ExternalReferenceRecord import io.deck.iam.api.MenuSeedCommand import io.deck.iam.api.SeedMenuDefinition import io.deck.iam.api.WorkspaceDirectory -import io.deck.iam.api.WorkspaceManagedType import io.deck.iam.api.WorkspaceRecord import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldContainAll @@ -31,7 +32,7 @@ import io.mockk.verify import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest import org.springframework.jdbc.core.JdbcTemplate -import java.time.Instant +import java.time.LocalDateTime import java.util.UUID class DeskPieDevDataSeederTest : @@ -242,8 +243,9 @@ private fun workspaceRecord(name: String): WorkspaceRecord = override val id: UUID = UUID.randomUUID() override val name: String = name override val description: String? = null - override val allowedDomains: List = emptyList() - override val managedType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED - override val createdAt: Instant? = null - override val updatedAt: Instant? = null + override val autoJoinDomains: List = emptyList() + override val managedType: ManagementType = ManagementType.USER_MANAGED + override val externalReference: ExternalReferenceRecord? = null + override val createdAt: LocalDateTime? = null + override val updatedAt: LocalDateTime? = null } diff --git a/backend/deskpie/src/test/kotlin/io/deck/deskpie/registry/DeskPieRegistryTest.kt b/backend/deskpie/src/test/kotlin/io/deck/deskpie/registry/DeskPieRegistryTest.kt index 4681fd7ef..6f12ff335 100644 --- a/backend/deskpie/src/test/kotlin/io/deck/deskpie/registry/DeskPieRegistryTest.kt +++ b/backend/deskpie/src/test/kotlin/io/deck/deskpie/registry/DeskPieRegistryTest.kt @@ -3,6 +3,7 @@ package io.deck.deskpie.registry import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.shouldBe class DeskPieRegistryTest : DescribeSpec({ @@ -12,6 +13,13 @@ class DeskPieRegistryTest : programs.map { it.code } shouldContain "CRM_CONTRACTING_PARTY_MANAGEMENT" } + + it("활동/노트는 top-level console page가 아니라 embedded capability로만 남겨야 한다") { + val programs = ProgramRegistrar().programs() + + programs.any { it.code == "CRM_ACTIVITY_MANAGEMENT" } shouldBe false + programs.any { it.code == "CRM_NOTE_MANAGEMENT" } shouldBe false + } } describe("PermissionRegistrar") { diff --git a/backend/iam/src/main/kotlin/io/deck/iam/ManagementType.kt b/backend/iam/src/main/kotlin/io/deck/iam/ManagementType.kt new file mode 100644 index 000000000..2f9aba077 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/ManagementType.kt @@ -0,0 +1,6 @@ +package io.deck.iam + +enum class ManagementType { + USER_MANAGED, + PLATFORM_MANAGED, +} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/DevSeedUserManager.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/DevSeedUserManager.kt index a2b2e2cb2..f025145b3 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/DevSeedUserManager.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/DevSeedUserManager.kt @@ -11,6 +11,7 @@ data class SeedRoleRecord( data class SeedUserSummary( val id: UUID, + val name: String, val email: String, val passwordMustChange: Boolean, ) @@ -24,6 +25,11 @@ interface DevSeedUserManager { fun clearPasswordMustChange(userId: UUID) + fun updateUserName( + userId: UUID, + name: String, + ) + fun createSeedUser( username: String, password: String, diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/MenuSeedCommand.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/MenuSeedCommand.kt index 401c3420f..b57a1a1e4 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/MenuSeedCommand.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/MenuSeedCommand.kt @@ -1,5 +1,7 @@ package io.deck.iam.api +import io.deck.iam.ManagementType + /** * 메뉴 시딩 커맨드 (dev/local 프로필 DataSeeder 전용) */ @@ -24,6 +26,7 @@ data class SeedMenuDefinition( val namesI18n: Map? = null, val icon: String? = null, val programType: String = MenuSeedCommand.NONE_PROGRAM_TYPE, + val managementType: ManagementType = ManagementType.USER_MANAGED, val permissions: Set = emptySet(), val children: List = emptyList(), ) diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/SystemSettingQuery.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/PlatformSettingQuery.kt similarity index 67% rename from backend/iam/src/main/kotlin/io/deck/iam/api/SystemSettingQuery.kt rename to backend/iam/src/main/kotlin/io/deck/iam/api/PlatformSettingQuery.kt index 0107013c4..c919b55af 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/SystemSettingQuery.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/PlatformSettingQuery.kt @@ -1,5 +1,5 @@ package io.deck.iam.api -interface SystemSettingQuery { +interface PlatformSettingQuery { fun getDefaultCountryCode(): String } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/ProgramDefinition.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/ProgramDefinition.kt index a88352e14..c7ce9dc5a 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/ProgramDefinition.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/ProgramDefinition.kt @@ -1,6 +1,6 @@ package io.deck.iam.api -import io.deck.iam.domain.WorkspaceManagedType +import io.deck.iam.ManagementType data class ProgramDefinition( val code: String, @@ -10,6 +10,7 @@ data class ProgramDefinition( ) { data class WorkspacePolicy( val required: Boolean = false, - val managedType: WorkspaceManagedType? = null, + val requiredManagedType: ManagementType? = null, + val selectionRequired: Boolean = false, ) } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/ProgramPaths.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/ProgramPaths.kt new file mode 100644 index 000000000..f47d23076 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/ProgramPaths.kt @@ -0,0 +1,40 @@ +package io.deck.iam.api + +private const val CONSOLE_PREFIX = "/console" +private val DISALLOWED_CONSOLE_SERVICE_PREFIXES = setOf("deskpie", "meetpie") + +private fun normalizeProgramPath(path: String): String = + when { + path == CONSOLE_PREFIX -> path + path.endsWith("/") -> path.removeSuffix("/") + else -> path + } + +private fun assertCanonicalConsoleProgramPath(path: String) { + if (!path.startsWith("$CONSOLE_PREFIX/")) { + return + } + + val firstSegment = path + .removePrefix("$CONSOLE_PREFIX/") + .split('/') + .firstOrNull() + ?.takeIf { it.isNotBlank() } ?: return + require(firstSegment !in DISALLOWED_CONSOLE_SERVICE_PREFIXES) { + "Console path must not include service prefix: $firstSegment" + } +} + +fun consoleProgramPath(path: String): String { + require(path.isNotBlank()) { "Program path must not be blank." } + + val normalizedPath = + when { + path == CONSOLE_PREFIX || path.startsWith("$CONSOLE_PREFIX/") -> path + path.startsWith("/") -> "$CONSOLE_PREFIX$path" + else -> "$CONSOLE_PREFIX/$path" + } + val canonicalPath = normalizeProgramPath(normalizedPath) + assertCanonicalConsoleProgramPath(canonicalPath) + return canonicalPath +} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceDirectory.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceDirectory.kt index 98021cc1d..e6322d957 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceDirectory.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceDirectory.kt @@ -1,49 +1,50 @@ package io.deck.iam.api +import io.deck.iam.ManagementType import java.util.UUID interface WorkspaceDirectory { - fun ensureManagedTypeEnabled(managedType: WorkspaceManagedType) + fun listAll(): List + + fun ensureManagedTypeEnabled(managedType: ManagementType) fun ensureAccessible( workspaceId: UUID, - managedType: WorkspaceManagedType? = null, + managedType: ManagementType? = null, ) fun verifyOwner( workspaceId: UUID, userId: UUID, - managedType: WorkspaceManagedType? = null, + managedType: ManagementType? = null, ) - fun get(workspaceId: UUID): WorkspaceRecord + fun getPlatformManaged(workspaceId: UUID): WorkspaceRecord - fun listAll(): List + fun listPlatformManaged(): List fun listVisibleByUser(userId: UUID): List - fun create( + fun createPlatformManaged( name: String, description: String?, initialOwnerId: UUID, - managedType: WorkspaceManagedType, - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ): WorkspaceRecord fun createForUser( name: String, description: String?, userId: UUID, - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ): WorkspaceRecord - fun updateByAdmin( + fun updatePlatformManaged( workspaceId: UUID, name: String, description: String?, updatedBy: UUID, - managedType: WorkspaceManagedType, - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ): WorkspaceRecord fun updateForUser( @@ -51,11 +52,11 @@ interface WorkspaceDirectory { name: String, description: String?, requestedBy: UUID, - managedType: WorkspaceManagedType, - allowedDomains: List = emptyList(), + managedType: ManagementType, + autoJoinDomains: List = emptyList(), ): WorkspaceRecord - fun deleteByAdminBatch( + fun deletePlatformManagedBatch( workspaceIds: Collection, deletedBy: UUID, ) @@ -63,6 +64,6 @@ interface WorkspaceDirectory { fun deleteBatch( workspaceIds: Collection, deletedBy: UUID, - managedType: WorkspaceManagedType, + managedType: ManagementType, ) } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceManagedType.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceManagedType.kt deleted file mode 100644 index 851986159..000000000 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceManagedType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.deck.iam.api - -enum class WorkspaceManagedType { - USER_MANAGED, - SYSTEM_MANAGED, -} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceRecord.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceRecord.kt index 63c10d14f..74c39510e 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceRecord.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceRecord.kt @@ -1,14 +1,22 @@ package io.deck.iam.api -import java.time.Instant +import io.deck.iam.ManagementType +import io.deck.iam.domain.ExternalSource +import java.time.LocalDateTime import java.util.UUID interface WorkspaceRecord { val id: UUID val name: String val description: String? - val allowedDomains: List - val managedType: WorkspaceManagedType - val createdAt: Instant? - val updatedAt: Instant? + val autoJoinDomains: List + val managedType: ManagementType + val externalReference: ExternalReferenceRecord? + val createdAt: LocalDateTime? + val updatedAt: LocalDateTime? } + +data class ExternalReferenceRecord( + val source: ExternalSource, + val externalId: String, +) diff --git a/backend/iam/src/main/kotlin/io/deck/iam/config/BrandingProviderImpl.kt b/backend/iam/src/main/kotlin/io/deck/iam/config/BrandingProviderImpl.kt index f0f7e58d2..3053f1913 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/config/BrandingProviderImpl.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/config/BrandingProviderImpl.kt @@ -1,23 +1,23 @@ package io.deck.iam.config import io.deck.common.api.branding.BrandingProvider -import io.deck.iam.service.SystemSettingService +import io.deck.iam.service.PlatformSettingService import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component /** * BrandingProvider 구현체 * - * brandName: SystemSettingService에서 조회 (DB, 캐시됨) + * brandName: PlatformSettingService에서 조회 (DB, 캐시됨) * baseUrl: application.yml에서 조회 (고정값) */ @Component class BrandingProviderImpl( - private val systemSettingService: SystemSettingService, + private val platformSettingService: PlatformSettingService, @param:Value($$"${app.base-url}") private val baseUrl: String, ) : BrandingProvider { - override fun getBrandName(): String = systemSettingService.getSettings().brandName + override fun getBrandName(): String = platformSettingService.getSettings().brandName override fun getBaseUrl(): String = baseUrl } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/AuthController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/AuthController.kt index fab6319e0..9bb354b92 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/AuthController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/AuthController.kt @@ -12,8 +12,8 @@ import io.deck.iam.service.AuthService import io.deck.iam.service.IdentityService import io.deck.iam.service.MenuService import io.deck.iam.service.OAuthProviderService +import io.deck.iam.service.PlatformSettingService import io.deck.iam.service.RoleService -import io.deck.iam.service.SystemSettingService import io.deck.iam.service.UserService import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -44,7 +44,7 @@ class AuthController( private val menuService: MenuService, private val roleService: RoleService, private val jwtProperties: JwtProperties, - private val systemSettingService: SystemSettingService, + private val platformSettingService: PlatformSettingService, private val oauthProviderService: OAuthProviderService, ) { /** @@ -70,7 +70,7 @@ class AuthController( */ @GetMapping("/config") fun config(): ResponseEntity { - val settings = systemSettingService.getSettings() + val settings = platformSettingService.getSettings() val configuredProviders = oauthProviderService.getConfiguredProviders() val providerTypes = configuredProviders.map { it.provider }.toSet() diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt index 78aefa7f2..b48d661b7 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt @@ -2,7 +2,7 @@ package io.deck.iam.controller import io.deck.common.api.image.Base64ImageUtils import io.deck.iam.domain.LogoType -import io.deck.iam.service.SystemSettingService +import io.deck.iam.service.PlatformSettingService import org.springframework.http.CacheControl import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -23,11 +23,11 @@ import java.util.concurrent.TimeUnit */ @RestController class BrandingController( - private val systemSettingService: SystemSettingService, + private val platformSettingService: PlatformSettingService, ) { @GetMapping("/api/v1/branding") fun getBranding(): ResponseEntity { - val settings = systemSettingService.getSettings() + val settings = platformSettingService.getSettings() return ResponseEntity.ok(settings.toPublicBrandingDto()) } @@ -77,7 +77,7 @@ class BrandingController( version: String?, ): ResponseEntity { // 1. 외부 URL이 설정된 경우 리다이렉트 (테마별 URL 지원) - val settings = systemSettingService.getSettings() + val settings = platformSettingService.getSettings() val externalUrl = settings.getLogoUrl(type, dark) if (externalUrl != null) { return ResponseEntity diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/MenuController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/MenuController.kt index 2425a1a24..7ce970671 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/MenuController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/MenuController.kt @@ -1,5 +1,6 @@ package io.deck.iam.controller +import io.deck.iam.ManagementType import io.deck.iam.service.MenuService import io.deck.iam.service.PermissionRegistry import io.deck.iam.service.ProgramRegistry @@ -43,7 +44,8 @@ class MenuController( it.workspace?.let { workspace -> ProgramWorkspacePolicyDto( required = workspace.required, - managedType = workspace.managedType, + requiredManagedType = workspace.requiredManagedType, + selectionRequired = workspace.selectionRequired, ) }, ) @@ -75,7 +77,7 @@ class MenuController( val tree = menuService .findMenuTreeByRoleId(roleId) - .map { it.toTreeDto() } + .map { it.toTreeDto(programRegistry) } return ResponseEntity.ok(tree) } @@ -95,8 +97,9 @@ class MenuController( namesI18n = request.namesI18n, icon = request.icon, programType = request.program, + managementType = request.managementType ?: ManagementType.USER_MANAGED, ) - return ResponseEntity.ok(menu.toDto()) + return ResponseEntity.ok(menu.toDto(programRegistry)) } /** @@ -117,8 +120,9 @@ class MenuController( namesI18n = request.namesI18n, icon = request.icon, programType = request.program, + managementType = request.managementType ?: ManagementType.USER_MANAGED, ) - return ResponseEntity.ok(menu.toDto()) + return ResponseEntity.ok(menu.toDto(programRegistry)) } /** @@ -137,9 +141,10 @@ class MenuController( namesI18n = request.namesI18n, icon = request.icon, programType = request.program, + managementType = request.managementType, permissions = request.permissions, ) - return ResponseEntity.ok(menu.toDto()) + return ResponseEntity.ok(menu.toDto(programRegistry)) } /** @@ -157,7 +162,7 @@ class MenuController( newParentId = request.parentId, newSortOrder = request.sortOrder, ) - return ResponseEntity.ok(menu.toDto()) + return ResponseEntity.ok(menu.toDto(programRegistry)) } /** @@ -186,7 +191,7 @@ class MenuController( val tree = menuService .findMenuTreeByRoleId(request.targetRoleId) - .map { it.toTreeDto() } + .map { it.toTreeDto(programRegistry) } return ResponseEntity.ok(tree) } @@ -194,7 +199,7 @@ class MenuController( // ========== Extension Functions ========== -private fun io.deck.iam.domain.MenuEntity.toDto(): MenuDto { +private fun io.deck.iam.domain.MenuEntity.toDto(programRegistry: ProgramRegistry): MenuDto { val locale = LocaleContextHolder.getLocale().language return MenuDto( id = id, @@ -202,11 +207,12 @@ private fun io.deck.iam.domain.MenuEntity.toDto(): MenuDto { namesI18n = namesI18n, icon = icon, program = programType, + managementType = managementType, permissions = permissions, ) } -private fun io.deck.iam.domain.MenuEntity.toTreeDto(): MenuTreeDto { +private fun io.deck.iam.domain.MenuEntity.toTreeDto(programRegistry: ProgramRegistry): MenuTreeDto { val locale = LocaleContextHolder.getLocale().language return MenuTreeDto( id = id, @@ -214,7 +220,8 @@ private fun io.deck.iam.domain.MenuEntity.toTreeDto(): MenuTreeDto { namesI18n = namesI18n, icon = icon, program = programType, + managementType = managementType, permissions = permissions, - children = children.map { it.toTreeDto() }, + children = children.map { it.toTreeDto(programRegistry) }, ) } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/MenuDtos.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/MenuDtos.kt index ff7af10dd..824d5befa 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/MenuDtos.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/MenuDtos.kt @@ -1,6 +1,6 @@ package io.deck.iam.controller -import io.deck.iam.domain.WorkspaceManagedType +import io.deck.iam.ManagementType import java.util.UUID data class ProgramDto( @@ -12,7 +12,8 @@ data class ProgramDto( data class ProgramWorkspacePolicyDto( val required: Boolean, - val managedType: WorkspaceManagedType? = null, + val requiredManagedType: ManagementType? = null, + val selectionRequired: Boolean = false, ) data class PermissionDto( @@ -26,6 +27,7 @@ data class MenuDto( val namesI18n: Map?, val icon: String?, val program: String, + val managementType: ManagementType, val permissions: Set, ) @@ -35,6 +37,7 @@ data class MenuTreeDto( val namesI18n: Map?, val icon: String?, val program: String, + val managementType: ManagementType, val permissions: Set, val children: List, ) @@ -44,6 +47,7 @@ data class CreateMenuRequest( val namesI18n: Map? = null, val icon: String? = null, val program: String, + val managementType: ManagementType? = null, ) data class UpdateMenuRequest( @@ -51,6 +55,7 @@ data class UpdateMenuRequest( val namesI18n: Map? = null, val icon: String?, val program: String, + val managementType: ManagementType? = null, val permissions: Set = emptySet(), ) diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingAuthProviderController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderController.kt similarity index 95% rename from backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingAuthProviderController.kt rename to backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderController.kt index 2a437da87..0320d2611 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingAuthProviderController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderController.kt @@ -12,8 +12,8 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping("/api/v1/system-settings/auth/providers") -class SystemSettingAuthProviderController( +@RequestMapping("/api/v1/platform-settings/auth/providers") +class PlatformSettingAuthProviderController( private val oauthProviderService: OAuthProviderService, ) { @GetMapping diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt similarity index 82% rename from backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt rename to backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt index c124f9857..1d78334b4 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt @@ -3,9 +3,9 @@ package io.deck.iam.controller import io.deck.common.api.context.userId import io.deck.common.api.image.Base64ImageUtils import io.deck.iam.domain.LogoType -import io.deck.iam.domain.SystemSettingEntity +import io.deck.iam.domain.PlatformSettingEntity import io.deck.iam.security.OwnerOnly -import io.deck.iam.service.SystemSettingService +import io.deck.iam.service.PlatformSettingService import org.springframework.beans.factory.annotation.Value import org.springframework.http.CacheControl import org.springframework.http.MediaType @@ -24,36 +24,36 @@ import java.util.UUID import java.util.concurrent.TimeUnit /** - * 시스템 설정 API 컨트롤러 + * 플랫폼 설정 API 컨트롤러 */ @RestController -@RequestMapping("/api/v1/system-settings") -class SystemSettingController( - private val systemSettingService: SystemSettingService, +@RequestMapping("/api/v1/platform-settings") +class PlatformSettingController( + private val platformSettingService: PlatformSettingService, @param:Value("\${app.base-url}") private val baseUrl: String, ) { /** - * 시스템 설정 조회 + * 플랫폼 설정 조회 */ @GetMapping @PreAuthorize("isAuthenticated()") - fun get(): ResponseEntity { - val settings = systemSettingService.getSettings() + fun get(): ResponseEntity { + val settings = platformSettingService.getSettings() return ResponseEntity.ok(settings.toDto(baseUrl)) } /** - * 시스템 설정 수정 (Owner만) + * 플랫폼 설정 수정 (Owner만) */ @PutMapping("/general") @OwnerOnly fun updateGeneral( @RequestBody request: UpdateGeneralSettingsRequest, principal: Principal, - ): ResponseEntity { + ): ResponseEntity { val userId = principal.userId val settings = - systemSettingService.updateGeneral( + platformSettingService.updateGeneral( userId = userId, brandName = request.brandName, contactEmail = request.contactEmail, @@ -66,10 +66,10 @@ class SystemSettingController( fun updateWorkspacePolicy( @RequestBody request: UpdateWorkspacePolicyRequest, principal: Principal, - ): ResponseEntity { + ): ResponseEntity { val userId = principal.userId val settings = - systemSettingService.updateWorkspacePolicy( + platformSettingService.updateWorkspacePolicy( userId = userId, workspacePolicy = request.workspacePolicy?.toDomain(), ) @@ -81,10 +81,10 @@ class SystemSettingController( fun updateCountryPolicy( @RequestBody request: UpdateCountryPolicyRequest, principal: Principal, - ): ResponseEntity { + ): ResponseEntity { val userId = principal.userId val settings = - systemSettingService.updateCountryPolicy( + platformSettingService.updateCountryPolicy( userId = userId, countryPolicy = request.countryPolicy.toDomain(), ) @@ -96,10 +96,10 @@ class SystemSettingController( fun updateCurrencyPolicy( @RequestBody request: UpdateCurrencyPolicyRequest, principal: Principal, - ): ResponseEntity { + ): ResponseEntity { val userId = principal.userId val settings = - systemSettingService.updateCurrencyPolicy( + platformSettingService.updateCurrencyPolicy( userId = userId, currencyPolicy = request.currencyPolicy.toDomain(), ) @@ -111,10 +111,10 @@ class SystemSettingController( fun updateGlobalizationPolicy( @RequestBody request: UpdateGlobalizationPolicyRequest, principal: Principal, - ): ResponseEntity { + ): ResponseEntity { val userId = principal.userId val settings = - systemSettingService.updateGlobalizationPolicy( + platformSettingService.updateGlobalizationPolicy( userId = userId, countryPolicy = request.countryPolicy.toDomain(), currencyPolicy = request.currencyPolicy.toDomain(), @@ -132,9 +132,9 @@ class SystemSettingController( @RequestBody request: SetLogoUrlRequest, @RequestParam(name = "dark", required = false, defaultValue = "false") dark: Boolean, principal: Principal, - ): ResponseEntity { + ): ResponseEntity { val userId = principal.userId - val settings = systemSettingService.setLogoUrl(userId, type, request.url, dark) + val settings = platformSettingService.setLogoUrl(userId, type, request.url, dark) return ResponseEntity.ok(settings.toDto(baseUrl)) } @@ -148,9 +148,9 @@ class SystemSettingController( @RequestBody request: UploadLogoRequest, @RequestParam(name = "dark", required = false, defaultValue = "false") dark: Boolean, principal: Principal, - ): ResponseEntity { + ): ResponseEntity { val userId = principal.userId - val settings = systemSettingService.uploadLogo(userId, type, request.data, dark) + val settings = platformSettingService.uploadLogo(userId, type, request.data, dark) return ResponseEntity.ok(settings.toDto(baseUrl)) } @@ -164,7 +164,7 @@ class SystemSettingController( @RequestParam(name = "dark", required = false, defaultValue = "false") dark: Boolean, ): ResponseEntity { val data = - systemSettingService.getLogoData(type, dark) + platformSettingService.getLogoData(type, dark) ?: return ResponseEntity.notFound().build() val parsed = @@ -188,7 +188,7 @@ class SystemSettingController( @OwnerOnly fun getAuthSettings(principal: Principal): ResponseEntity { val userId = principal.userId - val settings = systemSettingService.getAuthSettings(userId) + val settings = platformSettingService.getAuthSettings(userId) return ResponseEntity.ok(settings.toAuthDto()) } @@ -202,9 +202,9 @@ class SystemSettingController( principal: Principal, ): ResponseEntity { val userId = principal.userId - val current = systemSettingService.getAuthSettings(userId) + val current = platformSettingService.getAuthSettings(userId) val settings = - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = userId, internalLoginEnabled = request.internalLoginEnabled, auth0Enabled = current.auth0Enabled, @@ -222,8 +222,8 @@ class SystemSettingController( // ========== Extension Functions ========== -private fun SystemSettingEntity.toDto(baseUrl: String): SystemSettingDto = - SystemSettingDto( +private fun PlatformSettingEntity.toDto(baseUrl: String): PlatformSettingDto = + PlatformSettingDto( id = id, brandName = brandName, contactEmail = contactEmail, @@ -236,7 +236,8 @@ private fun SystemSettingEntity.toDto(baseUrl: String): SystemSettingDto = workspacePolicy?.let { WorkspacePolicyDto( useUserManaged = it.useUserManaged, - useSystemManaged = it.useSystemManaged, + usePlatformManaged = it.usePlatformManaged, + useExternalSync = it.useExternalSync, useSelector = it.useSelector, ) }, @@ -253,7 +254,7 @@ private fun SystemSettingEntity.toDto(baseUrl: String): SystemSettingDto = baseUrl = baseUrl, ) -internal fun SystemSettingEntity.toPublicBrandingDto(): PublicBrandingDto = +internal fun PlatformSettingEntity.toPublicBrandingDto(): PublicBrandingDto = PublicBrandingDto( brandName = brandName, logoHorizontalUrl = effectivePublicLogoUrl(LogoType.HORIZONTAL), @@ -263,7 +264,7 @@ internal fun SystemSettingEntity.toPublicBrandingDto(): PublicBrandingDto = faviconDarkUrl = effectivePublicLogoUrl(LogoType.FAVICON, dark = true), ) -private fun SystemSettingEntity.effectiveSystemLogoUrl( +private fun PlatformSettingEntity.effectiveSystemLogoUrl( type: LogoType, dark: Boolean = false, ): String? { @@ -277,13 +278,13 @@ private fun SystemSettingEntity.effectiveSystemLogoUrl( if (dark) add("dark=true") add("v=${data.hashCode()}") }.joinToString("&") - return "/api/v1/system-settings/logo/${type.name}?$query" + return "/api/v1/platform-settings/logo/${type.name}?$query" } return null } -private fun SystemSettingEntity.effectivePublicLogoUrl( +private fun PlatformSettingEntity.effectivePublicLogoUrl( type: LogoType, dark: Boolean = false, ): String { @@ -312,7 +313,7 @@ private fun SystemSettingEntity.effectivePublicLogoUrl( } } -private fun SystemSettingEntity.toAuthDto(): AuthSettingsDto = +private fun PlatformSettingEntity.toAuthDto(): AuthSettingsDto = AuthSettingsDto( internalLoginEnabled = internalLoginEnabled, auth0Enabled = auth0Enabled, diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt similarity index 93% rename from backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt rename to backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt index bcae29ed0..878ecffca 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt @@ -5,7 +5,7 @@ import io.deck.iam.domain.CurrencyPolicy import io.deck.iam.domain.WorkspacePolicy import java.util.UUID -data class SystemSettingDto( +data class PlatformSettingDto( val id: UUID, val brandName: String, val contactEmail: String?, @@ -53,13 +53,15 @@ data class UpdateGlobalizationPolicyRequest( data class WorkspacePolicyDto( val useUserManaged: Boolean, - val useSystemManaged: Boolean, + val usePlatformManaged: Boolean, + val useExternalSync: Boolean, val useSelector: Boolean, ) { fun toDomain(): WorkspacePolicy = WorkspacePolicy( useUserManaged = useUserManaged, - useSystemManaged = useSystemManaged, + usePlatformManaged = usePlatformManaged, + useExternalSync = useExternalSync, useSelector = useSelector, ) } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/UserController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/UserController.kt index 722b46569..ac4c055f0 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/UserController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/UserController.kt @@ -12,8 +12,8 @@ import io.deck.iam.domain.UserStatus import io.deck.iam.security.OwnerOnly import io.deck.iam.service.IdentityService import io.deck.iam.service.LoginHistoryService +import io.deck.iam.service.PlatformSettingService import io.deck.iam.service.RoleService -import io.deck.iam.service.SystemSettingService import io.deck.iam.service.UserService import org.springframework.data.domain.Pageable import org.springframework.data.web.PageableDefault @@ -44,7 +44,7 @@ class UserController( private val roleService: RoleService, private val loginHistoryService: LoginHistoryService, private val ownerService: io.deck.iam.service.OwnerService, - private val systemSettingService: SystemSettingService, + private val platformSettingService: PlatformSettingService, private val contactFieldQuery: ContactFieldQuery, ) { /** @@ -340,7 +340,7 @@ class UserController( } private fun resolveContactFieldConfig(currentCountryCode: String? = null): ContactFieldConfig { - val countryPolicy = systemSettingService.getSettings().countryPolicy.normalized() + val countryPolicy = platformSettingService.getSettings().countryPolicy.normalized() return contactFieldQuery.resolve( ResolveContactFieldCommand( diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationClaim.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationClaim.kt new file mode 100644 index 000000000..8f7a869b5 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationClaim.kt @@ -0,0 +1,7 @@ +package io.deck.iam.domain + +data class ExternalOrganizationClaim( + val externalId: String, + val name: String? = null, + val description: String? = null, +) diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationSync.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationSync.kt new file mode 100644 index 000000000..bff37bd63 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationSync.kt @@ -0,0 +1,12 @@ +package io.deck.iam.domain + +sealed interface ExternalOrganizationSync { + data object NoSync : ExternalOrganizationSync + + data object Unavailable : ExternalOrganizationSync + + data class AuthoritativeSnapshot( + val source: ExternalSource, + val organizations: List, + ) : ExternalOrganizationSync +} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalReference.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalReference.kt new file mode 100644 index 000000000..0fc244587 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalReference.kt @@ -0,0 +1,18 @@ +package io.deck.iam.domain + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated + +/** + * v1은 AIP 단일 source만 사용하지만, 저장 키는 source + externalId 조합으로 고정한다. + */ +@Embeddable +data class ExternalReference( + @Enumerated(EnumType.STRING) + @Column(name = "external_source", length = 50) + var source: ExternalSource, + @Column(name = "external_id", length = 255) + var externalId: String, +) diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalSource.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalSource.kt new file mode 100644 index 000000000..2f11c50a7 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalSource.kt @@ -0,0 +1,5 @@ +package io.deck.iam.domain + +enum class ExternalSource { + AIP, +} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/MenuEntity.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/MenuEntity.kt index b33340858..12bd9bbbb 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/MenuEntity.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/MenuEntity.kt @@ -1,10 +1,13 @@ package io.deck.iam.domain import io.deck.common.api.id.UuidUtils +import io.deck.iam.ManagementType import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EntityListeners +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.FetchType import jakarta.persistence.Id import jakarta.persistence.Index @@ -49,6 +52,9 @@ class MenuEntity( var icon: String? = null, @Column(name = "program_type", nullable = false, updatable = true, length = 100) var programType: String, + @Enumerated(EnumType.STRING) + @Column(name = "managed_type", nullable = false, updatable = true, length = 30) + var managementType: ManagementType = ManagementType.USER_MANAGED, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") var parent: MenuEntity? = null, @@ -82,12 +88,14 @@ class MenuEntity( namesI18n: Map?, icon: String?, programType: String, + managementType: ManagementType, permissions: Set, ) { this.name = name this.namesI18n = namesI18n this.icon = icon this.programType = programType + this.managementType = managementType this.permissions = permissions } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt similarity index 96% rename from backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt rename to backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt index 237c44cd7..234992cf8 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt @@ -15,14 +15,14 @@ import java.time.Instant import java.util.UUID /** - * 시스템 설정 엔티티 + * 플랫폼 설정 엔티티 * Owner 전용 설정 (브랜드명, 로고, 인증 설정 등) * 단일 레코드로 관리 */ @Entity -@Table(name = "system_settings") +@Table(name = "platform_settings") @EntityListeners(AuditingEntityListener::class) -class SystemSettingEntity( +class PlatformSettingEntity( @Id @Column(columnDefinition = "UUID", nullable = false, updatable = false) val id: UUID = UuidUtils.generate(), @@ -117,6 +117,12 @@ class SystemSettingEntity( this.workspacePolicy = workspacePolicy?.normalizedOrNull() } + fun normalizeEmbeddedPolicies() { + workspacePolicy = workspacePolicy?.normalizedOrNull() + countryPolicy = countryPolicy.normalized() + currencyPolicy = currencyPolicy.normalized() + } + fun updateCountryPolicy(countryPolicy: CountryPolicy) { this.countryPolicy = countryPolicy.normalized() } @@ -305,7 +311,7 @@ class SystemSettingEntity( override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is SystemSettingEntity) return false + if (other !is PlatformSettingEntity) return false return id == other.id } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/UserEntity.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/UserEntity.kt index 11875118b..93d43352f 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/UserEntity.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/UserEntity.kt @@ -248,6 +248,6 @@ class UserEntity( } companion object { - const val SYSTEM_NAME = "System" + const val PLATFORM_NAME = "Platform" } } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt index c4e9504df..73c536c60 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt @@ -2,7 +2,9 @@ package io.deck.iam.domain import io.deck.common.api.entity.SoftDeleteEntity import io.deck.common.api.id.UuidUtils +import io.deck.iam.ManagementType import jakarta.persistence.Column +import jakarta.persistence.Embedded import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated @@ -22,29 +24,52 @@ class WorkspaceEntity( var name: String, @Column(length = 500) var description: String? = null, - @Column(name = "allowed_domains", nullable = false, columnDefinition = "jsonb") + @Column(name = "auto_join_domains", nullable = false, columnDefinition = "jsonb") @JdbcTypeCode(SqlTypes.JSON) - var allowedDomains: List = emptyList(), + var autoJoinDomains: List = emptyList(), @Enumerated(EnumType.STRING) @Column(name = "managed_type", nullable = false, length = 30) - var managedType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, + var managedType: ManagementType = ManagementType.USER_MANAGED, + @Embedded + var externalReference: ExternalReference? = null, id: UUID? = null, ) : SoftDeleteEntity(id = id ?: UuidUtils.generate()) { init { - allowedDomains = allowedDomains.normalizeAllowedDomains() + autoJoinDomains = autoJoinDomains.normalizeAllowedDomains() + enforceExternalInvariant(managedType) } + val isExternal: Boolean + get() = externalReference != null + fun update( name: String, description: String?, - allowedDomains: List = this.allowedDomains, - managedType: WorkspaceManagedType = this.managedType, + autoJoinDomains: List = this.autoJoinDomains, + managedType: ManagementType = this.managedType, ) { + enforceExternalInvariant(managedType) this.name = name this.description = description - this.allowedDomains = allowedDomains.normalizeAllowedDomains() + this.autoJoinDomains = autoJoinDomains.normalizeAllowedDomains() this.managedType = managedType } + + fun syncExternalIdentity( + name: String, + description: String?, + ) { + enforceExternalInvariant(ManagementType.PLATFORM_MANAGED) + this.name = name + this.description = description + this.managedType = ManagementType.PLATFORM_MANAGED + } + + private fun enforceExternalInvariant(managedType: ManagementType) { + require(externalReference == null || managedType == ManagementType.PLATFORM_MANAGED) { + "External workspace must be PLATFORM_MANAGED" + } + } } private fun List.normalizeAllowedDomains(): List = diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceInviteEntity.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceInviteEntity.kt index c88e7fde2..5906268a2 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceInviteEntity.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceInviteEntity.kt @@ -8,6 +8,7 @@ import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.Index import jakarta.persistence.Table +import jakarta.persistence.Version import org.hibernate.annotations.SQLDelete import org.hibernate.annotations.SQLRestriction import java.time.Instant @@ -27,8 +28,7 @@ import java.util.UUID class WorkspaceInviteEntity( @Column(name = "workspace_id", nullable = false, columnDefinition = "UUID") val workspaceId: UUID, - @Column(nullable = false, length = 255) - val email: String, + email: String, @Column(name = "token_hash", nullable = false, unique = true, length = 64) var tokenHash: String, @Enumerated(EnumType.STRING) @@ -38,12 +38,20 @@ class WorkspaceInviteEntity( var expiresAt: Instant, @Column(length = 500) val message: String? = null, + @Column(name = "inviter_id", nullable = false, columnDefinition = "UUID") + var inviterId: UUID, @Column(name = "accepted_user_id", columnDefinition = "UUID") var acceptedUserId: UUID? = null, var acceptedAt: Instant? = null, var cancelledAt: Instant? = null, + @Version + @Column(nullable = false) + var version: Long = 0, id: UUID? = null, ) : SoftDeleteEntity(id = id ?: UuidUtils.generate()) { + @Column(nullable = false, length = 255) + val email: String = email.normalizeInviteEmail() + fun accept(userId: UUID) { require(status == InviteStatus.PENDING) { "Only PENDING invites can be accepted" } require(!isExpired()) { "Invite has expired" } @@ -62,12 +70,16 @@ class WorkspaceInviteEntity( fun isValid(): Boolean = status == InviteStatus.PENDING && !isExpired() - fun rotateToken( + fun resend( newTokenHash: String, newExpiresAt: Instant, + inviterId: UUID, ) { require(status == InviteStatus.PENDING) { "Only PENDING invites can have their token rotated" } tokenHash = newTokenHash expiresAt = newExpiresAt + this.inviterId = inviterId } } + +private fun String.normalizeInviteEmail(): String = trim().lowercase() diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceManagedType.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceManagedType.kt deleted file mode 100644 index 6d7937ee0..000000000 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceManagedType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.deck.iam.domain - -enum class WorkspaceManagedType { - USER_MANAGED, - SYSTEM_MANAGED, -} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspacePolicy.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspacePolicy.kt index 06be4cf2b..5640cb0df 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspacePolicy.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspacePolicy.kt @@ -7,10 +7,28 @@ import jakarta.persistence.Embeddable data class WorkspacePolicy( @Column(name = "workspace_use_user_managed") var useUserManaged: Boolean = true, - @Column(name = "workspace_use_system_managed") - var useSystemManaged: Boolean = true, + @Column(name = "workspace_use_platform_managed") + var usePlatformManaged: Boolean = true, + @Column(name = "workspace_use_external_sync") + var useExternalSync: Boolean = true, @Column(name = "workspace_use_selector") var useSelector: Boolean = true, ) { - fun normalizedOrNull(): WorkspacePolicy? = takeIf { useUserManaged || useSystemManaged } + init { + normalizeInPlace() + } + + fun normalizedOrNull(): WorkspacePolicy? = + copy() + .apply { normalizeInPlace() } + .takeIf { it.useUserManaged || it.usePlatformManaged } + + private fun normalizeInPlace() { + if (!usePlatformManaged) { + useExternalSync = false + } + if (!useUserManaged && !usePlatformManaged) { + useSelector = false + } + } } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/event/IamActivityLogType.kt b/backend/iam/src/main/kotlin/io/deck/iam/event/IamActivityLogType.kt index 630d2b85f..35352afe3 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/event/IamActivityLogType.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/event/IamActivityLogType.kt @@ -50,12 +50,12 @@ enum class IamActivityLogType { SESSION_REVOKED_ALL_EXCEPT_CURRENT, DEVICE_DEACTIVATED, DEVICE_DEACTIVATED_ALL, - SYSTEM_SETTINGS_GENERAL_UPDATED, - SYSTEM_SETTINGS_WORKSPACE_POLICY_UPDATED, - SYSTEM_SETTINGS_COUNTRY_POLICY_UPDATED, - SYSTEM_SETTINGS_CURRENCY_POLICY_UPDATED, - SYSTEM_SETTINGS_GLOBALIZATION_POLICY_UPDATED, - SYSTEM_SETTINGS_LOGO_URL_UPDATED, - SYSTEM_SETTINGS_LOGO_UPLOADED, - SYSTEM_SETTINGS_AUTH_UPDATED, + PLATFORM_SETTINGS_GENERAL_UPDATED, + PLATFORM_SETTINGS_WORKSPACE_POLICY_UPDATED, + PLATFORM_SETTINGS_COUNTRY_POLICY_UPDATED, + PLATFORM_SETTINGS_CURRENCY_POLICY_UPDATED, + PLATFORM_SETTINGS_GLOBALIZATION_POLICY_UPDATED, + PLATFORM_SETTINGS_LOGO_URL_UPDATED, + PLATFORM_SETTINGS_LOGO_UPLOADED, + PLATFORM_SETTINGS_AUTH_UPDATED, } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt b/backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt index 1b0908220..486c0c732 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt @@ -1,9 +1,10 @@ package io.deck.iam.registry +import io.deck.iam.ManagementType import io.deck.iam.api.ProgramDefinition import io.deck.iam.api.ProgramRegistrar +import io.deck.iam.api.consoleProgramPath import io.deck.iam.domain.MenuEntity -import io.deck.iam.domain.WorkspaceManagedType import org.springframework.stereotype.Component @Component @@ -11,36 +12,44 @@ class IamProgramRegistrar : ProgramRegistrar { override fun programs() = listOf( ProgramDefinition(MenuEntity.NONE_PROGRAM_CODE, ""), - ProgramDefinition("DASHBOARD", "/dashboard"), - ProgramDefinition("MENU_MANAGEMENT", "/system/menus", setOf("MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE")), - ProgramDefinition("USER_MANAGEMENT", "/system/users", setOf("USER_MANAGEMENT_READ", "USER_MANAGEMENT_WRITE")), - ProgramDefinition("ERROR_LOG", "/system/error-logs", setOf("ERROR_LOG_READ", "ERROR_LOG_WRITE")), - ProgramDefinition("API_AUDIT_LOG", "/system/api-audit-logs", setOf("API_AUDIT_LOG_READ", "API_AUDIT_LOG_WRITE")), - ProgramDefinition("ACTIVITY_LOG", "/system/activity-logs", setOf("ACTIVITY_LOG_READ")), + ProgramDefinition("DASHBOARD", consoleProgramPath("/dashboard")), ProgramDefinition( - "CODEBOOK_MANAGEMENT", - "/system/codebook", - setOf("CODEBOOK_MANAGEMENT_READ", "CODEBOOK_MANAGEMENT_WRITE"), + "MENU_MANAGEMENT", + "/settings/platform/menus", + setOf("MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE"), ), + ProgramDefinition( + "USER_MANAGEMENT", + consoleProgramPath("/users"), + setOf("USER_MANAGEMENT_READ", "USER_MANAGEMENT_WRITE"), + ), + ProgramDefinition( + "ERROR_LOG", + consoleProgramPath("/error-logs"), + setOf("ERROR_LOG_READ", "ERROR_LOG_WRITE"), + ), + ProgramDefinition( + "API_AUDIT_LOG", + consoleProgramPath("/api-audit-logs"), + setOf("API_AUDIT_LOG_READ", "API_AUDIT_LOG_WRITE"), + ), + ProgramDefinition("ACTIVITY_LOG", consoleProgramPath("/activity-logs"), setOf("ACTIVITY_LOG_READ")), + ProgramDefinition("LOGIN_HISTORY", consoleProgramPath("/login-history"), setOf("LOGIN_HISTORY_READ")), ProgramDefinition( "WORKSPACE_MANAGEMENT", - "/system/workspaces", + consoleProgramPath("/workspaces"), setOf("WORKSPACE_MANAGEMENT_READ", "WORKSPACE_MANAGEMENT_WRITE"), workspace = ProgramDefinition.WorkspacePolicy( required = true, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, + requiredManagedType = ManagementType.PLATFORM_MANAGED, ), ), ProgramDefinition( "MY_WORKSPACE", - "/my-workspaces", + consoleProgramPath("/my-workspaces"), setOf("MY_WORKSPACE_READ", "MY_WORKSPACE_WRITE"), - workspace = - ProgramDefinition.WorkspacePolicy( - required = true, - managedType = null, - ), + workspace = ProgramDefinition.WorkspacePolicy(required = true), ), ) } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/repository/PlatformSettingRepository.kt b/backend/iam/src/main/kotlin/io/deck/iam/repository/PlatformSettingRepository.kt new file mode 100644 index 000000000..559ea514b --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/repository/PlatformSettingRepository.kt @@ -0,0 +1,18 @@ +package io.deck.iam.repository + +import io.deck.iam.domain.PlatformSettingEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import java.util.UUID + +interface PlatformSettingRepository : JpaRepository { + /** + * 플랫폼 설정 조회 (단일 레코드) + */ + @Query( + """ + SELECT s FROM PlatformSettingEntity s ORDER BY s.createdAt LIMIT 1 + """, + ) + fun findFirst(): PlatformSettingEntity? +} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/repository/SystemSettingRepository.kt b/backend/iam/src/main/kotlin/io/deck/iam/repository/SystemSettingRepository.kt deleted file mode 100644 index 7f29a0b71..000000000 --- a/backend/iam/src/main/kotlin/io/deck/iam/repository/SystemSettingRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.deck.iam.repository - -import io.deck.iam.domain.SystemSettingEntity -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import java.util.UUID - -interface SystemSettingRepository : JpaRepository { - /** - * 시스템 설정 조회 (단일 레코드) - */ - @Query( - """ - SELECT s FROM SystemSettingEntity s ORDER BY s.createdAt LIMIT 1 - """, - ) - fun findFirst(): SystemSettingEntity? -} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceMutationJdbcRepository.kt b/backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceMutationJdbcRepository.kt new file mode 100644 index 000000000..f8d1df550 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceMutationJdbcRepository.kt @@ -0,0 +1,119 @@ +package io.deck.iam.repository + +import io.deck.iam.domain.ExternalReference +import org.springframework.dao.EmptyResultDataAccessException +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.time.Instant +import java.util.UUID + +@Repository +class WorkspaceMutationJdbcRepository( + private val jdbcTemplate: JdbcTemplate, +) { + fun upsertExternalWorkspace( + reference: ExternalReference, + name: String, + description: String?, + ): UUID = + requireNotNull( + queryForUuid( + UPSERT_EXTERNAL_WORKSPACE_SQL, + name, + description, + reference.source.name, + reference.externalId, + ), + ) { + "External workspace upsert did not return an id for ${reference.source}:${reference.externalId}" + } + + fun insertWorkspaceMemberIfAbsent( + workspaceId: UUID, + userId: UUID, + isOwner: Boolean = false, + ): UUID? = queryForUuid(INSERT_WORKSPACE_MEMBER_IF_ABSENT_SQL, workspaceId, userId, isOwner) + + fun insertPendingInviteIfAbsent( + inviteId: UUID, + workspaceId: UUID, + email: String, + tokenHash: String, + expiresAt: Instant, + message: String?, + inviterId: UUID, + ): Boolean = + queryForUuid( + INSERT_PENDING_INVITE_IF_ABSENT_SQL, + inviteId, + workspaceId, + email, + tokenHash, + expiresAt, + message, + inviterId, + inviterId, + ) != null + + private fun queryForUuid( + sql: String, + vararg args: Any?, + ): UUID? = + try { + jdbcTemplate.queryForObject(sql, UUID::class.java, *args) + } catch (_: EmptyResultDataAccessException) { + null + } + + companion object { + private val UPSERT_EXTERNAL_WORKSPACE_SQL = + """ + INSERT INTO workspaces ( + name, + description, + auto_join_domains, + managed_type, + external_source, + external_id + ) VALUES (?, ?, '[]'::jsonb, 'PLATFORM_MANAGED', ?, ?) + ON CONFLICT (external_source, external_id) + WHERE external_source IS NOT NULL AND external_id IS NOT NULL AND deleted_at IS NULL + DO UPDATE SET + name = EXCLUDED.name, + description = COALESCE(EXCLUDED.description, workspaces.description), + managed_type = 'PLATFORM_MANAGED', + updated_at = NOW() + RETURNING id + """.trimIndent() + + private val INSERT_WORKSPACE_MEMBER_IF_ABSENT_SQL = + """ + INSERT INTO workspace_members ( + workspace_id, + user_id, + is_owner + ) VALUES (?, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id + """.trimIndent() + + private val INSERT_PENDING_INVITE_IF_ABSENT_SQL = + """ + INSERT INTO workspace_invites ( + id, + workspace_id, + email, + token_hash, + status, + expires_at, + message, + inviter_id, + version, + created_by, + updated_by + ) VALUES (?, ?, ?, ?, 'PENDING', ?, ?, ?, 0, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id + """.trimIndent() + } +} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt b/backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt index b6ccc5fca..0282a4a80 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt @@ -1,11 +1,17 @@ package io.deck.iam.repository +import io.deck.iam.domain.ExternalSource import io.deck.iam.domain.WorkspaceEntity import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import java.util.UUID interface WorkspaceRepository : JpaRepository { + fun findByExternalReferenceSourceAndExternalReferenceExternalId( + source: ExternalSource, + externalId: String, + ): WorkspaceEntity? + @Query( """ SELECT w FROM WorkspaceEntity w @@ -33,7 +39,7 @@ interface WorkspaceRepository : JpaRepository { FROM workspaces w WHERE EXISTS ( SELECT 1 - FROM jsonb_array_elements_text(w.allowed_domains) AS d(domain) + FROM jsonb_array_elements_text(w.auto_join_domains) AS d(domain) WHERE lower(d.domain) = lower(:domain) ) """, diff --git a/backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2AuthenticationSuccessHandler.kt b/backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2AuthenticationSuccessHandler.kt index 75a796316..8e0c5f5aa 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2AuthenticationSuccessHandler.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2AuthenticationSuccessHandler.kt @@ -46,10 +46,10 @@ class OAuth2AuthenticationSuccessHandler( @Value("\${app.oauth2.default-redirect-uri:/}") private lateinit var defaultRedirectUri: String - @Value("\${app.oauth2.link-success-uri:/account/profile?linked=true}") + @Value("\${app.oauth2.link-success-uri:/settings/account/profile?linked=true}") private lateinit var linkSuccessUri: String - @Value("\${app.oauth2.link-error-uri:/account/profile?error=}") + @Value("\${app.oauth2.link-error-uri:/settings/account/profile?error=}") private lateinit var linkErrorUri: String override fun onAuthenticationSuccess( @@ -168,6 +168,7 @@ class OAuth2AuthenticationSuccessHandler( name = userInfo.name, ipAddress = ipAddress, userAgent = userAgent, + externalOrganizationSync = userInfo.externalOrganizationSync, ) ) { is AuthResult.Failure -> { @@ -244,6 +245,7 @@ class OAuth2AuthenticationSuccessHandler( "auth0" -> AuthProvider.AUTH0 "microsoft" -> AuthProvider.MICROSOFT "microsoft_calendar" -> AuthProvider.MICROSOFT + "aip" -> AuthProvider.AIP else -> throw IllegalStateException("Unknown OAuth2 provider: $registrationId") } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2UserInfoExtractor.kt b/backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2UserInfoExtractor.kt index 8f6d28d8d..7a18745a0 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2UserInfoExtractor.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2UserInfoExtractor.kt @@ -1,6 +1,9 @@ package io.deck.iam.security import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalSource import org.slf4j.LoggerFactory import org.springframework.security.oauth2.core.oidc.user.OidcUser import org.springframework.security.oauth2.core.user.OAuth2User @@ -12,6 +15,7 @@ data class OAuth2UserInfo( val email: String, val sub: String, val name: String, + val externalOrganizationSync: ExternalOrganizationSync = ExternalOrganizationSync.NoSync, ) /** @@ -29,7 +33,7 @@ sealed interface OAuth2UserInfoExtractor { AuthProvider.OKTA -> OidcExtractor("Okta") AuthProvider.AUTH0 -> OidcExtractor("Auth0") AuthProvider.MICROSOFT -> OidcExtractor("Microsoft") - AuthProvider.AIP -> OidcExtractor("AIP") + AuthProvider.AIP -> AipExtractor AuthProvider.INTERNAL -> throw IllegalStateException("INTERNAL provider is not supported for OAuth2") } } @@ -113,3 +117,100 @@ private data class OidcExtractor( return OAuth2UserInfo(email, sub, name) } } + +private data object AipExtractor : OAuth2UserInfoExtractor { + override fun extract(oauth2User: OAuth2User): OAuth2UserInfo { + val oidcUser = oauth2User as OidcUser + val email = + oidcUser.email + ?: throw IllegalStateException("Email not found from AIP") + val sub = oidcUser.subject + val name = oidcUser.fullName ?: oidcUser.preferredUsername ?: email + val externalOrganizationSync = extractExternalOrganizationSync(oidcUser) + return OAuth2UserInfo(email, sub, name, externalOrganizationSync) + } + + private fun extractExternalOrganizationSync(oidcUser: OidcUser): ExternalOrganizationSync { + val claimValues = + sequenceOf("organizations", "orgs") + .mapNotNull { claimName -> oidcUser.claims[claimName] } + .toList() + + if (claimValues.isEmpty()) { + return ExternalOrganizationSync.Unavailable + } + + val parsedClaims = + claimValues + .mapNotNull(::claimsToOrganizations) + val flattenedOrganizations = + parsedClaims + .flatMap(ParsedOrganizations::organizations) + .distinctBy { it.externalId } + + val hadOnlyInvalidEntries = parsedClaims.any(ParsedOrganizations::hadEntries) && flattenedOrganizations.isEmpty() + if (parsedClaims.isEmpty()) { + return ExternalOrganizationSync.Unavailable + } + if (hadOnlyInvalidEntries) { + return ExternalOrganizationSync.Unavailable + } + + return ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = flattenedOrganizations, + ) + } + + private fun claimsToOrganizations(value: Any): ParsedOrganizations? = + when (value) { + is Collection<*> -> { + ParsedOrganizations( + organizations = value.mapNotNull(::toOrganizationClaim), + hadEntries = value.isNotEmpty(), + ) + } + + is Array<*> -> { + ParsedOrganizations( + organizations = value.mapNotNull(::toOrganizationClaim), + hadEntries = value.isNotEmpty(), + ) + } + + else -> { + null + } + } + + private fun toOrganizationClaim(value: Any?): ExternalOrganizationClaim? = + when (value) { + is String -> { + value.trim().takeIf(String::isNotBlank)?.let(::ExternalOrganizationClaim) + } + + is Map<*, *> -> { + val externalId = + sequenceOf("id", "externalId", "organizationId", "orgId") + .mapNotNull { key -> value[key]?.toString()?.trim() } + .firstOrNull(String::isNotBlank) + ?: return null + val name = value["name"]?.toString()?.trim()?.ifBlank { null } + val description = value["description"]?.toString()?.trim()?.ifBlank { null } + ExternalOrganizationClaim( + externalId = externalId, + name = name, + description = description, + ) + } + + else -> { + null + } + } +} + +private data class ParsedOrganizations( + val organizations: List, + val hadEntries: Boolean, +) diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/AuthService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/AuthService.kt index bcf556e29..608b3e424 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/AuthService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/AuthService.kt @@ -4,6 +4,8 @@ import io.deck.crypto.api.jwt.JwtService import io.deck.crypto.api.jwt.TokenResult import io.deck.iam.config.JwtProperties import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync import io.deck.iam.domain.SessionType import io.deck.iam.domain.UserEntity import io.deck.iam.domain.UserId @@ -29,6 +31,7 @@ data class RefreshTokenResult( @Service class AuthService( private val userService: UserService, + private val oAuthLoginProvisioningService: OAuthLoginProvisioningService, private val identityService: IdentityService, private val loginHistoryService: LoginHistoryService, private val sessionService: SessionService, @@ -386,39 +389,33 @@ class AuthService( name: String, ipAddress: String, userAgent: String?, + externalOrganizationSync: ExternalOrganizationSync = ExternalOrganizationSync.NoSync, ): AuthResult { - // 1. 사용자 조회 또는 생성 - val user = userService.findOrCreateByOAuth(provider, providerUserId, email, name) + val provisioningResult = + oAuthLoginProvisioningService.resolveUser( + provider = provider, + providerUserId = providerUserId, + email = email, + name = name, + externalOrganizationSync = externalOrganizationSync, + ) + val user = provisioningResult.user - // 2. 사용자 상태 검증 - val statusError = getOAuthStatusError(user) + val statusError = provisioningResult.statusError if (statusError != null) { - publishLoginFailure(email, statusError.first, ipAddress, userAgent, user) - return AuthResult.Failure(statusError.second, statusError.third) + publishLoginFailure(email, statusError.failReason, ipAddress, userAgent, user) + return AuthResult.Failure(statusError.errorType, statusError.message) } - // 3. JWT 토큰 생성 + 세션 생성 + // 2. JWT 토큰 생성 + 세션 생성 val tokenResult = createTokenWithSession(user, SessionType.WEB, ipAddress, userAgent) - // 4. 로그인 성공 이벤트 발행 (BEFORE_COMMIT에서 로그인 기록 저장) + // 3. 로그인 성공 이벤트 발행 (BEFORE_COMMIT에서 로그인 기록 저장) publishLoginSuccess(user, email, ipAddress, userAgent) return AuthResult.Success(user, tokenResult.token, tokenResult.jti) } - /** - * OAuth 사용자 상태 검증 - * @return null이면 정상, 아니면 (failReason, errorType, message) 트리플 - */ - private fun getOAuthStatusError(user: UserEntity): Triple? = - when { - user.isDeleted -> Triple("ACCOUNT_DELETED", AuthErrorType.ACCOUNT_INACTIVE, "Account is deleted") - user.status == UserStatus.LOCKED -> Triple("ACCOUNT_LOCKED", AuthErrorType.ACCOUNT_LOCKED, "Account is locked") - user.status == UserStatus.DORMANT -> Triple("ACCOUNT_DORMANT", AuthErrorType.ACCOUNT_INACTIVE, "Account is dormant") - user.status != UserStatus.ACTIVE -> Triple(user.status.name, AuthErrorType.ACCOUNT_INACTIVE, "Account is ${user.status.name.lowercase()}") - else -> null - } - companion object { private const val SESSION_ID_CLAIM = "session_id" private const val WEB_CLIENT_ID = "web" diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/DevSeedUserManagerImpl.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/DevSeedUserManagerImpl.kt index c22499208..c9b524378 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/DevSeedUserManagerImpl.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/DevSeedUserManagerImpl.kt @@ -32,6 +32,7 @@ class DevSeedUserManagerImpl( userRepository.findByEmail(email)?.let { SeedUserSummary( id = it.id, + name = it.name, email = it.email, passwordMustChange = it.passwordMustChange, ) @@ -49,6 +50,19 @@ class DevSeedUserManagerImpl( userRepository.save(user) } + @Transactional + override fun updateUserName( + userId: UUID, + name: String, + ) { + val user = userRepository.findById(userId).orElseThrow() + if (user.name == name) { + return + } + user.updateProfile(name) + userRepository.save(user) + } + @Transactional override fun createSeedUser( username: String, diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalOrganizationFlow.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalOrganizationFlow.kt new file mode 100644 index 000000000..541521018 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalOrganizationFlow.kt @@ -0,0 +1,66 @@ +package io.deck.iam.service + +import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalSource +import io.deck.iam.domain.WorkspacePolicy + +internal object ExternalOrganizationFlow { + fun authoritativeSnapshotFor( + provider: AuthProvider, + sync: ExternalOrganizationSync, + ): ExternalOrganizationSync.AuthoritativeSnapshot? { + val expectedSource = sourceFor(provider) ?: return null + val snapshot = sync as? ExternalOrganizationSync.AuthoritativeSnapshot ?: return null + return snapshot.takeIf { it.source == expectedSource } + } + + fun isEnabled( + workspacePolicy: WorkspacePolicy?, + source: ExternalSource, + ): Boolean = + when (source) { + ExternalSource.AIP -> workspacePolicy?.useExternalSync == true + } + + fun approvalReason(source: ExternalSource): String = + when (source) { + ExternalSource.AIP -> "AIP_EXTERNAL_ORGANIZATION" + } + + fun normalizeOrganizations( + source: ExternalSource, + organizations: List, + ): List = + when (source) { + ExternalSource.AIP -> organizations.normalizeStandardClaims() + } + + fun normalizeOrganizationsOrEmpty( + source: ExternalSource?, + organizations: List, + ): List = source?.let { normalizeOrganizations(it, organizations) } ?: emptyList() + + private fun sourceFor(provider: AuthProvider): ExternalSource? = + when (provider) { + AuthProvider.AIP -> ExternalSource.AIP + else -> null + } +} + +private fun List.normalizeStandardClaims(): List = + asSequence() + .mapNotNull { organization -> + val externalId = organization.externalId.trim() + if (externalId.isBlank()) { + null + } else { + ExternalOrganizationClaim( + externalId = externalId, + name = organization.name?.trim()?.ifBlank { null }, + description = organization.description?.trim()?.ifBlank { null }, + ) + } + }.distinctBy(ExternalOrganizationClaim::externalId) + .toList() diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt new file mode 100644 index 000000000..6ed2af24c --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt @@ -0,0 +1,158 @@ +package io.deck.iam.service + +import io.deck.common.api.id.SYSTEM_ACTOR_ID +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalReference +import io.deck.iam.domain.ExternalSource +import io.deck.iam.domain.UserEntity +import io.deck.iam.domain.UserId +import io.deck.iam.domain.WorkspaceEntity +import io.deck.iam.domain.WorkspaceMemberEntity +import io.deck.iam.event.WorkspaceMemberAddedEvent +import io.deck.iam.event.WorkspaceMemberRemovedEvent +import io.deck.iam.repository.WorkspaceMemberRepository +import io.deck.iam.repository.WorkspaceMutationJdbcRepository +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +enum class ExternalWorkspaceSyncMode { + AUTHORITATIVE_FULL_SNAPSHOT, +} + +data class ExternalWorkspaceSyncResult( + val workspaces: List, + val addedWorkspaces: List = emptyList(), + val removedWorkspaceIds: Set = emptySet(), +) + +/** + * v1 authoritative external workspace sync는 AIP 단일 source만 다룬다. + * + * 다른 external source가 추가되면 lookup key와 reconciliation scope를 이 경계에서 확장한다. + */ +@Service +@Transactional(readOnly = true) +class ExternalWorkspaceSyncService( + private val workspaceService: WorkspaceService, + private val workspaceMemberRepository: WorkspaceMemberRepository, + private val eventPublisher: ApplicationEventPublisher, + private val workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository, +) { + @Transactional + fun syncForUser( + user: UserEntity, + externalOrganizationSnapshot: ExternalOrganizationSync.AuthoritativeSnapshot, + enabled: Boolean, + reason: String, + mode: ExternalWorkspaceSyncMode = ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, + ): ExternalWorkspaceSyncResult { + if (!enabled) { + return ExternalWorkspaceSyncResult(workspaces = emptyList()) + } + + val externalSource = externalOrganizationSnapshot.source + val externalOrganizations = + ExternalOrganizationFlow.normalizeOrganizations( + externalSource, + externalOrganizationSnapshot.organizations, + ) + val desiredWorkspaces = externalOrganizations.map { organization -> upsertWorkspace(externalSource, organization) } + val membershipDelta = + if (mode == ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT) { + reconcileMemberships(user.id, externalSource, desiredWorkspaces, reason) + } else { + ExternalWorkspaceMembershipDelta() + } + val desiredWorkspaceById = desiredWorkspaces.associateBy(WorkspaceEntity::id) + + return ExternalWorkspaceSyncResult( + workspaces = desiredWorkspaces, + addedWorkspaces = membershipDelta.addedWorkspaceIds.mapNotNull(desiredWorkspaceById::get), + removedWorkspaceIds = membershipDelta.removedWorkspaceIds, + ) + } + + private fun upsertWorkspace( + source: ExternalSource, + organization: ExternalOrganizationClaim, + ): WorkspaceEntity = + workspaceService.upsertExternalWorkspace( + reference = ExternalReference(source = source, externalId = organization.externalId), + name = organization.name, + description = organization.description, + ) + + private fun reconcileMemberships( + userId: java.util.UUID, + source: ExternalSource, + desiredWorkspaces: List, + reason: String, + ): ExternalWorkspaceMembershipDelta { + val desiredWorkspaceIds = desiredWorkspaces.map { it.id }.toSet() + val currentExternalWorkspaces = + workspaceService + .findByUser(userId) + .filter { workspace -> isManagedByCurrentExternalSource(workspace, source) } + val removedWorkspaceIds = mutableSetOf() + val addedWorkspaceIds = mutableSetOf() + + currentExternalWorkspaces + .filter { it.id !in desiredWorkspaceIds } + .forEach { workspace -> + workspaceMemberRepository.findByWorkspaceIdAndUserId(workspace.id, userId)?.let { membership -> + workspaceMemberRepository.delete(membership) + removedWorkspaceIds += workspace.id + eventPublisher.publishEvent( + WorkspaceMemberRemovedEvent( + workspaceId = workspace.id, + memberId = UserId(userId), + removedBy = UserId(SYSTEM_ACTOR_ID), + ), + ) + } + } + + desiredWorkspaces.forEach { workspace -> + if (ensureExternalMembership(workspace.id, userId)) { + addedWorkspaceIds += workspace.id + eventPublisher.publishEvent( + WorkspaceMemberAddedEvent( + workspaceId = workspace.id, + memberId = UserId(userId), + addedBy = UserId(SYSTEM_ACTOR_ID), + reason = reason, + ), + ) + } + } + + return ExternalWorkspaceMembershipDelta( + addedWorkspaceIds = addedWorkspaceIds, + removedWorkspaceIds = removedWorkspaceIds, + ) + } + + private fun ensureExternalMembership( + workspaceId: UUID, + userId: UUID, + ): Boolean { + if (workspaceMemberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId)) { + return false + } + + return workspaceMutationJdbcRepository.insertWorkspaceMemberIfAbsent(workspaceId, userId) != null + } +} + +private data class ExternalWorkspaceMembershipDelta( + val addedWorkspaceIds: Set = emptySet(), + val removedWorkspaceIds: Set = emptySet(), +) + +private fun isManagedByCurrentExternalSource( + workspace: WorkspaceEntity, + source: ExternalSource, +): Boolean = workspace.externalReference?.source == source diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/InviteService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/InviteService.kt index b677345b4..b6e89f5a4 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/InviteService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/InviteService.kt @@ -82,7 +82,7 @@ class InviteService( email = email, token = rawToken, message = message, - inviterName = inviter?.name ?: UserEntity.SYSTEM_NAME, + inviterName = inviter?.name ?: UserEntity.PLATFORM_NAME, inviteId = saved.id, invitedByUserId = createdBy.value, ), @@ -142,7 +142,7 @@ class InviteService( email = invite.email, token = rawToken, message = invite.message, - inviterName = resender?.name ?: "System", + inviterName = resender?.name ?: UserEntity.PLATFORM_NAME, inviteId = invite.id, invitedByUserId = resendBy.value, ), diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/MenuSeedCommandImpl.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/MenuSeedCommandImpl.kt index 02389c947..4a347eb5c 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/MenuSeedCommandImpl.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/MenuSeedCommandImpl.kt @@ -1,5 +1,6 @@ package io.deck.iam.service +import io.deck.iam.ManagementType import io.deck.iam.api.MenuSeedCommand import io.deck.iam.api.SeedMenuDefinition import io.deck.iam.domain.MenuEntity @@ -69,6 +70,7 @@ class MenuSeedCommandImpl( namesI18n = seedMenu.namesI18n, icon = seedMenu.icon, programType = seedMenu.programType, + managementType = seedMenu.managementType.toDomain(), sortOrder = nextSortOrder, permissions = seedMenu.permissions, ), @@ -86,6 +88,7 @@ class MenuSeedCommandImpl( namesI18n = child.namesI18n, icon = child.icon, programType = child.programType, + managementType = child.managementType.toDomain(), sortOrder = index, permissions = child.permissions, parent = savedParent, @@ -159,6 +162,7 @@ class MenuSeedCommandImpl( namesI18n = seedMenu.namesI18n, icon = seedMenu.icon, programType = seedMenu.programType, + managementType = seedMenu.managementType.toDomain(), sortOrder = nextSortOrder, permissions = seedMenu.permissions, ), @@ -179,6 +183,7 @@ class MenuSeedCommandImpl( namesI18n = child.namesI18n, icon = child.icon, programType = child.programType, + managementType = child.managementType.toDomain(), sortOrder = index, permissions = child.permissions, parent = savedRoot, @@ -204,6 +209,7 @@ class MenuSeedCommandImpl( if ( menu.icon != definition.icon || menu.programType != definition.programType || + menu.managementType != definition.managementType.toDomain() || menu.permissions != mergedPermissions || menu.sortOrder != desiredSortOrder || menu.namesI18n != definition.namesI18n @@ -213,6 +219,7 @@ class MenuSeedCommandImpl( namesI18n = definition.namesI18n, icon = definition.icon, programType = definition.programType, + managementType = definition.managementType.toDomain(), permissions = mergedPermissions, ) if (menu.sortOrder != desiredSortOrder) { @@ -224,3 +231,5 @@ class MenuSeedCommandImpl( return menu } } + +private fun ManagementType.toDomain(): ManagementType = this diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/MenuService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/MenuService.kt index b61b72058..23f960535 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/MenuService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/MenuService.kt @@ -5,6 +5,7 @@ import io.deck.common.api.event.ActivityTargetType import io.deck.common.api.event.currentUserActivityEvent import io.deck.common.api.exception.BadRequestException import io.deck.common.api.exception.NotFoundException +import io.deck.iam.ManagementType import io.deck.iam.domain.LocaleType import io.deck.iam.domain.MenuEntity import io.deck.iam.domain.RoleEntity @@ -61,6 +62,7 @@ class MenuService( namesI18n: Map? = null, icon: String? = null, programType: String, + managementType: ManagementType = ManagementType.USER_MANAGED, ): MenuEntity { validateNamesI18n(namesI18n) val sortOrder = (menuRepository.findMaxSortOrderByRoleIdForRoot(roleId) ?: -1) + 1 @@ -72,6 +74,7 @@ class MenuService( namesI18n = namesI18n, icon = icon, programType = programType, + managementType = managementType, sortOrder = sortOrder, ) val saved = menuRepository.save(menu) @@ -96,6 +99,7 @@ class MenuService( namesI18n: Map? = null, icon: String? = null, programType: String, + managementType: ManagementType = ManagementType.USER_MANAGED, ): MenuEntity { validateNamesI18n(namesI18n) val parent = @@ -112,6 +116,7 @@ class MenuService( namesI18n = namesI18n, icon = icon, programType = programType, + managementType = managementType, parent = parent, sortOrder = sortOrder, ) @@ -137,6 +142,7 @@ class MenuService( namesI18n: Map? = null, icon: String?, programType: String, + managementType: ManagementType? = null, permissions: Set, ): MenuEntity { validateNamesI18n(namesI18n) @@ -150,6 +156,7 @@ class MenuService( namesI18n = namesI18n, icon = icon, programType = programType, + managementType = managementType ?: menu.managementType, permissions = permissions, ) @@ -303,6 +310,7 @@ class MenuService( namesI18n = source.namesI18n, icon = source.icon, programType = source.programType, + managementType = source.managementType, permissions = source.permissions.toMutableSet(), parent = parent, sortOrder = sortOrder, diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthLoginProvisioningService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthLoginProvisioningService.kt new file mode 100644 index 000000000..cb5164a20 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthLoginProvisioningService.kt @@ -0,0 +1,82 @@ +package io.deck.iam.service + +import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.UserEntity +import io.deck.iam.domain.UserStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +data class OAuthLoginStatusError( + val failReason: String, + val errorType: AuthErrorType, + val message: String, +) + +data class OAuthLoginProvisioningResult( + val user: UserEntity, + val statusError: OAuthLoginStatusError? = null, +) + +@Service +class OAuthLoginProvisioningService( + private val userService: UserService, +) { + @Transactional + fun resolveUser( + provider: AuthProvider, + providerUserId: String, + email: String, + name: String, + externalOrganizationSync: ExternalOrganizationSync = ExternalOrganizationSync.NoSync, + ): OAuthLoginProvisioningResult { + val authoritativeExternalSync = ExternalOrganizationFlow.authoritativeSnapshotFor(provider, externalOrganizationSync) + val externalOrganizations = authoritativeExternalSync?.organizations.orEmpty() + val user = + userService.findOrCreateByOAuth( + provider = provider, + providerUserId = providerUserId, + email = email, + name = name, + externalOrganizations = externalOrganizations, + externalOrganizationSource = authoritativeExternalSync?.source, + ) + + val statusError = user.toOAuthLoginStatusError() + if (statusError == null && authoritativeExternalSync != null) { + userService.syncExternalOrganizationsForOAuthUser(user, authoritativeExternalSync) + } + + return OAuthLoginProvisioningResult( + user = user, + statusError = statusError, + ) + } + + private fun UserEntity.toOAuthLoginStatusError(): OAuthLoginStatusError? = + when { + isDeleted -> { + OAuthLoginStatusError("ACCOUNT_DELETED", AuthErrorType.ACCOUNT_INACTIVE, "Account is deleted") + } + + status == UserStatus.LOCKED -> { + OAuthLoginStatusError("ACCOUNT_LOCKED", AuthErrorType.ACCOUNT_LOCKED, "Account is locked") + } + + status == UserStatus.DORMANT -> { + OAuthLoginStatusError("ACCOUNT_DORMANT", AuthErrorType.ACCOUNT_INACTIVE, "Account is dormant") + } + + status != UserStatus.ACTIVE -> { + OAuthLoginStatusError( + status.name, + AuthErrorType.ACCOUNT_INACTIVE, + "Account is ${status.name.lowercase()}", + ) + } + + else -> { + null + } + } +} diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt index d7b7f5d73..a435cfa16 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt @@ -21,7 +21,7 @@ class OAuthProviderService( private val oauthProviderRepository: OAuthProviderRepository, private val userRepository: UserRepository, private val identityService: IdentityService, - private val systemSettingService: SystemSettingService, + private val platformSettingService: PlatformSettingService, private val eventPublisher: ApplicationEventPublisher, ) { companion object { @@ -156,7 +156,7 @@ class OAuthProviderService( } private fun validateOwnerCanLoginWithEntities(entities: List) { - val settings = systemSettingService.getSettings() + val settings = platformSettingService.getSettings() val owners = userRepository.findOwners() if (owners.isEmpty()) { diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt similarity index 87% rename from backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt rename to backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt index 128ea60fd..7f3818650 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt @@ -3,15 +3,15 @@ package io.deck.iam.service import io.deck.common.api.event.ActivityTargetType import io.deck.common.api.event.activityEvent import io.deck.common.api.exception.BadRequestException -import io.deck.iam.api.SystemSettingQuery +import io.deck.iam.api.PlatformSettingQuery import io.deck.iam.domain.AuthProvider import io.deck.iam.domain.CountryPolicy import io.deck.iam.domain.CurrencyPolicy import io.deck.iam.domain.LogoType -import io.deck.iam.domain.SystemSettingEntity +import io.deck.iam.domain.PlatformSettingEntity import io.deck.iam.domain.WorkspacePolicy import io.deck.iam.event.IamActivityLogType -import io.deck.iam.repository.SystemSettingRepository +import io.deck.iam.repository.PlatformSettingRepository import io.deck.iam.repository.UserRepository import org.springframework.cache.annotation.CacheEvict import org.springframework.cache.annotation.Cacheable @@ -25,15 +25,15 @@ import java.util.Locale import java.util.UUID @Service -class SystemSettingService( - private val systemSettingRepository: SystemSettingRepository, +class PlatformSettingService( + private val platformSettingRepository: PlatformSettingRepository, private val userRepository: UserRepository, private val identityService: IdentityService, private val ownerService: OwnerService, private val eventPublisher: ApplicationEventPublisher, -) : SystemSettingQuery { +) : PlatformSettingQuery { companion object { - const val CACHE_NAME = "systemSettings" + const val CACHE_NAME = "platformSettings" private val STANDARD_COUNTRY_CODES = Locale.getISOCountries().toSet() private val STANDARD_CURRENCY_CODES = @@ -206,19 +206,21 @@ class SystemSettingService( } /** - * 시스템 설정 조회 (단일 레코드, 캐시됨) + * 플랫폼 설정 조회 (단일 레코드, 캐시됨) * 캐시 미스 시 조회+생성이 필요하므로 @Transactional 유지 */ @Transactional @Cacheable(CACHE_NAME) - fun getSettings(): SystemSettingEntity = - systemSettingRepository.findFirst() - ?: systemSettingRepository.save(SystemSettingEntity()) + fun getSettings(): PlatformSettingEntity = + platformSettingRepository + .findFirst() + ?.apply { normalizeEmbeddedPolicies() } + ?: platformSettingRepository.save(PlatformSettingEntity()) override fun getDefaultCountryCode(): String = getSettings().countryPolicy.normalized().defaultCountryCode /** - * 시스템 설정 수정 (Owner만) + * 플랫폼 설정 수정 (Owner만) */ @Transactional @CacheEvict(CACHE_NAME, allEntries = true) @@ -226,13 +228,13 @@ class SystemSettingService( userId: UUID, brandName: String, contactEmail: String?, - ): SystemSettingEntity { + ): PlatformSettingEntity { requireOwner(userId) val settings = getSettings() settings.update(brandName = brandName, contactEmail = contactEmail) - val saved = systemSettingRepository.save(settings) + val saved = platformSettingRepository.save(settings) publishActivity( - eventType = IamActivityLogType.SYSTEM_SETTINGS_GENERAL_UPDATED, + eventType = IamActivityLogType.PLATFORM_SETTINGS_GENERAL_UPDATED, actorId = userId, targetId = saved.id.toString(), metadata = @@ -249,19 +251,20 @@ class SystemSettingService( fun updateWorkspacePolicy( userId: UUID, workspacePolicy: WorkspacePolicy?, - ): SystemSettingEntity { + ): PlatformSettingEntity { requireOwner(userId) val settings = getSettings() settings.updateWorkspacePolicy(workspacePolicy) - val saved = systemSettingRepository.save(settings) + val saved = platformSettingRepository.save(settings) publishActivity( - eventType = IamActivityLogType.SYSTEM_SETTINGS_WORKSPACE_POLICY_UPDATED, + eventType = IamActivityLogType.PLATFORM_SETTINGS_WORKSPACE_POLICY_UPDATED, actorId = userId, targetId = saved.id.toString(), metadata = mapOf( "useUserManaged" to saved.workspacePolicy?.useUserManaged, - "useSystemManaged" to saved.workspacePolicy?.useSystemManaged, + "usePlatformManaged" to saved.workspacePolicy?.usePlatformManaged, + "useExternalSync" to saved.workspacePolicy?.useExternalSync, "useSelector" to saved.workspacePolicy?.useSelector, ), ) @@ -273,14 +276,14 @@ class SystemSettingService( fun updateCountryPolicy( userId: UUID, countryPolicy: CountryPolicy, - ): SystemSettingEntity { + ): PlatformSettingEntity { requireOwner(userId) validateCountryPolicy(countryPolicy) val settings = getSettings() settings.updateCountryPolicy(countryPolicy) - val saved = systemSettingRepository.save(settings) + val saved = platformSettingRepository.save(settings) publishActivity( - eventType = IamActivityLogType.SYSTEM_SETTINGS_COUNTRY_POLICY_UPDATED, + eventType = IamActivityLogType.PLATFORM_SETTINGS_COUNTRY_POLICY_UPDATED, actorId = userId, targetId = saved.id.toString(), metadata = @@ -297,14 +300,14 @@ class SystemSettingService( fun updateCurrencyPolicy( userId: UUID, currencyPolicy: CurrencyPolicy, - ): SystemSettingEntity { + ): PlatformSettingEntity { requireOwner(userId) validateCurrencyPolicy(currencyPolicy) val settings = getSettings() settings.updateCurrencyPolicy(currencyPolicy) - val saved = systemSettingRepository.save(settings) + val saved = platformSettingRepository.save(settings) publishActivity( - eventType = IamActivityLogType.SYSTEM_SETTINGS_CURRENCY_POLICY_UPDATED, + eventType = IamActivityLogType.PLATFORM_SETTINGS_CURRENCY_POLICY_UPDATED, actorId = userId, targetId = saved.id.toString(), metadata = @@ -322,15 +325,15 @@ class SystemSettingService( userId: UUID, countryPolicy: CountryPolicy, currencyPolicy: CurrencyPolicy, - ): SystemSettingEntity { + ): PlatformSettingEntity { requireOwner(userId) validateCountryPolicy(countryPolicy) validateCurrencyPolicy(currencyPolicy) val settings = getSettings() settings.updateGlobalizationPolicy(countryPolicy, currencyPolicy) - val saved = systemSettingRepository.save(settings) + val saved = platformSettingRepository.save(settings) publishActivity( - eventType = IamActivityLogType.SYSTEM_SETTINGS_GLOBALIZATION_POLICY_UPDATED, + eventType = IamActivityLogType.PLATFORM_SETTINGS_GLOBALIZATION_POLICY_UPDATED, actorId = userId, targetId = saved.id.toString(), metadata = @@ -354,13 +357,13 @@ class SystemSettingService( type: LogoType, url: String?, dark: Boolean = false, - ): SystemSettingEntity { + ): PlatformSettingEntity { requireOwner(userId) val settings = getSettings() settings.setLogoUrl(type, url, dark) - val saved = systemSettingRepository.save(settings) + val saved = platformSettingRepository.save(settings) publishActivity( - eventType = IamActivityLogType.SYSTEM_SETTINGS_LOGO_URL_UPDATED, + eventType = IamActivityLogType.PLATFORM_SETTINGS_LOGO_URL_UPDATED, actorId = userId, targetId = saved.id.toString(), metadata = @@ -383,13 +386,13 @@ class SystemSettingService( type: LogoType, data: String?, dark: Boolean = false, - ): SystemSettingEntity { + ): PlatformSettingEntity { requireOwner(userId) val settings = getSettings() settings.setLogoData(type, data, dark) - val saved = systemSettingRepository.save(settings) + val saved = platformSettingRepository.save(settings) publishActivity( - eventType = IamActivityLogType.SYSTEM_SETTINGS_LOGO_UPLOADED, + eventType = IamActivityLogType.PLATFORM_SETTINGS_LOGO_UPLOADED, actorId = userId, targetId = saved.id.toString(), metadata = @@ -414,7 +417,7 @@ class SystemSettingService( /** * 인증 설정 조회 (Owner만) */ - fun getAuthSettings(userId: UUID): SystemSettingEntity { + fun getAuthSettings(userId: UUID): PlatformSettingEntity { requireOwner(userId) return getSettings() } @@ -435,7 +438,7 @@ class SystemSettingService( oktaDomain: String?, oktaClientId: String?, oktaClientSecret: String?, - ): SystemSettingEntity { + ): PlatformSettingEntity { requireOwner(userId) // 락아웃 방지: 활성화된 auth provider로 로그인 가능한 owner가 최소 1명 있어야 함 validateOwnerCanLogin(internalLoginEnabled, auth0Enabled, oktaEnabled) @@ -452,9 +455,9 @@ class SystemSettingService( oktaClientId = oktaClientId, oktaClientSecret = oktaClientSecret, ) - val saved = systemSettingRepository.save(settings) + val saved = platformSettingRepository.save(settings) publishActivity( - eventType = IamActivityLogType.SYSTEM_SETTINGS_AUTH_UPDATED, + eventType = IamActivityLogType.PLATFORM_SETTINGS_AUTH_UPDATED, actorId = userId, targetId = saved.id.toString(), metadata = @@ -567,7 +570,7 @@ class SystemSettingService( return registrations } - private fun buildAuth0Registration(settings: SystemSettingEntity): ClientRegistration = + private fun buildAuth0Registration(settings: PlatformSettingEntity): ClientRegistration = ClientRegistration .withRegistrationId("auth0") .clientId(settings.auth0ClientId ?: throw BadRequestException("iam.auth.auth0_client_id_not_configured")) @@ -583,7 +586,7 @@ class SystemSettingService( .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .build() - private fun buildOktaRegistration(settings: SystemSettingEntity): ClientRegistration = + private fun buildOktaRegistration(settings: PlatformSettingEntity): ClientRegistration = ClientRegistration .withRegistrationId("okta") .clientId(settings.oktaClientId ?: throw BadRequestException("iam.auth.okta_client_id_not_configured")) @@ -609,7 +612,7 @@ class SystemSettingService( activityEvent( type = eventType, actorId = actorId, - targetType = ActivityTargetType.SYSTEM_SETTING, + targetType = ActivityTargetType.PLATFORM_SETTING, targetId = targetId, metadata = metadata, ), diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt index 27c4bf48c..a526da2b9 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt @@ -9,9 +9,13 @@ import io.deck.common.api.id.SYSTEM_ACTOR_ID import io.deck.common.api.meta.enumByCodeOrNull import io.deck.common.api.validation.PasswordRuleResult import io.deck.globalization.contactfield.api.ContactPhoneNumberNormalizer +import io.deck.iam.ManagementType import io.deck.iam.api.event.UserEventType import io.deck.iam.domain.AuthProvider import io.deck.iam.domain.DateFormatType +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalSource import io.deck.iam.domain.LocaleType import io.deck.iam.domain.TimezoneType import io.deck.iam.domain.UserEntity @@ -19,6 +23,7 @@ import io.deck.iam.domain.UserId import io.deck.iam.domain.UserStatus import io.deck.iam.domain.WorkspaceEntity import io.deck.iam.domain.WorkspaceMemberEntity +import io.deck.iam.domain.WorkspacePolicy import io.deck.iam.event.RoleInfo import io.deck.iam.event.UserActivityEvent import io.deck.iam.event.UserWithdrawnEvent @@ -65,11 +70,13 @@ class UserService( private val eventPublisher: ApplicationEventPublisher, private val channelAvailability: ChannelAvailability, private val workspaceMemberRepository: WorkspaceMemberRepository, + private val workspaceMemberService: WorkspaceMemberService, private val workspaceRepository: WorkspaceRepository, - private val systemSettingService: SystemSettingService, + private val platformSettingService: PlatformSettingService, private val contactPhoneNumberNormalizer: ContactPhoneNumberNormalizer, private val partyCommand: PartyCommand, private val partyQuery: PartyQuery, + private val externalWorkspaceSyncService: ExternalWorkspaceSyncService, ) { fun findById(id: UUID): UserEntity? = userRepository.findById(id).orElse(null) @@ -209,22 +216,40 @@ class UserService( * 주의: 호출 전에 validateOAuthLogin()으로 유효성 검사 필요 */ @Transactional - fun findOrCreateByOAuth( + internal fun findOrCreateByOAuth( provider: AuthProvider, providerUserId: String, email: String, name: String, + externalOrganizations: List = emptyList(), + externalOrganizationSource: ExternalSource? = null, ): UserEntity { + val normalizedExternalOrganizations = + ExternalOrganizationFlow.normalizeOrganizationsOrEmpty( + externalOrganizationSource, + externalOrganizations, + ) + val externalOrganizationFlowEnabled = isExternalOrganizationFlowEnabled(externalOrganizationSource) + val autoJoinMatches = resolveAutoJoinWorkspaceMatches(email) + // 1. 기존 Identity 확인 val existingIdentity = identityService.findByProviderAndProviderUserId(provider, providerUserId) if (existingIdentity != null) { - // 기존 사용자 반환 (사용자 정보는 유지, Primary 변경 시에만 email 동기화) - return existingIdentity.user + return normalizeExistingOAuthUser( + user = existingIdentity.user, + externalOrganizationSource = externalOrganizationSource, + externalOrganizations = normalizedExternalOrganizations, + externalWorkspaceFlowEnabled = externalOrganizationFlowEnabled, + ) } - val allowedDomain = extractEmailDomain(email) - val matchedWorkspaces = allowedDomain?.let(workspaceRepository::findAllByAllowedDomain).orEmpty() - val autoApproved = matchedWorkspaces.isNotEmpty() + val autoApprovedByExternalSync = + shouldAutoApproveByExternalSync( + externalOrganizationSource = externalOrganizationSource, + externalOrganizations = normalizedExternalOrganizations, + externalWorkspaceFlowEnabled = externalOrganizationFlowEnabled, + ) + val autoApproved = autoJoinMatches.workspaces.isNotEmpty() || autoApprovedByExternalSync // 2. 새 사용자 생성 val user = @@ -247,10 +272,12 @@ class UserService( // 3. OAuth Identity 생성 identityService.createOAuthIdentity(savedUser, provider, providerUserId, email, isPrimary = true) - if (autoApproved) { - publishAutoApprovedEvent(savedUser, allowedDomain.orEmpty(), matchedWorkspaces) - addUserToMatchedWorkspaces(savedUser.id, allowedDomain.orEmpty(), matchedWorkspaces) - } else { + if (autoJoinMatches.workspaces.isNotEmpty()) { + publishAutoApprovedEvent(savedUser, autoJoinMatches.domain, autoJoinMatches.workspaces) + addUserToMatchedWorkspaces(savedUser.id, autoJoinMatches.domain, autoJoinMatches.workspaces) + } + + if (!autoApproved) { // 4. 승인 대기 알림 이벤트 eventPublisher.publishEvent( InternalUserPendingEvent( @@ -265,6 +292,81 @@ class UserService( return savedUser } + private fun normalizeExistingOAuthUser( + user: UserEntity, + externalOrganizationSource: ExternalSource?, + externalOrganizations: List, + externalWorkspaceFlowEnabled: Boolean, + ): UserEntity { + if (!shouldAutoApprovePendingExternalUser(user, externalOrganizationSource, externalOrganizations, externalWorkspaceFlowEnabled)) { + return user + } + + val source = requireNotNull(externalOrganizationSource) { "External organization source is required for external auto-approval" } + user.changeStatus(UserStatus.ACTIVE, ExternalOrganizationFlow.approvalReason(source)) + return userRepository.save(user) + } + + private fun shouldAutoApproveByExternalSync( + externalOrganizationSource: ExternalSource?, + externalOrganizations: List, + externalWorkspaceFlowEnabled: Boolean, + ): Boolean = externalOrganizationSource != null && externalOrganizations.isNotEmpty() && externalWorkspaceFlowEnabled + + private fun shouldAutoApprovePendingExternalUser( + user: UserEntity, + externalOrganizationSource: ExternalSource?, + externalOrganizations: List, + externalWorkspaceFlowEnabled: Boolean, + ): Boolean = + user.status == UserStatus.PENDING && + shouldAutoApproveByExternalSync(externalOrganizationSource, externalOrganizations, externalWorkspaceFlowEnabled) + + private fun resolveAutoJoinWorkspaceMatches(email: String): AutoJoinWorkspaceMatches { + val domain = extractEmailDomain(email) ?: return AutoJoinWorkspaceMatches.None + val workspacePolicy = platformSettingService.getSettings().workspacePolicy ?: return AutoJoinWorkspaceMatches(domain, emptyList()) + val workspaces = + workspaceRepository + .findAllByAllowedDomain(domain) + .filter { canAutoJoinWorkspace(workspacePolicy, it) } + return AutoJoinWorkspaceMatches(domain, workspaces) + } + + private fun canAutoJoinWorkspace( + workspacePolicy: WorkspacePolicy, + workspace: WorkspaceEntity, + ): Boolean = !workspace.isExternal && isManagedTypeEnabledForAutoJoin(workspacePolicy, workspace.managedType) + + private fun isManagedTypeEnabledForAutoJoin( + workspacePolicy: WorkspacePolicy, + managedType: ManagementType, + ): Boolean = + when (managedType) { + ManagementType.USER_MANAGED -> workspacePolicy.useUserManaged + ManagementType.PLATFORM_MANAGED -> workspacePolicy.usePlatformManaged + } + + @Transactional + internal fun syncExternalOrganizationsForOAuthUser( + user: UserEntity, + externalOrganizationSync: ExternalOrganizationSync.AuthoritativeSnapshot, + ): List { + val approvalReason = ExternalOrganizationFlow.approvalReason(externalOrganizationSync.source) + val syncResult = + externalWorkspaceSyncService.syncForUser( + user = user, + externalOrganizationSnapshot = externalOrganizationSync, + enabled = isExternalOrganizationFlowEnabled(externalOrganizationSync.source), + reason = approvalReason, + ) + + if (syncResult.addedWorkspaces.isNotEmpty()) { + publishExternalOrganizationApprovedEvent(user, syncResult.addedWorkspaces, approvalReason) + } + + return syncResult.workspaces + } + // ========== 사용자 정보 수정 ========== /** @@ -807,6 +909,23 @@ class UserService( ) } + private fun publishExternalOrganizationApprovedEvent( + user: UserEntity, + matchedWorkspaces: List, + reason: String, + ) { + eventPublisher.publishEvent( + InternalUserApprovedEvent( + email = user.email, + userName = user.name, + targetUserId = user.id, + approvedByUserId = SYSTEM_ACTOR_ID, + reason = reason, + matchedWorkspaceIds = matchedWorkspaces.map { it.id.toString() }.sorted(), + ), + ) + } + private fun addUserToMatchedWorkspaces( userId: UUID, domain: String, @@ -817,25 +936,18 @@ class UserService( return@forEach } - workspaceMemberRepository.save( - WorkspaceMemberEntity( - workspaceId = workspace.id, - userId = userId, - isOwner = false, - ), - ) - eventPublisher.publishEvent( - WorkspaceMemberAddedEvent( - workspaceId = workspace.id, - memberId = UserId(userId), - addedBy = UserId(SYSTEM_ACTOR_ID), - reason = WORKSPACE_ALLOWED_DOMAIN_REASON, - domain = domain, - ), + workspaceMemberService.addMember( + workspaceId = workspace.id, + userId = userId, + addedBy = SYSTEM_ACTOR_ID, + reason = WORKSPACE_ALLOWED_DOMAIN_REASON, + domain = domain, ) } } + private fun isExternalOrganizationFlowEnabled(source: ExternalSource?): Boolean = source != null && ExternalOrganizationFlow.isEnabled(platformSettingService.getSettings().workspacePolicy, source) + private fun findOwnedWorkspaceMemberships(userId: UUID): List = workspaceMemberRepository.findActiveOwnerMembershipsByUserId(userId) private fun ensureWorkspaceOwnerCanBeRemoved(ownedMemberships: List) { @@ -1094,7 +1206,7 @@ class UserService( ): String = normalizeCountryCode(countryCode) ?: normalizeCountryCode(fallbackCountryCode) ?: resolveDefaultCountryCode() private fun resolveDefaultCountryCode(): String = - systemSettingService + platformSettingService .getSettings() .countryPolicy .normalized() @@ -1226,6 +1338,15 @@ data class ResolvedUserPhoneNumber( val isPrimary: Boolean, ) +private data class AutoJoinWorkspaceMatches( + val domain: String, + val workspaces: List, +) { + companion object { + val None = AutoJoinWorkspaceMatches(domain = "", workspaces = emptyList()) + } +} + data class ResolvedUserAddress( val countryCode: String, val postalCode: String?, diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceDirectoryImpl.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceDirectoryImpl.kt index 1f6157a47..5f03d9269 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceDirectoryImpl.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceDirectoryImpl.kt @@ -1,23 +1,28 @@ package io.deck.iam.service +import io.deck.iam.ManagementType +import io.deck.iam.api.ExternalReferenceRecord import io.deck.iam.api.WorkspaceDirectory -import io.deck.iam.api.WorkspaceManagedType import io.deck.iam.api.WorkspaceRecord import org.springframework.stereotype.Service import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID @Service class WorkspaceDirectoryImpl( private val workspaceService: WorkspaceService, ) : WorkspaceDirectory { - override fun ensureManagedTypeEnabled(managedType: WorkspaceManagedType) { + override fun listAll(): List = workspaceService.findAll().map { it.toRecord() } + + override fun ensureManagedTypeEnabled(managedType: ManagementType) { workspaceService.ensureManagedTypeEnabled(managedType.toDomain()) } override fun ensureAccessible( workspaceId: UUID, - managedType: WorkspaceManagedType?, + managedType: ManagementType?, ) { workspaceService.ensureWorkspaceAccessible(workspaceId, managedType?.toDomain()) } @@ -25,56 +30,52 @@ class WorkspaceDirectoryImpl( override fun verifyOwner( workspaceId: UUID, userId: UUID, - managedType: WorkspaceManagedType?, + managedType: ManagementType?, ) { workspaceService.verifyOwner(workspaceId, userId, managedType?.toDomain()) } - override fun get(workspaceId: UUID): WorkspaceRecord = workspaceService.findById(workspaceId).toRecord() + override fun getPlatformManaged(workspaceId: UUID): WorkspaceRecord = workspaceService.findPlatformManagedById(workspaceId).toRecord() - override fun listAll(): List = workspaceService.findAll().map { it.toRecord() } + override fun listPlatformManaged(): List = workspaceService.findAllPlatformManaged().map { it.toRecord() } override fun listVisibleByUser(userId: UUID): List = workspaceService.findVisibleByUser(userId).map { it.toRecord() } - override fun create( + override fun createPlatformManaged( name: String, description: String?, initialOwnerId: UUID, - managedType: WorkspaceManagedType, - allowedDomains: List, + autoJoinDomains: List, ): WorkspaceRecord = workspaceService - .create( + .createPlatformManaged( name = name, description = description, initialOwnerId = initialOwnerId, - managedType = managedType.toDomain(), - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, ).toRecord() override fun createForUser( name: String, description: String?, userId: UUID, - allowedDomains: List, - ): WorkspaceRecord = workspaceService.createForUser(name, description, userId, allowedDomains).toRecord() + autoJoinDomains: List, + ): WorkspaceRecord = workspaceService.createForUser(name, description, userId, autoJoinDomains).toRecord() - override fun updateByAdmin( + override fun updatePlatformManaged( workspaceId: UUID, name: String, description: String?, updatedBy: UUID, - managedType: WorkspaceManagedType, - allowedDomains: List, + autoJoinDomains: List, ): WorkspaceRecord = workspaceService - .updateByAdmin( + .updatePlatformManaged( workspaceId = workspaceId, name = name, description = description, updatedBy = updatedBy, - managedType = managedType.toDomain(), - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, ).toRecord() override fun updateForUser( @@ -82,8 +83,8 @@ class WorkspaceDirectoryImpl( name: String, description: String?, requestedBy: UUID, - managedType: WorkspaceManagedType, - allowedDomains: List, + managedType: ManagementType, + autoJoinDomains: List, ): WorkspaceRecord = workspaceService .update( @@ -92,20 +93,20 @@ class WorkspaceDirectoryImpl( description = description, requestedBy = requestedBy, managedType = managedType.toDomain(), - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, ).toRecord() - override fun deleteByAdminBatch( + override fun deletePlatformManagedBatch( workspaceIds: Collection, deletedBy: UUID, ) { - workspaceService.deleteByAdminBatch(workspaceIds, deletedBy) + workspaceService.deletePlatformManagedBatch(workspaceIds, deletedBy) } override fun deleteBatch( workspaceIds: Collection, deletedBy: UUID, - managedType: WorkspaceManagedType, + managedType: ManagementType, ) { workspaceService.deleteBatch(workspaceIds, deletedBy, managedType.toDomain()) } @@ -115,10 +116,11 @@ private data class WorkspaceRecordView( override val id: UUID, override val name: String, override val description: String?, - override val allowedDomains: List, - override val managedType: WorkspaceManagedType, - override val createdAt: Instant?, - override val updatedAt: Instant?, + override val autoJoinDomains: List, + override val managedType: ManagementType, + override val externalReference: ExternalReferenceRecord?, + override val createdAt: LocalDateTime?, + override val updatedAt: LocalDateTime?, ) : WorkspaceRecord private fun io.deck.iam.domain.WorkspaceEntity.toRecord(): WorkspaceRecord = @@ -126,14 +128,15 @@ private fun io.deck.iam.domain.WorkspaceEntity.toRecord(): WorkspaceRecord = id = id, name = name, description = description, - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, managedType = managedType.toApi(), - createdAt = createdAt, - updatedAt = updatedAt, + externalReference = externalReference?.let { ExternalReferenceRecord(source = it.source, externalId = it.externalId) }, + createdAt = createdAt?.toUtcLocalDateTime(), + updatedAt = updatedAt?.toUtcLocalDateTime(), ) -private fun io.deck.iam.domain.WorkspaceManagedType.toApi(): WorkspaceManagedType = WorkspaceManagedType.valueOf(name) +private fun ManagementType.toApi(): ManagementType = this + +private fun ManagementType.toDomain(): ManagementType = this -private fun WorkspaceManagedType.toDomain(): io.deck.iam.domain.WorkspaceManagedType = - io.deck.iam.domain.WorkspaceManagedType - .valueOf(name) +private fun Instant.toUtcLocalDateTime(): LocalDateTime = atOffset(ZoneOffset.UTC).toLocalDateTime() diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInvitationManagerImpl.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInvitationManagerImpl.kt index 5bcd6ba42..13b7a3455 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInvitationManagerImpl.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInvitationManagerImpl.kt @@ -1,6 +1,4 @@ package io.deck.iam.service - -import io.deck.common.api.exception.BadRequestException import io.deck.iam.api.InviteStatus import io.deck.iam.api.WorkspaceInvitationManager import io.deck.iam.api.WorkspaceInviteRecord @@ -26,15 +24,7 @@ class WorkspaceInvitationManagerImpl( emails: Collection, message: String?, invitedBy: UUID, - ) { - emails.forEach { email -> - try { - workspaceInviteService.invite(workspaceId, email.trim(), message, invitedBy) - } catch (_: BadRequestException) { - // 이미 초대됨 skip - } - } - } + ) = workspaceInviteService.inviteAllIgnoringExisting(workspaceId, emails, message, invitedBy) override fun cancelBatch( workspaceId: UUID, diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInviteService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInviteService.kt index 092cab296..a92cfd337 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInviteService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInviteService.kt @@ -2,6 +2,7 @@ package io.deck.iam.service import io.deck.common.api.context.RequestContext import io.deck.common.api.exception.BadRequestException +import io.deck.common.api.exception.ConflictException import io.deck.common.api.exception.NotFoundException import io.deck.common.api.hash.HashUtils import io.deck.common.api.id.SYSTEM_ACTOR_ID @@ -16,8 +17,9 @@ import io.deck.iam.event.WorkspaceInviteSentEvent import io.deck.iam.repository.UserRepository import io.deck.iam.repository.WorkspaceInviteRepository import io.deck.iam.repository.WorkspaceMemberRepository -import io.deck.iam.repository.WorkspaceRepository +import io.deck.iam.repository.WorkspaceMutationJdbcRepository import org.springframework.context.ApplicationEventPublisher +import org.springframework.dao.OptimisticLockingFailureException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.security.SecureRandom @@ -35,46 +37,68 @@ data class WorkspaceInviteValidation( override val hasAccount: Boolean, ) : WorkspaceInviteValidationResult +enum class WorkspaceInviteIgnoreReason { + ALREADY_MEMBER, + PENDING_CONFLICT, +} + +private data class WorkspaceInviteValidationState( + val invite: WorkspaceInviteEntity, + val workspaceName: String?, + val expired: Boolean, + val alreadyMember: Boolean, + val hasAccount: Boolean, + val valid: Boolean, +) { + fun toResponse(): WorkspaceInviteValidation = + WorkspaceInviteValidation( + valid = valid, + email = invite.email, + workspaceName = workspaceName, + expired = expired, + alreadyMember = alreadyMember, + hasAccount = hasAccount, + ) +} + @Service @Transactional(readOnly = true) class WorkspaceInviteService( private val inviteRepository: WorkspaceInviteRepository, private val userRepository: UserRepository, - private val workspaceRepository: WorkspaceRepository, + private val workspaceService: WorkspaceService, private val memberRepository: WorkspaceMemberRepository, private val userService: UserService, private val memberService: WorkspaceMemberService, private val eventPublisher: ApplicationEventPublisher, + private val workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository, ) { - fun findAllByWorkspace(workspaceId: UUID): List = inviteRepository.findAllByWorkspaceId(workspaceId) + private fun requireMutableWorkspace(workspaceId: UUID) = workspaceService.findMutableById(workspaceId) + + @Transactional + fun inviteAllIgnoringExisting( + workspaceId: UUID, + emails: Collection, + message: String?, + invitedBy: UUID, + ) { + emails.forEach { email -> + inviteIgnoringExisting(workspaceId, email.trim(), message, invitedBy) + } + } + + fun findAllByWorkspace(workspaceId: UUID): List { + requireMutableWorkspace(workspaceId) + return inviteRepository.findAllByWorkspaceId(workspaceId) + } fun validateToken(token: String): WorkspaceInviteValidation { val tokenHash = HashUtils.sha3(token) val invite = inviteRepository.findByTokenHash(tokenHash) - ?: return WorkspaceInviteValidation( - valid = false, - email = null, - workspaceName = null, - expired = false, - alreadyMember = false, - hasAccount = false, - ) - - val workspace = workspaceRepository.findById(invite.workspaceId).orElse(null) - val user = userRepository.findByEmail(invite.email) - val alreadyMember = - user != null && - memberRepository.existsByWorkspaceIdAndUserId(invite.workspaceId, user.id) + ?: return invalidInviteValidation() - return WorkspaceInviteValidation( - valid = invite.isValid() && !alreadyMember, - email = invite.email, - workspaceName = workspace?.name, - expired = invite.isExpired(), - alreadyMember = alreadyMember, - hasAccount = user != null, - ) + return loadInviteValidationState(invite).toResponse() } @Transactional @@ -84,28 +108,16 @@ class WorkspaceInviteService( message: String?, invitedBy: UUID, ): WorkspaceInviteEntity { - val workspace = - workspaceRepository - .findById(workspaceId) - .orElseThrow { NotFoundException("iam.workspace.not_found") } - val existingUser = userRepository.findByEmail(email) + val normalizedEmail = email.normalizeInviteEmail() + val workspace = requireMutableWorkspace(workspaceId) + val existingUser = userRepository.findByEmail(normalizedEmail) if (existingUser != null && memberService.isMember(workspaceId, existingUser.id)) { throw BadRequestException("iam.workspace_member.already_member", "ALREADY_MEMBER") } inviteRepository - .findByWorkspaceIdAndEmailAndStatus(workspaceId, email, InviteStatus.PENDING) - ?.let { - it.cancel() - eventPublisher.publishEvent( - WorkspaceInviteCancelledEvent( - workspaceId = workspaceId, - inviteId = it.id, - email = it.email, - cancelledBy = UserId(invitedBy), - ), - ) - } + .findByWorkspaceIdAndEmailAndStatus(workspaceId, normalizedEmail, InviteStatus.PENDING) + ?.let { cancelPendingInvite(it, workspaceId, invitedBy) } val rawToken = generateToken() val tokenHash = HashUtils.sha3(rawToken) @@ -113,22 +125,25 @@ class WorkspaceInviteService( val invite = WorkspaceInviteEntity( workspaceId = workspaceId, - email = email, + email = normalizedEmail, tokenHash = tokenHash, expiresAt = Instant.now().plus(INVITE_EXPIRY_DAYS, ChronoUnit.DAYS), message = message, - ) - val saved = inviteRepository.save(invite) + inviterId = invitedBy, + ).apply { + createdBy = invitedBy + } + val saved = persistNewInvite(invite) val inviter = userRepository.findById(invitedBy).orElse(null) eventPublisher.publishEvent( WorkspaceInviteSentEvent( workspaceId = workspaceId, workspaceName = workspace.name, - email = email, + email = normalizedEmail, token = rawToken, message = message, - inviterName = inviter?.name ?: UserEntity.SYSTEM_NAME, + inviterName = inviter?.name ?: UserEntity.PLATFORM_NAME, inviteId = saved.id, invitedBy = UserId(invitedBy), ), @@ -136,6 +151,22 @@ class WorkspaceInviteService( return saved } + @Transactional + fun inviteIgnoringExisting( + workspaceId: UUID, + email: String, + message: String?, + invitedBy: UUID, + ): WorkspaceInviteIgnoreReason? = + try { + invite(workspaceId, email, message, invitedBy) + null + } catch (exception: BadRequestException) { + exception.toIgnoreReasonOrNull() ?: throw exception + } catch (exception: ConflictException) { + exception.toIgnoreReasonOrNull() ?: throw exception + } + @Transactional fun accept( token: String, @@ -148,6 +179,7 @@ class WorkspaceInviteService( ?: throw BadRequestException("iam.workspace_invite.invalid_token") require(invite.isValid()) { "Invite is not valid" } + requireMutableWorkspace(invite.workspaceId) val user = userRepository.findByEmail(invite.email) val resolvedUser = @@ -160,15 +192,14 @@ class WorkspaceInviteService( name = resolvedName, email = invite.email, roleIds = emptySet(), - createdBy = UserId(invite.workspaceId), + createdBy = UserId(invite.inviterId), contactProfile = null, ) } - invite.accept(resolvedUser.id) - inviteRepository.save(invite) - memberService.addMember(invite.workspaceId, resolvedUser.id, resolvedUser.id) + invite.accept(resolvedUser.id) + persistInviteMutation(invite, "iam.workspace_invite.concurrent_accept", "WORKSPACE_INVITE_CONCURRENT_ACCEPT") eventPublisher.publishEvent( WorkspaceInviteAcceptedEvent( @@ -187,8 +218,9 @@ class WorkspaceInviteService( workspaceId: UUID, ) { val invite = findInviteBelongingTo(inviteId, workspaceId) + requireMutableWorkspace(workspaceId) invite.cancel() - inviteRepository.save(invite) + persistInviteMutation(invite, "iam.workspace_invite.concurrent_cancel", "WORKSPACE_INVITE_CONCURRENT_CANCEL") eventPublisher.publishEvent( WorkspaceInviteCancelledEvent( workspaceId = workspaceId, @@ -216,17 +248,18 @@ class WorkspaceInviteService( workspaceId: UUID, ) { val invite = findInviteBelongingTo(inviteId, workspaceId) - val workspace = - workspaceRepository - .findById(invite.workspaceId) - .orElseThrow { NotFoundException("iam.workspace.not_found") } + val workspace = requireMutableWorkspace(invite.workspaceId) require(invite.status == InviteStatus.PENDING) { "Only PENDING invites can be resent" } val rawToken = generateToken() val tokenHash = HashUtils.sha3(rawToken) - invite.rotateToken(tokenHash, Instant.now().plus(INVITE_EXPIRY_DAYS, ChronoUnit.DAYS)) - inviteRepository.save(invite) + invite.resend( + newTokenHash = tokenHash, + newExpiresAt = Instant.now().plus(INVITE_EXPIRY_DAYS, ChronoUnit.DAYS), + inviterId = resendBy, + ) + persistInviteMutation(invite, "iam.workspace_invite.concurrent_resend", "WORKSPACE_INVITE_CONCURRENT_RESEND") val resender = userRepository.findById(resendBy).orElse(null) eventPublisher.publishEvent( @@ -236,7 +269,7 @@ class WorkspaceInviteService( email = invite.email, token = rawToken, message = invite.message, - inviterName = resender?.name ?: UserEntity.SYSTEM_NAME, + inviterName = resender?.name ?: UserEntity.PLATFORM_NAME, inviteId = invite.id, invitedBy = UserId(resendBy), ), @@ -266,6 +299,91 @@ class WorkspaceInviteService( return invite } + private fun cancelPendingInvite( + invite: WorkspaceInviteEntity, + workspaceId: UUID, + cancelledBy: UUID, + ) { + invite.cancel() + persistInviteMutation(invite, "iam.workspace_invite.concurrent_cancel", "WORKSPACE_INVITE_CONCURRENT_CANCEL") + eventPublisher.publishEvent( + WorkspaceInviteCancelledEvent( + workspaceId = workspaceId, + inviteId = invite.id, + email = invite.email, + cancelledBy = UserId(cancelledBy), + ), + ) + } + + private fun persistNewInvite(invite: WorkspaceInviteEntity): WorkspaceInviteEntity = + if ( + workspaceMutationJdbcRepository.insertPendingInviteIfAbsent( + inviteId = invite.id, + workspaceId = invite.workspaceId, + email = invite.email, + tokenHash = invite.tokenHash, + expiresAt = invite.expiresAt, + message = invite.message, + inviterId = invite.inviterId, + ) + ) { + invite + } else { + throw ConflictException("iam.workspace_invite.pending_conflict", "WORKSPACE_INVITE_PENDING_CONFLICT") + } + + private fun persistInviteMutation( + invite: WorkspaceInviteEntity, + messageCode: String, + code: String, + ): WorkspaceInviteEntity = + try { + inviteRepository.saveAndFlush(invite) + } catch (_: OptimisticLockingFailureException) { + throw ConflictException(messageCode, code) + } + + private fun loadInviteValidationState(invite: WorkspaceInviteEntity): WorkspaceInviteValidationState { + val workspace = + try { + workspaceService.ensureWorkspaceAccessible(invite.workspaceId) + } catch (_: NotFoundException) { + null + } + val invitedUser = userRepository.findByEmail(invite.email) + val workspaceMissing = workspace == null + val expired = invite.isExpired() + val inviteActive = invite.isValid() + val alreadyMember = + workspace != null && + invitedUser != null && + memberRepository.existsByWorkspaceIdAndUserId(invite.workspaceId, invitedUser.id) + val externalWorkspaceLocked = workspace?.isExternal == true + val workspaceAvailable = !workspaceMissing + val canJoinWorkspace = !alreadyMember && !externalWorkspaceLocked + val valid = inviteActive && workspaceAvailable && canJoinWorkspace + + return WorkspaceInviteValidationState( + invite = invite, + workspaceName = workspace?.name, + expired = expired, + alreadyMember = alreadyMember, + hasAccount = invitedUser != null, + valid = valid, + ) + } + + private fun invalidInviteValidation(): WorkspaceInviteValidation = + WorkspaceInviteValidation( + valid = false, + email = null, + workspaceName = null, + expired = false, + alreadyMember = false, + hasAccount = false, + ) + private fun generateToken(): String { val bytes = ByteArray(TOKEN_BYTES) SecureRandom().nextBytes(bytes) @@ -277,3 +395,17 @@ class WorkspaceInviteService( private const val INVITE_EXPIRY_DAYS = 7L } } + +private fun String.normalizeInviteEmail(): String = trim().lowercase() + +private fun BadRequestException.toIgnoreReasonOrNull(): WorkspaceInviteIgnoreReason? = + when (code) { + "ALREADY_MEMBER" -> WorkspaceInviteIgnoreReason.ALREADY_MEMBER + else -> null + } + +private fun ConflictException.toIgnoreReasonOrNull(): WorkspaceInviteIgnoreReason? = + when (code) { + "WORKSPACE_INVITE_PENDING_CONFLICT" -> WorkspaceInviteIgnoreReason.PENDING_CONFLICT + else -> null + } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceMemberService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceMemberService.kt index 35e4c488c..0235bd078 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceMemberService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceMemberService.kt @@ -10,7 +10,6 @@ import io.deck.iam.event.WorkspaceMemberAddedEvent import io.deck.iam.event.WorkspaceMemberOwnershipChangedEvent import io.deck.iam.event.WorkspaceMemberRemovedEvent import io.deck.iam.repository.WorkspaceMemberRepository -import io.deck.iam.repository.WorkspaceRepository import org.springframework.context.ApplicationEventPublisher import org.springframework.security.access.AccessDeniedException import org.springframework.stereotype.Service @@ -21,9 +20,13 @@ import java.util.UUID @Transactional(readOnly = true) class WorkspaceMemberService( private val memberRepository: WorkspaceMemberRepository, - private val workspaceRepository: WorkspaceRepository, + private val workspaceService: WorkspaceService, private val eventPublisher: ApplicationEventPublisher, ) { + private fun requireMutableWorkspace(workspaceId: UUID) { + workspaceService.findMutableById(workspaceId) + } + fun findMembers(workspaceId: UUID): List = memberRepository.findAllByWorkspaceId(workspaceId) fun findOwners(workspaceId: UUID): List = memberRepository.findAllByWorkspaceIdAndIsOwnerTrue(workspaceId) @@ -76,7 +79,10 @@ class WorkspaceMemberService( workspaceId: UUID, userId: UUID, addedBy: UUID, + reason: String? = null, + domain: String? = null, ): WorkspaceMemberEntity { + requireMutableWorkspace(workspaceId) if (memberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId)) { throw BadRequestException("iam.workspace_member.already_member", "ALREADY_MEMBER") } @@ -93,6 +99,8 @@ class WorkspaceMemberService( workspaceId = workspaceId, memberId = UserId(userId), addedBy = UserId(addedBy), + reason = reason, + domain = domain, ), ) return saved @@ -103,9 +111,7 @@ class WorkspaceMemberService( workspaceId: UUID, userId: UUID, ) { - workspaceRepository - .findById(workspaceId) - .orElseThrow { NotFoundException("iam.workspace.not_found") } + requireMutableWorkspace(workspaceId) val member = memberRepository.findByWorkspaceIdAndUserId(workspaceId, userId) @@ -121,6 +127,7 @@ class WorkspaceMemberService( userId: UUID, removedBy: UUID, ) { + requireMutableWorkspace(workspaceId) val member = memberRepository.findByWorkspaceIdAndUserId(workspaceId, userId) ?: throw NotFoundException("iam.workspace_member.not_found") @@ -142,6 +149,7 @@ class WorkspaceMemberService( userIds: Collection, removedBy: UUID, ) { + requireMutableWorkspace(workspaceId) val targetUserIds = userIds.distinct() if (targetUserIds.isEmpty()) { return @@ -172,6 +180,7 @@ class WorkspaceMemberService( workspaceId: UUID, ownerUserIds: Collection, ) { + requireMutableWorkspace(workspaceId) val selectedOwnerIds = ownerUserIds.distinct().toSet() if (selectedOwnerIds.isEmpty()) { throw BadRequestException(messageCode = "iam.workspace.last_owner_required") diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceProvisioningCommandImpl.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceProvisioningCommandImpl.kt index b7dbca7d0..06053fa28 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceProvisioningCommandImpl.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceProvisioningCommandImpl.kt @@ -1,7 +1,6 @@ package io.deck.iam.service import io.deck.iam.api.WorkspaceProvisioningCommand -import io.deck.iam.domain.WorkspaceManagedType import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -15,11 +14,10 @@ class WorkspaceProvisioningCommandImpl( userId: UUID, userName: String, ) { - workspaceService.create( + workspaceService.ensurePersonalWorkspace( name = "${userName}의 워크스페이스", description = null, initialOwnerId = userId, - managedType = WorkspaceManagedType.USER_MANAGED, ) } } diff --git a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt index ba1289d10..2b15abb71 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt @@ -2,16 +2,18 @@ package io.deck.iam.service import io.deck.common.api.exception.BadRequestException import io.deck.common.api.exception.NotFoundException +import io.deck.iam.ManagementType +import io.deck.iam.domain.ExternalReference import io.deck.iam.domain.UserId import io.deck.iam.domain.ValidationPatterns import io.deck.iam.domain.WorkspaceEntity -import io.deck.iam.domain.WorkspaceManagedType import io.deck.iam.domain.WorkspaceMemberEntity import io.deck.iam.domain.WorkspacePolicy import io.deck.iam.event.WorkspaceCreatedEvent import io.deck.iam.event.WorkspaceDeletedEvent import io.deck.iam.event.WorkspaceUpdatedEvent import io.deck.iam.repository.WorkspaceMemberRepository +import io.deck.iam.repository.WorkspaceMutationJdbcRepository import io.deck.iam.repository.WorkspaceRepository import org.springframework.context.ApplicationEventPublisher import org.springframework.security.access.AccessDeniedException @@ -25,8 +27,11 @@ class WorkspaceService( private val workspaceRepository: WorkspaceRepository, private val memberRepository: WorkspaceMemberRepository, private val eventPublisher: ApplicationEventPublisher, - private val systemSettingService: SystemSettingService, + private val platformSettingService: PlatformSettingService, + private val workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository, ) { + fun findAll(): List = workspaceRepository.findAll() + fun findById(id: UUID): WorkspaceEntity = workspaceRepository .findById(id) @@ -34,52 +39,118 @@ class WorkspaceService( fun findByUser(userId: UUID): List = workspaceRepository.findAllByMemberUserId(userId) + private fun ensureMutable(workspace: WorkspaceEntity): WorkspaceEntity { + if (workspace.isExternal) { + throw BadRequestException(messageCode = "iam.workspace.external_locked") + } + return workspace + } + + fun findMutableById(id: UUID): WorkspaceEntity = ensureMutable(ensureWorkspaceAccessible(id)) + fun findVisibleByUser(userId: UUID): List { - val workspacePolicy = systemSettingService.getSettings().workspacePolicy ?: return emptyList() + val workspacePolicy = platformSettingService.getSettings().workspacePolicy ?: return emptyList() return workspaceRepository.findAllByMemberUserId(userId).filter { isManagedTypeEnabled(workspacePolicy, it.managedType) } } - fun ensureManagedTypeEnabled(managedType: WorkspaceManagedType) { - val workspacePolicy = systemSettingService.getSettings().workspacePolicy ?: throw workspacePolicyNotFound() - if (!isManagedTypeEnabled(workspacePolicy, managedType)) { + fun ensureManagedTypeEnabled(managedType: ManagementType) { + if (!isManagedTypeEnabled(managedType)) { throw workspacePolicyNotFound() } } + fun isManagedTypeEnabled(managedType: ManagementType): Boolean { + val workspacePolicy = platformSettingService.getSettings().workspacePolicy ?: return false + return isManagedTypeEnabled(workspacePolicy, managedType) + } + fun ensureWorkspaceAccessible( workspaceId: UUID, - managedType: WorkspaceManagedType? = null, + managedType: ManagementType? = null, ): WorkspaceEntity = findPolicyAccessibleWorkspace(workspaceId, managedType) + fun findPlatformManagedById(workspaceId: UUID): WorkspaceEntity = findPolicyAccessibleWorkspace(workspaceId, ManagementType.PLATFORM_MANAGED) + + fun findAllPlatformManaged(): List { + ensureManagedTypeEnabled(ManagementType.PLATFORM_MANAGED) + return workspaceRepository.findAll().filter { it.managedType == ManagementType.PLATFORM_MANAGED } + } + @Transactional fun createForUser( name: String, description: String?, initialOwnerId: UUID, - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ): WorkspaceEntity { - try { - ensureManagedTypeEnabled(WorkspaceManagedType.USER_MANAGED) - } catch (_: NotFoundException) { + if (!isManagedTypeEnabled(ManagementType.USER_MANAGED)) { throw BadRequestException(messageCode = "iam.workspace.creation_disabled") } - return create(name, description, initialOwnerId, WorkspaceManagedType.USER_MANAGED, allowedDomains) + return saveWorkspace(name, description, initialOwnerId, ManagementType.USER_MANAGED, autoJoinDomains) + } + + @Transactional + fun createForUserIfEnabled( + name: String, + description: String?, + initialOwnerId: UUID, + autoJoinDomains: List = emptyList(), + ): WorkspaceEntity? { + if (!isManagedTypeEnabled(ManagementType.USER_MANAGED)) { + return null + } + return saveWorkspace(name, description, initialOwnerId, ManagementType.USER_MANAGED, autoJoinDomains) + } + + @Transactional + fun ensurePersonalWorkspace( + name: String, + description: String?, + initialOwnerId: UUID, + autoJoinDomains: List = emptyList(), + ): WorkspaceEntity? { + if (!isManagedTypeEnabled(ManagementType.USER_MANAGED)) { + return null + } + + findOwnedUserManagedWorkspace(initialOwnerId)?.let { return it } + return saveWorkspace(name, description, initialOwnerId, ManagementType.USER_MANAGED, autoJoinDomains) + } + + @Transactional + fun createPlatformManaged( + name: String, + description: String?, + initialOwnerId: UUID, + autoJoinDomains: List = emptyList(), + ): WorkspaceEntity { + ensureManagedTypeEnabled(ManagementType.PLATFORM_MANAGED) + return saveWorkspace(name, description, initialOwnerId, ManagementType.PLATFORM_MANAGED, autoJoinDomains) } @Transactional - fun create( + internal fun upsertExternalWorkspace( + reference: ExternalReference, + name: String?, + description: String?, + ): WorkspaceEntity = + findExternalWorkspace(reference)?.let { workspace -> + syncExternalWorkspaceIdentity(workspace, name, description) + } ?: createExternalWorkspace(reference, name, description) + + private fun saveWorkspace( name: String, description: String?, initialOwnerId: UUID, - managedType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, - allowedDomains: List = emptyList(), + managedType: ManagementType, + autoJoinDomains: List, ): WorkspaceEntity { - validateAllowedDomains(allowedDomains) + validateAllowedDomains(autoJoinDomains) val workspace = WorkspaceEntity( name = name, description = description, - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, managedType = managedType, ) val saved = workspaceRepository.save(workspace) @@ -102,17 +173,58 @@ class WorkspaceService( return saved } + private fun createExternalWorkspace( + reference: ExternalReference, + name: String?, + description: String?, + ): WorkspaceEntity { + val workspaceId = + workspaceMutationJdbcRepository.upsertExternalWorkspace( + reference = reference, + name = name ?: reference.externalId, + description = description, + ) + return workspaceRepository + .findById(workspaceId) + .orElseThrow { NotFoundException("iam.workspace.not_found", messageArgs = arrayOf(workspaceId)) } + } + + private fun findExternalWorkspace(reference: ExternalReference): WorkspaceEntity? = workspaceRepository.findByExternalReferenceSourceAndExternalReferenceExternalId(reference.source, reference.externalId) + + private fun syncExternalWorkspaceIdentity( + workspace: WorkspaceEntity, + name: String?, + description: String?, + ): WorkspaceEntity { + workspace.syncExternalIdentity( + name = name ?: workspace.name, + description = description ?: workspace.description, + ) + return workspace + } + + private fun findOwnedUserManagedWorkspace(userId: UUID): WorkspaceEntity? { + val ownedWorkspaceIds = memberRepository.findActiveOwnerMembershipsByUserId(userId).map { it.workspaceId } + if (ownedWorkspaceIds.isEmpty()) { + return null + } + + return workspaceRepository + .findAllById(ownedWorkspaceIds) + .firstOrNull { it.managedType == ManagementType.USER_MANAGED && !it.isExternal } + } + @Transactional fun update( workspaceId: UUID, name: String, description: String?, requestedBy: UUID, - allowedDomains: List? = null, - managedType: WorkspaceManagedType? = null, + autoJoinDomains: List? = null, + managedType: ManagementType? = null, ): WorkspaceEntity { - val workspace = findPolicyAccessibleWorkspace(workspaceId, managedType) - val effectiveAllowedDomains = allowedDomains ?: workspace.allowedDomains + val workspace = ensureMutable(findPolicyAccessibleWorkspace(workspaceId, managedType)) + val effectiveAllowedDomains = autoJoinDomains ?: workspace.autoJoinDomains val effectiveManagedType = managedType ?: workspace.managedType ensureOwner(workspace, requestedBy) validateAllowedDomains(effectiveAllowedDomains) @@ -131,9 +243,9 @@ class WorkspaceService( fun delete( workspaceId: UUID, deletedBy: UUID, - managedType: WorkspaceManagedType? = null, + managedType: ManagementType? = null, ) { - val workspace = findPolicyAccessibleWorkspace(workspaceId, managedType) + val workspace = ensureMutable(findPolicyAccessibleWorkspace(workspaceId, managedType)) ensureOwner(workspace, deletedBy) workspace.softDelete(deletedBy) @@ -150,29 +262,25 @@ class WorkspaceService( fun deleteBatch( workspaceIds: Collection, deletedBy: UUID, - managedType: WorkspaceManagedType? = null, + managedType: ManagementType? = null, ) { workspaceIds.distinct().forEach { workspaceId -> delete(workspaceId, deletedBy, managedType) } } - fun findAll(): List = workspaceRepository.findAll() - @Transactional - fun updateByAdmin( + fun updatePlatformManaged( workspaceId: UUID, name: String, description: String?, updatedBy: UUID, - allowedDomains: List? = null, - managedType: WorkspaceManagedType? = null, + autoJoinDomains: List? = null, ): WorkspaceEntity { - val workspace = findById(workspaceId) - val effectiveAllowedDomains = allowedDomains ?: workspace.allowedDomains - val effectiveManagedType = managedType ?: workspace.managedType + val workspace = ensureMutable(findPlatformManagedById(workspaceId)) + val effectiveAllowedDomains = autoJoinDomains ?: workspace.autoJoinDomains validateAllowedDomains(effectiveAllowedDomains) - workspace.update(name, description, effectiveAllowedDomains, effectiveManagedType) + workspace.update(name, description, effectiveAllowedDomains, ManagementType.PLATFORM_MANAGED) eventPublisher.publishEvent( WorkspaceUpdatedEvent( workspaceId = workspace.id, @@ -183,11 +291,11 @@ class WorkspaceService( } @Transactional - fun deleteByAdmin( + fun deletePlatformManaged( workspaceId: UUID, deletedBy: UUID, ) { - val workspace = findById(workspaceId) + val workspace = ensureMutable(findPlatformManagedById(workspaceId)) workspace.softDelete(deletedBy) eventPublisher.publishEvent( WorkspaceDeletedEvent( @@ -198,19 +306,19 @@ class WorkspaceService( } @Transactional - fun deleteByAdminBatch( + fun deletePlatformManagedBatch( workspaceIds: Collection, deletedBy: UUID, ) { workspaceIds.distinct().forEach { workspaceId -> - deleteByAdmin(workspaceId, deletedBy) + deletePlatformManaged(workspaceId, deletedBy) } } fun verifyOwner( workspaceId: UUID, userId: UUID, - managedType: WorkspaceManagedType? = null, + managedType: ManagementType? = null, ) { ensureOwner(findPolicyAccessibleWorkspace(workspaceId, managedType), userId) } @@ -224,8 +332,8 @@ class WorkspaceService( } } - private fun validateAllowedDomains(allowedDomains: List) { - allowedDomains.forEach { domain -> + private fun validateAllowedDomains(autoJoinDomains: List) { + autoJoinDomains.forEach { domain -> val normalizedDomain = domain.trim().lowercase() if (normalizedDomain.isNotBlank() && !DOMAIN_PATTERN.matches(normalizedDomain)) { throw BadRequestException( @@ -238,7 +346,7 @@ class WorkspaceService( private fun findPolicyAccessibleWorkspace( workspaceId: UUID, - expectedManagedType: WorkspaceManagedType?, + expectedManagedType: ManagementType?, ): WorkspaceEntity { val workspace = findById(workspaceId) if (expectedManagedType != null && workspace.managedType != expectedManagedType) { @@ -250,11 +358,11 @@ class WorkspaceService( private fun isManagedTypeEnabled( workspacePolicy: WorkspacePolicy, - managedType: WorkspaceManagedType, + managedType: ManagementType, ): Boolean = when (managedType) { - WorkspaceManagedType.USER_MANAGED -> workspacePolicy.useUserManaged - WorkspaceManagedType.SYSTEM_MANAGED -> workspacePolicy.useSystemManaged + ManagementType.USER_MANAGED -> workspacePolicy.useUserManaged + ManagementType.PLATFORM_MANAGED -> workspacePolicy.usePlatformManaged } private fun workspacePolicyNotFound(): NotFoundException = NotFoundException("iam.workspace.not_found") diff --git a/backend/iam/src/main/resources/messages-iam.properties b/backend/iam/src/main/resources/messages-iam.properties index 0aabece15..743d2adf6 100644 --- a/backend/iam/src/main/resources/messages-iam.properties +++ b/backend/iam/src/main/resources/messages-iam.properties @@ -2,6 +2,7 @@ iam.workspace.not_found=Workspace not found: {0} iam.workspace.last_workspace=Cannot delete the last workspace. Every user must have at least one workspace. iam.workspace.owner_last_workspace=Cannot change owner. The current owner has only one workspace. iam.workspace.last_owner_required=At least one workspace owner must remain. Transfer ownership or delete the workspace first. +iam.workspace.external_locked=External workspaces are synced from an external source and cannot be modified in Deck. iam.workspace.owner_requires_active_user=Only active users can become workspace owners. iam.workspace_member.already_member=User is already a member iam.workspace_member.not_found=Member not found diff --git a/backend/iam/src/main/resources/messages-iam_ja.properties b/backend/iam/src/main/resources/messages-iam_ja.properties index 97ccf5097..b5a7bc5e4 100644 --- a/backend/iam/src/main/resources/messages-iam_ja.properties +++ b/backend/iam/src/main/resources/messages-iam_ja.properties @@ -2,6 +2,7 @@ iam.workspace.not_found=ワークスペースが見つかりません: {0} iam.workspace.last_workspace=最後のワークスペースは削除できません。すべてのユーザーには少なくとも1つのワークスペースが必要です。 iam.workspace.owner_last_workspace=オーナーを変更できません。現在のオーナーのワークスペースは1つだけです。 iam.workspace.last_owner_required=ワークスペースには少なくとも1人のownerが必要です。ownerを移譲するか、ワークスペースを削除してください。 +iam.workspace.external_locked=外部ワークスペースは外部ソースと同期されるため、Deck では変更できません。 iam.workspace.owner_requires_active_user=アクティブなユーザーのみworkspace ownerになれます。 iam.workspace_member.already_member=ユーザーは既にメンバーです iam.workspace_member.not_found=メンバーが見つかりません diff --git a/backend/iam/src/main/resources/messages-iam_ko.properties b/backend/iam/src/main/resources/messages-iam_ko.properties index 236ce2041..4f4540c16 100644 --- a/backend/iam/src/main/resources/messages-iam_ko.properties +++ b/backend/iam/src/main/resources/messages-iam_ko.properties @@ -2,6 +2,7 @@ iam.workspace.not_found=워크스페이스를 찾을 수 없습니다: {0} iam.workspace.last_workspace=마지막 워크스페이스는 삭제할 수 없습니다. 모든 사용자는 최소 하나의 워크스페이스가 필요합니다. iam.workspace.owner_last_workspace=오너를 변경할 수 없습니다. 현재 오너가 보유한 워크스페이스가 하나뿐입니다. iam.workspace.last_owner_required=워크스페이스에는 최소 한 명의 owner가 남아 있어야 합니다. owner를 이관하거나 워크스페이스를 삭제해 주세요. +iam.workspace.external_locked=외부 워크스페이스는 외부 원본과 동기화되므로 Deck에서 수정할 수 없습니다. iam.workspace.owner_requires_active_user=활성 상태 사용자만 workspace owner가 될 수 있습니다. iam.workspace_member.already_member=이미 멤버로 등록된 사용자입니다 iam.workspace_member.not_found=멤버를 찾을 수 없습니다 diff --git a/backend/iam/src/test/kotlin/io/deck/iam/api/ProgramPathsTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/api/ProgramPathsTest.kt new file mode 100644 index 000000000..5fa855b5c --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/api/ProgramPathsTest.kt @@ -0,0 +1,28 @@ +package io.deck.iam.api + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class ProgramPathsTest : + DescribeSpec({ + describe("consoleProgramPath") { + it("bare menu route는 canonical console path로 변환한다") { + consoleProgramPath("/contacts") shouldBe "/console/contacts" + } + + it("trailing slash는 제거해 canonical path 하나만 반환한다") { + consoleProgramPath("/calendar-integrations/") shouldBe "/console/calendar-integrations" + consoleProgramPath("/console/booking/profile/") shouldBe "/console/booking/profile" + } + + it("service prefix가 다시 포함된 console path는 거부한다") { + val exception = + shouldThrow { + consoleProgramPath("/console/deskpie/companies") + } + + exception.message shouldBe "Console path must not include service prefix: deskpie" + } + } + }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerLoginTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerLoginTest.kt index 2f147aa9e..159457634 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerLoginTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerLoginTest.kt @@ -12,8 +12,8 @@ import io.deck.iam.service.AuthService import io.deck.iam.service.IdentityService import io.deck.iam.service.MenuService import io.deck.iam.service.OAuthProviderService +import io.deck.iam.service.PlatformSettingService import io.deck.iam.service.RoleService -import io.deck.iam.service.SystemSettingService import io.deck.iam.service.UserService import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldContainExactly @@ -34,7 +34,7 @@ class AuthControllerLoginTest : lateinit var menuService: MenuService lateinit var roleService: RoleService lateinit var jwtProperties: JwtProperties - lateinit var systemSettingService: SystemSettingService + lateinit var platformSettingService: PlatformSettingService lateinit var oauthProviderService: OAuthProviderService lateinit var controller: AuthController @@ -45,7 +45,7 @@ class AuthControllerLoginTest : menuService = mockk() roleService = mockk() jwtProperties = mockk() - systemSettingService = mockk() + platformSettingService = mockk() oauthProviderService = mockk() every { jwtProperties.cookieName } returns "deck_token" @@ -61,7 +61,7 @@ class AuthControllerLoginTest : menuService = menuService, roleService = roleService, jwtProperties = jwtProperties, - systemSettingService = systemSettingService, + platformSettingService = platformSettingService, oauthProviderService = oauthProviderService, ) } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerMeTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerMeTest.kt index 928aecb5a..3e4e9a2cb 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerMeTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerMeTest.kt @@ -12,8 +12,8 @@ import io.deck.iam.service.AuthService import io.deck.iam.service.IdentityService import io.deck.iam.service.MenuService import io.deck.iam.service.OAuthProviderService +import io.deck.iam.service.PlatformSettingService import io.deck.iam.service.RoleService -import io.deck.iam.service.SystemSettingService import io.deck.iam.service.UserService import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldContainExactly @@ -39,7 +39,7 @@ class AuthControllerMeTest : lateinit var menuService: MenuService lateinit var roleService: RoleService lateinit var jwtProperties: JwtProperties - lateinit var systemSettingService: SystemSettingService + lateinit var platformSettingService: PlatformSettingService lateinit var oauthProviderService: OAuthProviderService lateinit var controller: AuthController @@ -50,7 +50,7 @@ class AuthControllerMeTest : menuService = mockk() roleService = mockk() jwtProperties = mockk() - systemSettingService = mockk() + platformSettingService = mockk() oauthProviderService = mockk() every { jwtProperties.cookieName } returns "deck_token" @@ -66,7 +66,7 @@ class AuthControllerMeTest : menuService = menuService, roleService = roleService, jwtProperties = jwtProperties, - systemSettingService = systemSettingService, + platformSettingService = platformSettingService, oauthProviderService = oauthProviderService, ) } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerRefreshTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerRefreshTest.kt index b66a8c0ec..9aaf486b9 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerRefreshTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerRefreshTest.kt @@ -5,9 +5,9 @@ import io.deck.iam.service.AuthService import io.deck.iam.service.IdentityService import io.deck.iam.service.MenuService import io.deck.iam.service.OAuthProviderService +import io.deck.iam.service.PlatformSettingService import io.deck.iam.service.RefreshTokenResult import io.deck.iam.service.RoleService -import io.deck.iam.service.SystemSettingService import io.deck.iam.service.UserService import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -27,7 +27,7 @@ class AuthControllerRefreshTest : lateinit var menuService: MenuService lateinit var roleService: RoleService lateinit var jwtProperties: JwtProperties - lateinit var systemSettingService: SystemSettingService + lateinit var platformSettingService: PlatformSettingService lateinit var oauthProviderService: OAuthProviderService lateinit var controller: AuthController @@ -38,7 +38,7 @@ class AuthControllerRefreshTest : menuService = mockk() roleService = mockk() jwtProperties = mockk() - systemSettingService = mockk() + platformSettingService = mockk() oauthProviderService = mockk() every { jwtProperties.cookieName } returns "deck_token" @@ -54,7 +54,7 @@ class AuthControllerRefreshTest : menuService = menuService, roleService = roleService, jwtProperties = jwtProperties, - systemSettingService = systemSettingService, + platformSettingService = platformSettingService, oauthProviderService = oauthProviderService, ) } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt index 116c2de92..e1602d7c7 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt @@ -1,7 +1,7 @@ package io.deck.iam.controller -import io.deck.iam.domain.SystemSettingEntity -import io.deck.iam.service.SystemSettingService +import io.deck.iam.domain.PlatformSettingEntity +import io.deck.iam.service.PlatformSettingService import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.every @@ -12,7 +12,7 @@ import java.util.Base64 class BrandingControllerTest : DescribeSpec({ - lateinit var systemSettingService: SystemSettingService + lateinit var platformSettingService: PlatformSettingService lateinit var controller: BrandingController fun svgDataUri(svg: String): String { @@ -21,8 +21,8 @@ class BrandingControllerTest : } beforeEach { - systemSettingService = mockk(relaxed = true) - controller = BrandingController(systemSettingService) + platformSettingService = mockk(relaxed = true) + controller = BrandingController(platformSettingService) } describe("브랜딩 로고 서빙") { @@ -32,7 +32,7 @@ class BrandingControllerTest : val darkSvg = svgDataUri("") val faviconSvg = svgDataUri("") val settings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "DeskPie", logoHorizontalData = lightSvg, logoHorizontalDarkData = darkSvg, @@ -40,7 +40,7 @@ class BrandingControllerTest : faviconData = faviconSvg, ) - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings // when val response = controller.getBranding() @@ -57,7 +57,7 @@ class BrandingControllerTest : it("외부 URL이 있으면 리다이렉트해야 한다") { // given - every { systemSettingService.getSettings() } returns SystemSettingEntity(logoHorizontalUrl = "https://cdn.example.com/logo.svg") + every { platformSettingService.getSettings() } returns PlatformSettingEntity(logoHorizontalUrl = "https://cdn.example.com/logo.svg") // when val response = controller.getHorizontalLogo() @@ -71,9 +71,9 @@ class BrandingControllerTest : // given val svg = "" val data = svgDataUri(svg) - val settings = SystemSettingEntity(logoHorizontalData = data) + val settings = PlatformSettingEntity(logoHorizontalData = data) - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings // when val response = controller.getHorizontalLogo(data.hashCode().toString()) @@ -89,9 +89,9 @@ class BrandingControllerTest : // given val svg = "" val data = svgDataUri(svg) - val settings = SystemSettingEntity(logoHorizontalDarkData = data) + val settings = PlatformSettingEntity(logoHorizontalDarkData = data) - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings // when val response = controller.getHorizontalLogoDark() @@ -106,9 +106,9 @@ class BrandingControllerTest : // given val svg = "" val data = svgDataUri(svg) - val settings = SystemSettingEntity(logoPublicData = data) + val settings = PlatformSettingEntity(logoPublicData = data) - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings // when val response = controller.getPublicLogo("stale-version") @@ -120,7 +120,7 @@ class BrandingControllerTest : it("다크 로고가 없으면 다크 기본 에셋으로 폴백해야 한다") { // given - every { systemSettingService.getSettings() } returns SystemSettingEntity() + every { platformSettingService.getSettings() } returns PlatformSettingEntity() // when val response = controller.getHorizontalLogoDark() @@ -135,11 +135,11 @@ class BrandingControllerTest : val pngBytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) val data = Base64.getEncoder().encodeToString(pngBytes) val settings = - SystemSettingEntity( + PlatformSettingEntity( logoPublicData = data, ) - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings // when val response = controller.getPublicLogo(data.hashCode().toString()) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/MenuControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/MenuControllerTest.kt index 6c042091d..b38678403 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/MenuControllerTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/MenuControllerTest.kt @@ -1,8 +1,8 @@ package io.deck.iam.controller +import io.deck.iam.ManagementType import io.deck.iam.api.ProgramDefinition import io.deck.iam.domain.MenuEntity -import io.deck.iam.domain.WorkspaceManagedType import io.deck.iam.service.MenuService import io.deck.iam.service.PermissionRegistry import io.deck.iam.service.ProgramRegistry @@ -46,6 +46,7 @@ class MenuControllerTest : namesI18n = mapOf("en" to "Profile", "ko" to "프로필"), icon = "user", programType = "BOOKING_PROFILE", + managementType = ManagementType.PLATFORM_MANAGED, sortOrder = 0, ) val root = @@ -56,6 +57,7 @@ class MenuControllerTest : namesI18n = mapOf("en" to "Booking", "ko" to "예약"), icon = "calendar", programType = MenuEntity.NONE_PROGRAM_CODE, + managementType = ManagementType.USER_MANAGED, sortOrder = 0, children = mutableListOf(child), ) @@ -68,11 +70,37 @@ class MenuControllerTest : val response = controller.getMenuTree(roleId) response.body?.first()?.name shouldBe "예약" + response.body?.first()?.managementType shouldBe ManagementType.USER_MANAGED response.body ?.first() ?.children ?.first() ?.name shouldBe "프로필" + response.body + ?.first() + ?.children + ?.first() + ?.managementType shouldBe ManagementType.PLATFORM_MANAGED + } + + it("console program 메뉴는 PLATFORM_MANAGED를 반환한다") { + val roleId = UUID.randomUUID() + val menu = + MenuEntity( + roleId = roleId, + name = "Users", + namesI18n = mapOf("en" to "Users", "ko" to "사용자"), + icon = "users", + programType = "USER_MANAGEMENT", + managementType = ManagementType.PLATFORM_MANAGED, + sortOrder = 0, + ) + + every { menuService.findMenuTreeByRoleId(roleId) } returns listOf(menu) + + val response = controller.getMenuTree(roleId) + + response.body?.first()?.managementType shouldBe ManagementType.PLATFORM_MANAGED } } @@ -83,13 +111,9 @@ class MenuControllerTest : listOf( ProgramDefinition( code = "MY_WORKSPACE", - path = "/my-workspaces", + path = "/console/my-workspaces", permissions = setOf("MY_WORKSPACE_READ"), - workspace = - ProgramDefinition.WorkspacePolicy( - required = true, - managedType = WorkspaceManagedType.USER_MANAGED, - ), + workspace = ProgramDefinition.WorkspacePolicy(required = true), ), ) @@ -101,13 +125,9 @@ class MenuControllerTest : listOf( ProgramDto( code = "MY_WORKSPACE", - path = "/my-workspaces", + path = "/console/my-workspaces", permissions = setOf("MY_WORKSPACE_READ"), - workspace = - ProgramWorkspacePolicyDto( - required = true, - managedType = WorkspaceManagedType.USER_MANAGED, - ), + workspace = ProgramWorkspacePolicyDto(required = true), ), ) } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/SystemSettingAuthProviderControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderControllerTest.kt similarity index 94% rename from backend/iam/src/test/kotlin/io/deck/iam/controller/SystemSettingAuthProviderControllerTest.kt rename to backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderControllerTest.kt index 73a11183a..1f19cf08c 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/SystemSettingAuthProviderControllerTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderControllerTest.kt @@ -10,14 +10,14 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify -class SystemSettingAuthProviderControllerTest : +class PlatformSettingAuthProviderControllerTest : DescribeSpec({ lateinit var oauthProviderService: OAuthProviderService - lateinit var controller: SystemSettingAuthProviderController + lateinit var controller: PlatformSettingAuthProviderController beforeEach { oauthProviderService = mockk(relaxed = true) - controller = SystemSettingAuthProviderController(oauthProviderService) + controller = PlatformSettingAuthProviderController(oauthProviderService) } describe("owner auth provider settings") { diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/SystemSettingControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingControllerTest.kt similarity index 73% rename from backend/iam/src/test/kotlin/io/deck/iam/controller/SystemSettingControllerTest.kt rename to backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingControllerTest.kt index 65862ea5b..07d1163c6 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/SystemSettingControllerTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingControllerTest.kt @@ -3,9 +3,9 @@ package io.deck.iam.controller import io.deck.iam.domain.CountryPolicy import io.deck.iam.domain.CurrencyPolicy import io.deck.iam.domain.LogoType -import io.deck.iam.domain.SystemSettingEntity +import io.deck.iam.domain.PlatformSettingEntity import io.deck.iam.domain.WorkspacePolicy -import io.deck.iam.service.SystemSettingService +import io.deck.iam.service.PlatformSettingService import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -17,10 +17,10 @@ import org.springframework.security.access.AccessDeniedException import java.security.Principal import java.util.UUID -class SystemSettingControllerTest : +class PlatformSettingControllerTest : DescribeSpec({ - lateinit var systemSettingService: SystemSettingService - lateinit var controller: SystemSettingController + lateinit var platformSettingService: PlatformSettingService + lateinit var controller: PlatformSettingController fun principal(userId: UUID = UUID.randomUUID()): Principal = mockk { @@ -28,8 +28,8 @@ class SystemSettingControllerTest : } beforeEach { - systemSettingService = mockk(relaxed = true) - controller = SystemSettingController(systemSettingService, "https://deck.test") + platformSettingService = mockk(relaxed = true) + controller = PlatformSettingController(platformSettingService, "https://deck.test") } describe("브랜딩 로고 URL 응답") { @@ -41,7 +41,7 @@ class SystemSettingControllerTest : val faviconLight = "data:image/svg+xml;base64,PHN2ZyBmYXZpY29uLWxpZ2h0Pjwvc3ZnPg==" val faviconDark = "data:image/svg+xml;base64,PHN2ZyBmYXZpY29uLWRhcms+PC9zdmc+" val settings = - SystemSettingEntity( + PlatformSettingEntity( logoHorizontalData = lightLogo, logoHorizontalDarkData = darkLogo, logoPublicData = publicLogo, @@ -49,29 +49,29 @@ class SystemSettingControllerTest : faviconDarkData = faviconDark, ) - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings // when val response = controller.get() // then response.statusCode shouldBe HttpStatus.OK - response.body?.logoHorizontalUrl shouldBe "/api/v1/system-settings/logo/HORIZONTAL?v=${lightLogo.hashCode()}" + response.body?.logoHorizontalUrl shouldBe "/api/v1/platform-settings/logo/HORIZONTAL?v=${lightLogo.hashCode()}" response.body?.logoHorizontalDarkUrl shouldBe - "/api/v1/system-settings/logo/HORIZONTAL?dark=true&v=${darkLogo.hashCode()}" - response.body?.logoPublicUrl shouldBe "/api/v1/system-settings/logo/PUBLIC?v=${publicLogo.hashCode()}" - response.body?.faviconUrl shouldBe "/api/v1/system-settings/logo/FAVICON?v=${faviconLight.hashCode()}" + "/api/v1/platform-settings/logo/HORIZONTAL?dark=true&v=${darkLogo.hashCode()}" + response.body?.logoPublicUrl shouldBe "/api/v1/platform-settings/logo/PUBLIC?v=${publicLogo.hashCode()}" + response.body?.faviconUrl shouldBe "/api/v1/platform-settings/logo/FAVICON?v=${faviconLight.hashCode()}" response.body?.faviconDarkUrl shouldBe - "/api/v1/system-settings/logo/FAVICON?dark=true&v=${faviconDark.hashCode()}" + "/api/v1/platform-settings/logo/FAVICON?dark=true&v=${faviconDark.hashCode()}" } it("업로드 로고는 backend 서빙 URL을 반환해야 한다") { // given val ownerId = UUID.randomUUID() val request = UploadLogoRequest("data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=") - val settings = SystemSettingEntity(logoHorizontalData = request.data) + val settings = PlatformSettingEntity(logoHorizontalData = request.data) - every { systemSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, request.data, false) } returns settings + every { platformSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, request.data, false) } returns settings // when val response = controller.uploadLogo(LogoType.HORIZONTAL, request, dark = false, principal = principal(ownerId)) @@ -79,17 +79,17 @@ class SystemSettingControllerTest : // then response.statusCode shouldBe HttpStatus.OK response.body?.logoHorizontalUrl shouldBe - "/api/v1/system-settings/logo/HORIZONTAL?v=${request.data.hashCode()}" - verify { systemSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, request.data, false) } + "/api/v1/platform-settings/logo/HORIZONTAL?v=${request.data.hashCode()}" + verify { platformSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, request.data, false) } } it("다크 로고 업로드는 dark 쿼리가 포함된 URL을 반환해야 한다") { // given val ownerId = UUID.randomUUID() val request = UploadLogoRequest("data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=") - val settings = SystemSettingEntity(logoHorizontalDarkData = request.data) + val settings = PlatformSettingEntity(logoHorizontalDarkData = request.data) - every { systemSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, request.data, true) } returns settings + every { platformSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, request.data, true) } returns settings // when val response = controller.uploadLogo(LogoType.HORIZONTAL, request, dark = true, principal = principal(ownerId)) @@ -97,17 +97,17 @@ class SystemSettingControllerTest : // then response.statusCode shouldBe HttpStatus.OK response.body?.logoHorizontalDarkUrl shouldBe - "/api/v1/system-settings/logo/HORIZONTAL?dark=true&v=${request.data.hashCode()}" - verify { systemSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, request.data, true) } + "/api/v1/platform-settings/logo/HORIZONTAL?dark=true&v=${request.data.hashCode()}" + verify { platformSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, request.data, true) } } it("외부 URL 등록은 해당 URL을 그대로 반환해야 한다") { // given val ownerId = UUID.randomUUID() val request = SetLogoUrlRequest("https://cdn.example.com/logo-light.svg") - val settings = SystemSettingEntity(logoHorizontalUrl = request.url) + val settings = PlatformSettingEntity(logoHorizontalUrl = request.url) - every { systemSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, request.url, false) } returns settings + every { platformSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, request.url, false) } returns settings // when val response = controller.setLogoUrl(LogoType.HORIZONTAL, request, dark = false, principal = principal(ownerId)) @@ -115,7 +115,7 @@ class SystemSettingControllerTest : // then response.statusCode shouldBe HttpStatus.OK response.body?.logoHorizontalUrl shouldBe "https://cdn.example.com/logo-light.svg" - verify { systemSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, request.url, false) } + verify { platformSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, request.url, false) } } it("Owner가 아니면 로고 업로드를 거부해야 한다") { @@ -124,7 +124,7 @@ class SystemSettingControllerTest : val request = UploadLogoRequest("data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=") every { - systemSettingService.uploadLogo(userId, LogoType.HORIZONTAL, request.data, false) + platformSettingService.uploadLogo(userId, LogoType.HORIZONTAL, request.data, false) } throws AccessDeniedException("Owner permission required") // when & then @@ -140,14 +140,20 @@ class SystemSettingControllerTest : val ownerId = UUID.randomUUID() val request = UpdateGeneralSettingsRequest(brandName = "Deck Next", contactEmail = "privacy@deck.io") val settings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Next", contactEmail = "privacy@deck.io", - workspacePolicy = WorkspacePolicy(useUserManaged = true, useSystemManaged = false, useSelector = true), + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = false, + useSelector = true, + ), ) every { - systemSettingService.updateGeneral( + platformSettingService.updateGeneral( userId = ownerId, brandName = "Deck Next", contactEmail = "privacy@deck.io", @@ -162,7 +168,7 @@ class SystemSettingControllerTest : response.body?.brandName shouldBe "Deck Next" response.body?.contactEmail shouldBe "privacy@deck.io" verify { - systemSettingService.updateGeneral( + platformSettingService.updateGeneral( userId = ownerId, brandName = "Deck Next", contactEmail = "privacy@deck.io", @@ -178,20 +184,33 @@ class SystemSettingControllerTest : workspacePolicy = WorkspacePolicyDto( useUserManaged = true, - useSystemManaged = false, + usePlatformManaged = false, + useExternalSync = false, useSelector = true, ), ) val settings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Next", - workspacePolicy = WorkspacePolicy(useUserManaged = true, useSystemManaged = false, useSelector = true), + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = false, + useSelector = true, + ), ) every { - systemSettingService.updateWorkspacePolicy( + platformSettingService.updateWorkspacePolicy( userId = ownerId, - workspacePolicy = WorkspacePolicy(useUserManaged = true, useSystemManaged = false, useSelector = true), + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = false, + useSelector = true, + ), ) } returns settings @@ -202,9 +221,15 @@ class SystemSettingControllerTest : response.statusCode shouldBe HttpStatus.OK response.body?.workspacePolicy shouldBe request.workspacePolicy verify { - systemSettingService.updateWorkspacePolicy( + platformSettingService.updateWorkspacePolicy( userId = ownerId, - workspacePolicy = WorkspacePolicy(useUserManaged = true, useSystemManaged = false, useSelector = true), + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = false, + useSelector = true, + ), ) } } @@ -221,13 +246,13 @@ class SystemSettingControllerTest : ), ) val settings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Next", countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "KR"), ) every { - systemSettingService.updateCountryPolicy( + platformSettingService.updateCountryPolicy( userId = ownerId, countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "KR"), ) @@ -240,7 +265,7 @@ class SystemSettingControllerTest : response.statusCode shouldBe HttpStatus.OK response.body?.countryPolicy shouldBe request.countryPolicy verify { - systemSettingService.updateCountryPolicy( + platformSettingService.updateCountryPolicy( userId = ownerId, countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "KR"), ) @@ -259,7 +284,7 @@ class SystemSettingControllerTest : ), ) val settings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Next", currencyPolicy = CurrencyPolicy( @@ -269,7 +294,7 @@ class SystemSettingControllerTest : ) every { - systemSettingService.updateCurrencyPolicy( + platformSettingService.updateCurrencyPolicy( userId = ownerId, currencyPolicy = CurrencyPolicy( @@ -286,7 +311,7 @@ class SystemSettingControllerTest : response.statusCode shouldBe HttpStatus.OK response.body?.currencyPolicy shouldBe request.currencyPolicy verify { - systemSettingService.updateCurrencyPolicy( + platformSettingService.updateCurrencyPolicy( userId = ownerId, currencyPolicy = CurrencyPolicy( @@ -313,7 +338,7 @@ class SystemSettingControllerTest : ), ) val settings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Next", countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "US"), currencyPolicy = @@ -324,7 +349,7 @@ class SystemSettingControllerTest : ) every { - systemSettingService.updateGlobalizationPolicy( + platformSettingService.updateGlobalizationPolicy( userId = ownerId, countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "US"), currencyPolicy = @@ -341,7 +366,7 @@ class SystemSettingControllerTest : response.body?.countryPolicy shouldBe request.countryPolicy response.body?.currencyPolicy shouldBe request.currencyPolicy verify { - systemSettingService.updateGlobalizationPolicy( + platformSettingService.updateGlobalizationPolicy( userId = ownerId, countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "US"), currencyPolicy = diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/UserControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/UserControllerTest.kt index 996fae5c2..c4004e067 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/UserControllerTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/UserControllerTest.kt @@ -14,8 +14,8 @@ import io.deck.globalization.contactfield.api.ContactPhonePresentationFormat import io.deck.globalization.contactfield.api.ContactPhoneStorageFormat import io.deck.globalization.contactfield.api.CountryContactFieldPolicy import io.deck.iam.domain.CountryPolicy +import io.deck.iam.domain.PlatformSettingEntity import io.deck.iam.domain.RoleEntity -import io.deck.iam.domain.SystemSettingEntity import io.deck.iam.domain.UserEntity import io.deck.iam.domain.UserId import io.deck.iam.domain.UserStatus @@ -23,11 +23,11 @@ import io.deck.iam.security.OwnerOnly import io.deck.iam.service.IdentityService import io.deck.iam.service.LoginHistoryService import io.deck.iam.service.OwnerService +import io.deck.iam.service.PlatformSettingService import io.deck.iam.service.ResolvedUserAddress import io.deck.iam.service.ResolvedUserContactProfile import io.deck.iam.service.ResolvedUserPhoneNumber import io.deck.iam.service.RoleService -import io.deck.iam.service.SystemSettingService import io.deck.iam.service.UserAddressInput import io.deck.iam.service.UserContactProfileInput import io.deck.iam.service.UserIdentifierInput @@ -50,7 +50,7 @@ class UserControllerTest : lateinit var roleService: RoleService lateinit var loginHistoryService: LoginHistoryService lateinit var ownerService: OwnerService - lateinit var systemSettingService: SystemSettingService + lateinit var platformSettingService: PlatformSettingService lateinit var contactFieldQuery: ContactFieldQuery lateinit var controller: UserController @@ -184,13 +184,13 @@ class UserControllerTest : roleService = mockk(relaxed = true) loginHistoryService = mockk(relaxed = true) ownerService = mockk(relaxed = true) - systemSettingService = mockk(relaxed = true) + platformSettingService = mockk(relaxed = true) contactFieldQuery = mockk(relaxed = true) every { userService.resolveContactProfile(any()) } returns resolvedContactProfile() - every { systemSettingService.getSettings() } returns - SystemSettingEntity( + every { platformSettingService.getSettings() } returns + PlatformSettingEntity( countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "KR"), ) every { contactFieldQuery.resolve(any()) } returns defaultContactFieldConfig() @@ -202,7 +202,7 @@ class UserControllerTest : roleService, loginHistoryService, ownerService, - systemSettingService, + platformSettingService, contactFieldQuery, ) } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/domain/ManagementTypeContractTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/domain/ManagementTypeContractTest.kt new file mode 100644 index 000000000..cbd4ac1fc --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/domain/ManagementTypeContractTest.kt @@ -0,0 +1,36 @@ +package io.deck.iam.domain + +import io.deck.iam.ManagementType +import io.deck.iam.api.ProgramDefinition +import io.deck.iam.api.WorkspaceRecord +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor + +class ManagementTypeContractTest : + DescribeSpec({ + it("workspace, menu, api contract는 공용 ManagementType을 사용해야 한다") { + WorkspaceEntity::class + .memberProperties + .single { it.name == "managedType" } + .returnType.classifier shouldBe + ManagementType::class + MenuEntity::class + .memberProperties + .single { it.name == "managementType" } + .returnType.classifier shouldBe + ManagementType::class + WorkspaceRecord::class + .memberProperties + .single { it.name == "managedType" } + .returnType.classifier shouldBe + ManagementType::class + ProgramDefinition.WorkspacePolicy::class + .primaryConstructor + ?.parameters + ?.single { it.name == "managedType" } + ?.type + ?.classifier shouldBe ManagementType::class + } + }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceEntityTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceEntityTest.kt index 57f137c8c..1eb3edf26 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceEntityTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceEntityTest.kt @@ -1,5 +1,8 @@ package io.deck.iam.domain +import io.deck.iam.ManagementType +import io.deck.iam.domain.ExternalSource.AIP +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -9,7 +12,31 @@ class WorkspaceEntityTest : it("기본값은 USER_MANAGED다") { val workspace = WorkspaceEntity(name = "기본 워크스페이스") - workspace.managedType shouldBe WorkspaceManagedType.USER_MANAGED + workspace.managedType shouldBe ManagementType.USER_MANAGED + } + } + + describe("externalReference") { + it("externalReference가 있으면 external workspace로 판단한다") { + val workspace = + WorkspaceEntity( + name = "AIP Workspace", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(source = AIP, externalId = "aip-org-1"), + ) + + workspace.isExternal shouldBe true + workspace.externalReference?.externalId shouldBe "aip-org-1" + } + + it("external workspace는 PLATFORM_MANAGED가 아니면 생성할 수 없다") { + shouldThrow { + WorkspaceEntity( + name = "AIP Workspace", + managedType = ManagementType.USER_MANAGED, + externalReference = ExternalReference(source = AIP, externalId = "aip-org-1"), + ) + } } } @@ -24,12 +51,29 @@ class WorkspaceEntityTest : workspace.update( name = "새 이름", description = "새 설명", - managedType = WorkspaceManagedType.SYSTEM_MANAGED, + managedType = ManagementType.PLATFORM_MANAGED, ) workspace.name shouldBe "새 이름" workspace.description shouldBe "새 설명" - workspace.managedType shouldBe WorkspaceManagedType.SYSTEM_MANAGED + workspace.managedType shouldBe ManagementType.PLATFORM_MANAGED + } + + it("external workspace를 USER_MANAGED로 바꾸려 하면 거부해야 한다") { + val workspace = + WorkspaceEntity( + name = "AIP Workspace", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(source = AIP, externalId = "aip-org-1"), + ) + + shouldThrow { + workspace.update( + name = "새 이름", + description = "새 설명", + managedType = ManagementType.USER_MANAGED, + ) + } } } }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceInviteEntityTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceInviteEntityTest.kt index b406c3282..c40a4dfe6 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceInviteEntityTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceInviteEntityTest.kt @@ -13,6 +13,7 @@ class WorkspaceInviteEntityTest : fun createInvite( status: InviteStatus = InviteStatus.PENDING, expiresAt: Instant = Instant.now().plus(1, ChronoUnit.DAYS), + inviterId: UUID = UUID.randomUUID(), ) = WorkspaceInviteEntity( workspaceId = UUID.randomUUID(), email = "invite@example.com", @@ -20,6 +21,7 @@ class WorkspaceInviteEntityTest : status = status, expiresAt = expiresAt, message = "초대 메시지", + inviterId = inviterId, ) describe("accept") { @@ -141,28 +143,30 @@ class WorkspaceInviteEntityTest : } } - describe("rotateToken") { - it("PENDING 상태에서 tokenHash와 expiresAt을 갱신한다") { + describe("resend") { + it("PENDING 상태에서 tokenHash와 expiresAt, inviterId를 갱신한다") { // given val invite = createInvite() val newTokenHash = "new-token-hash" val newExpiry = Instant.now().plus(3, ChronoUnit.DAYS) + val resendBy = UUID.randomUUID() // when - invite.rotateToken(newTokenHash, newExpiry) + invite.resend(newTokenHash, newExpiry, resendBy) // then invite.tokenHash shouldBe newTokenHash invite.expiresAt shouldBe newExpiry + invite.inviterId shouldBe resendBy } - it("PENDING이 아닌 상태에서 rotateToken을 호출하면 IllegalArgumentException이 발생한다") { + it("PENDING이 아닌 상태에서 resend를 호출하면 IllegalArgumentException이 발생한다") { // given val invite = createInvite(status = InviteStatus.ACCEPTED) // when & then shouldThrow { - invite.rotateToken("new-token-hash", Instant.now().plus(1, ChronoUnit.DAYS)) + invite.resend("new-token-hash", Instant.now().plus(1, ChronoUnit.DAYS), UUID.randomUUID()) } } } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2LinkingTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2LinkingTest.kt index c23303217..9109ac7e3 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2LinkingTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2LinkingTest.kt @@ -3,6 +3,9 @@ package io.deck.iam.security import io.deck.iam.api.event.OAuthIdentityLinkedEvent import io.deck.iam.config.JwtProperties import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalSource import io.deck.iam.domain.UserEntity import io.deck.iam.domain.UserStatus import io.deck.iam.service.AuthErrorType @@ -63,13 +66,14 @@ class OAuth2LinkingTest : email: String = "oauth@gmail.com", sub: String = "google-sub-123", name: String = "OAuth User", + extraClaims: Map = emptyMap(), ): DefaultOidcUser { val claims = - mapOf( + mutableMapOf( "sub" to sub, "email" to email, "name" to name, - ) + ).apply { putAll(extraClaims) } val idToken = OidcIdToken .withTokenValue("fake-token") @@ -155,11 +159,11 @@ class OAuth2LinkingTest : } handler.javaClass.getDeclaredField("linkSuccessUri").apply { isAccessible = true - set(handler, "/account/settings?linked=true") + set(handler, "/settings/account/profile?linked=true") } handler.javaClass.getDeclaredField("linkErrorUri").apply { isAccessible = true - set(handler, "/account/settings?error=") + set(handler, "/settings/account/profile?error=") } } @@ -168,6 +172,61 @@ class OAuth2LinkingTest : // ============================================ describe("일반 OAuth 로그인") { + it("AIP 로그인 시 external organization claims를 AuthService로 전달한다") { + val user = createTestUser() + val oidcUser = + createOidcUser( + email = "aip@example.com", + sub = "aip-sub-123", + name = "AIP User", + extraClaims = + mapOf( + "organizations" to + listOf( + mapOf("id" to "aip-org-1", "name" to "Acme"), + mapOf("id" to "aip-org-2", "name" to "Beta", "description" to "Beta team"), + ), + ), + ) + val authentication = createAuthentication(oidcUser, registrationId = "aip") + val request = mockRequest() + val response = mockResponse() + val externalOrganizations = + listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta", description = "Beta team"), + ) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations) + + every { userService.validateOAuthLogin(AuthProvider.AIP, "aip-sub-123", "aip@example.com") } returns null + every { + authService.authenticateWithOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-sub-123", + email = "aip@example.com", + name = "AIP User", + ipAddress = "127.0.0.1", + userAgent = "TestAgent", + externalOrganizationSync = authoritativeSnapshot, + ) + } returns AuthResult.Success(user, "jwt-token") + + handler.onAuthenticationSuccess(request, response, authentication) + + verify { + authService.authenticateWithOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-sub-123", + email = "aip@example.com", + name = "AIP User", + ipAddress = "127.0.0.1", + userAgent = "TestAgent", + externalOrganizationSync = authoritativeSnapshot, + ) + } + verify { response.sendRedirect("/") } + } + it("AuthService 호출 성공 시 쿠키 설정 후 리다이렉트") { // given val user = createTestUser() @@ -185,6 +244,7 @@ class OAuth2LinkingTest : name = "OAuth User", ipAddress = "127.0.0.1", userAgent = "TestAgent", + externalOrganizationSync = ExternalOrganizationSync.NoSync, ) } returns AuthResult.Success(user, "jwt-token") @@ -200,6 +260,7 @@ class OAuth2LinkingTest : name = "OAuth User", ipAddress = "127.0.0.1", userAgent = "TestAgent", + externalOrganizationSync = ExternalOrganizationSync.NoSync, ) } verify { response.sendRedirect("/") } @@ -214,7 +275,7 @@ class OAuth2LinkingTest : every { userService.validateOAuthLogin(any(), any(), any()) } returns null every { - authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any()) + authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any(), any()) } returns AuthResult.Failure(AuthErrorType.ACCOUNT_LOCKED, "Account is locked") // when @@ -240,6 +301,7 @@ class OAuth2LinkingTest : name = "OAuth User", ipAddress = "127.0.0.1", userAgent = "TestAgent", + externalOrganizationSync = ExternalOrganizationSync.NoSync, ) } returns AuthResult.TwoFactorRequired(user, "two-factor-token") @@ -273,7 +335,7 @@ class OAuth2LinkingTest : ) } verify { response.sendRedirect(match { it.contains("/login?error=") }) } - verify(exactly = 0) { authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any()) } + verify(exactly = 0) { authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any(), any()) } } it("예외 발생 시 실패 로그 기록 후 에러 페이지로 리다이렉트") { @@ -285,7 +347,7 @@ class OAuth2LinkingTest : every { userService.validateOAuthLogin(any(), any(), any()) } returns null every { - authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any()) + authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any(), any()) } throws RuntimeException("Unexpected error") // when @@ -341,7 +403,7 @@ class OAuth2LinkingTest : ) } verify { response.sendRedirect("/oauth-callback/?status=success") } - verify(exactly = 0) { authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any()) } + verify(exactly = 0) { authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any(), any()) } } it("calendar registration에 intent가 없으면 oauth-callback error로 리다이렉트한다") { @@ -401,14 +463,14 @@ class OAuth2LinkingTest : false, ) } - verify(exactly = 0) { authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any()) } + verify(exactly = 0) { authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any(), any()) } verify { response.addHeader( HttpHeaders.SET_COOKIE, match { it.startsWith("${OAuthFlowCookieService.INTENT_COOKIE_NAME}=") && it.contains("Max-Age=0") }, ) } - verify { response.sendRedirect("/account/settings?linked=true") } + verify { response.sendRedirect("/settings/account/profile?linked=true") } } it("intent가 없으면 일반 로그인으로 처리한다") { @@ -428,6 +490,7 @@ class OAuth2LinkingTest : name = "OAuth User", ipAddress = "127.0.0.1", userAgent = "TestAgent", + externalOrganizationSync = ExternalOrganizationSync.NoSync, ) } returns AuthResult.Success(user, "jwt-token") @@ -435,7 +498,7 @@ class OAuth2LinkingTest : handler.onAuthenticationSuccess(request, response, authentication) // then - verify { authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any()) } + verify { authService.authenticateWithOAuth(any(), any(), any(), any(), any(), any(), any()) } verify { response.sendRedirect("/") } verify(exactly = 0) { identityService.createOAuthIdentity(any(), any(), any(), any(), any()) } } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2UserInfoExtractorTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2UserInfoExtractorTest.kt new file mode 100644 index 000000000..19738884f --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2UserInfoExtractorTest.kt @@ -0,0 +1,68 @@ +package io.deck.iam.security + +import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalSource +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.springframework.security.oauth2.core.oidc.OidcIdToken +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser +import java.time.Instant + +class OAuth2UserInfoExtractorTest : + DescribeSpec({ + fun createOidcUser(claims: Map = emptyMap()): DefaultOidcUser { + val idToken = + OidcIdToken( + "token", + Instant.now(), + Instant.now().plusSeconds(3600), + mapOf( + "sub" to "aip-sub-123", + "email" to "aip@example.com", + "name" to "AIP User", + ) + claims.filterValues { it != null }, + ) + return DefaultOidcUser(emptyList(), idToken) + } + + val extractor = OAuth2UserInfoExtractor.of(AuthProvider.AIP) + + describe("AipExtractor external organization sync contract") { + it("organization claim이 없으면 sync unavailable로 해석한다") { + val result = extractor.extract(createOidcUser()) + + result.externalOrganizationSync shouldBe ExternalOrganizationSync.Unavailable + } + + it("collection claim이 있어도 유효한 organization entry를 하나도 파싱하지 못하면 sync unavailable로 해석한다") { + val result = + extractor.extract( + createOidcUser( + mapOf( + "organizations" to listOf(emptyMap(), mapOf("name" to "Acme")), + ), + ), + ) + + result.externalOrganizationSync shouldBe ExternalOrganizationSync.Unavailable + } + + it("빈 organizations collection은 authoritative empty snapshot으로 해석한다") { + val result = + extractor.extract( + createOidcUser( + mapOf( + "organizations" to emptyList>(), + ), + ), + ) + + result.externalOrganizationSync shouldBe + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = emptyList(), + ) + } + } + }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/security/OwnerOnlyAnnotationTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/security/OwnerOnlyAnnotationTest.kt index 8782aa157..e3b5d1bcb 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/security/OwnerOnlyAnnotationTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/security/OwnerOnlyAnnotationTest.kt @@ -1,7 +1,7 @@ package io.deck.iam.security -import io.deck.iam.controller.SystemSettingAuthProviderController -import io.deck.iam.controller.SystemSettingController +import io.deck.iam.controller.PlatformSettingAuthProviderController +import io.deck.iam.controller.PlatformSettingController import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -16,13 +16,13 @@ class OwnerOnlyAnnotationTest : OwnerOnly::class.findAnnotation()?.value shouldBe "@ownerService.isOwner(authentication.name)" } - it("SystemSettingController의 owner-only write 메서드에 적용되어야 한다") { - val method = SystemSettingController::class.declaredMemberFunctions.first { it.name == "updateAuthSettings" } + it("PlatformSettingController의 owner-only write 메서드에 적용되어야 한다") { + val method = PlatformSettingController::class.declaredMemberFunctions.first { it.name == "updateAuthSettings" } method.findAnnotation() shouldNotBe null } - it("SystemSettingAuthProviderController의 owner-only read 메서드에 적용되어야 한다") { - val method = SystemSettingAuthProviderController::class.declaredMemberFunctions.first { it.name == "getAll" } + it("PlatformSettingAuthProviderController의 owner-only read 메서드에 적용되어야 한다") { + val method = PlatformSettingAuthProviderController::class.declaredMemberFunctions.first { it.name == "getAll" } method.findAnnotation() shouldNotBe null } } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/AuthServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/AuthServiceTest.kt index b5b7463b5..2d16c55f0 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/AuthServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/AuthServiceTest.kt @@ -4,6 +4,9 @@ import io.deck.crypto.service.JwtService import io.deck.crypto.service.TokenResult import io.deck.iam.config.JwtProperties import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalSource import io.deck.iam.domain.IdentityEntity import io.deck.iam.domain.SessionType import io.deck.iam.domain.UserEntity @@ -25,6 +28,7 @@ import java.util.UUID class AuthServiceTest : DescribeSpec({ lateinit var userService: UserService + lateinit var oAuthLoginProvisioningService: OAuthLoginProvisioningService lateinit var identityService: IdentityService lateinit var loginHistoryService: LoginHistoryService lateinit var sessionService: SessionService @@ -67,6 +71,7 @@ class AuthServiceTest : beforeEach { userService = mockk() + oAuthLoginProvisioningService = mockk() identityService = mockk() loginHistoryService = mockk(relaxed = true) sessionService = mockk(relaxed = true) @@ -82,6 +87,7 @@ class AuthServiceTest : authService = AuthService( userService = userService, + oAuthLoginProvisioningService = oAuthLoginProvisioningService, identityService = identityService, loginHistoryService = loginHistoryService, sessionService = sessionService, @@ -297,6 +303,132 @@ class AuthServiceTest : } describe("authenticateWithOAuth") { + it("AIP external organization claims를 OAuth provisioning service로 전달한다") { + val user = createUser() + val sessionId = UUID.randomUUID() + val session = createSession(sessionId = sessionId, userId = user.id) + val tokenResult = + TokenResult( + token = "oauth-token", + jti = sessionId.toString(), + expiresAt = session.idleExpiresAt, + ) + val externalOrganizations = + listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta", description = "Beta team"), + ) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations) + + every { + oAuthLoginProvisioningService.resolveUser( + AuthProvider.AIP, + "aip-sub", + "oauth@example.com", + "OAuth User", + authoritativeSnapshot, + ) + } returns OAuthLoginProvisioningResult(user = user) + every { + sessionService.createPending( + sessionId = any(), + userId = user.id, + sessionType = SessionType.WEB, + clientId = "web", + ipAddress = "192.168.1.1", + userAgent = "Mozilla/5.0", + idleExpiresAt = any(), + absoluteExpiresAt = any(), + deviceId = null, + ) + } returns session + every { sessionService.activate(sessionId, null) } returns session + every { jwtService.reissueToken(user.id.toString(), any(), sessionId.toString(), session.idleExpiresAt) } returns tokenResult + + val result = + authService.authenticateWithOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-sub", + email = "oauth@example.com", + name = "OAuth User", + ipAddress = "192.168.1.1", + userAgent = "Mozilla/5.0", + externalOrganizationSync = authoritativeSnapshot, + ) + + result.shouldBeInstanceOf() + verify { + oAuthLoginProvisioningService.resolveUser( + AuthProvider.AIP, + "aip-sub", + "oauth@example.com", + "OAuth User", + authoritativeSnapshot, + ) + } + } + + it("AIP empty snapshot도 authoritative sync로 전달한다") { + val user = createUser() + val sessionId = UUID.randomUUID() + val session = createSession(sessionId = sessionId, userId = user.id) + val tokenResult = + TokenResult( + token = "oauth-token", + jti = sessionId.toString(), + expiresAt = session.idleExpiresAt, + ) + + val authoritativeEmptySnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, emptyList()) + + every { + oAuthLoginProvisioningService.resolveUser( + AuthProvider.AIP, + "aip-empty-sub", + "oauth@example.com", + "OAuth User", + authoritativeEmptySnapshot, + ) + } returns OAuthLoginProvisioningResult(user = user) + every { + sessionService.createPending( + sessionId = any(), + userId = user.id, + sessionType = SessionType.WEB, + clientId = "web", + ipAddress = "192.168.1.1", + userAgent = "Mozilla/5.0", + idleExpiresAt = any(), + absoluteExpiresAt = any(), + deviceId = null, + ) + } returns session + every { sessionService.activate(sessionId, null) } returns session + every { jwtService.reissueToken(user.id.toString(), any(), sessionId.toString(), session.idleExpiresAt) } returns tokenResult + + val result = + authService.authenticateWithOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-empty-sub", + email = "oauth@example.com", + name = "OAuth User", + ipAddress = "192.168.1.1", + userAgent = "Mozilla/5.0", + externalOrganizationSync = authoritativeEmptySnapshot, + ) + + result.shouldBeInstanceOf() + verify { + oAuthLoginProvisioningService.resolveUser( + AuthProvider.AIP, + "aip-empty-sub", + "oauth@example.com", + "OAuth User", + authoritativeEmptySnapshot, + ) + } + } + it("OAuth 로그인 성공 시도도 세션 중심 토큰 발급을 사용한다") { val user = createUser() val sessionId = UUID.randomUUID() @@ -309,8 +441,14 @@ class AuthServiceTest : ) every { - userService.findOrCreateByOAuth(AuthProvider.GOOGLE, "google-sub", "oauth@example.com", "OAuth User") - } returns user + oAuthLoginProvisioningService.resolveUser( + AuthProvider.GOOGLE, + "google-sub", + "oauth@example.com", + "OAuth User", + ExternalOrganizationSync.NoSync, + ) + } returns OAuthLoginProvisioningResult(user = user) every { sessionService.createPending( sessionId = any(), @@ -346,8 +484,18 @@ class AuthServiceTest : it("잠긴 OAuth 사용자는 세션을 만들지 않고 ACCOUNT_LOCKED를 반환한다") { val user = createUser(status = UserStatus.LOCKED) every { - userService.findOrCreateByOAuth(AuthProvider.GOOGLE, "google-sub", "oauth@example.com", "OAuth User") - } returns user + oAuthLoginProvisioningService.resolveUser( + AuthProvider.GOOGLE, + "google-sub", + "oauth@example.com", + "OAuth User", + ExternalOrganizationSync.NoSync, + ) + } returns + OAuthLoginProvisioningResult( + user = user, + statusError = OAuthLoginStatusError("ACCOUNT_LOCKED", AuthErrorType.ACCOUNT_LOCKED, "Account is locked"), + ) val result = authService.authenticateWithOAuth( @@ -364,14 +512,62 @@ class AuthServiceTest : verify(exactly = 0) { sessionService.createPending(any(), any(), any(), any(), any(), any(), any(), any(), any()) } } + it("잠긴 AIP 사용자는 external workspace sync를 일으키지 않는다") { + val user = createUser(status = UserStatus.LOCKED) + val externalOrganizations = + listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations) + + every { + oAuthLoginProvisioningService.resolveUser( + AuthProvider.AIP, + "aip-sub-locked", + "oauth@example.com", + "OAuth User", + authoritativeSnapshot, + ) + } returns + OAuthLoginProvisioningResult( + user = user, + statusError = OAuthLoginStatusError("ACCOUNT_LOCKED", AuthErrorType.ACCOUNT_LOCKED, "Account is locked"), + ) + + val result = + authService.authenticateWithOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-sub-locked", + email = "oauth@example.com", + name = "OAuth User", + ipAddress = "192.168.1.1", + userAgent = "Mozilla/5.0", + externalOrganizationSync = authoritativeSnapshot, + ) + + result.shouldBeInstanceOf() + result.errorType shouldBe AuthErrorType.ACCOUNT_LOCKED + verify(exactly = 0) { sessionService.createPending(any(), any(), any(), any(), any(), any(), any(), any(), any()) } + } + it("삭제된 OAuth 사용자는 세션을 만들지 않고 ACCOUNT_INACTIVE를 반환한다") { val user = createUser().apply { deletedAt = Instant.now() } every { - userService.findOrCreateByOAuth(AuthProvider.GOOGLE, "google-sub", "oauth@example.com", "OAuth User") - } returns user + oAuthLoginProvisioningService.resolveUser( + AuthProvider.GOOGLE, + "google-sub", + "oauth@example.com", + "OAuth User", + ExternalOrganizationSync.NoSync, + ) + } returns + OAuthLoginProvisioningResult( + user = user, + statusError = OAuthLoginStatusError("ACCOUNT_DELETED", AuthErrorType.ACCOUNT_INACTIVE, "Account is deleted"), + ) val result = authService.authenticateWithOAuth( diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/ExternalWorkspaceSyncServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/ExternalWorkspaceSyncServiceTest.kt new file mode 100644 index 000000000..70c111869 --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/ExternalWorkspaceSyncServiceTest.kt @@ -0,0 +1,243 @@ +package io.deck.iam.service + +import io.deck.common.api.id.SYSTEM_ACTOR_ID +import io.deck.iam.ManagementType +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalReference +import io.deck.iam.domain.ExternalSource +import io.deck.iam.domain.UserEntity +import io.deck.iam.domain.WorkspaceEntity +import io.deck.iam.domain.WorkspaceMemberEntity +import io.deck.iam.event.WorkspaceMemberAddedEvent +import io.deck.iam.event.WorkspaceMemberRemovedEvent +import io.deck.iam.repository.WorkspaceMemberRepository +import io.deck.iam.repository.WorkspaceMutationJdbcRepository +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.util.UUID + +class ExternalWorkspaceSyncServiceTest : + DescribeSpec({ + val workspaceService = mockk() + val workspaceMemberRepository = mockk() + val eventPublisher = mockk(relaxed = true) + val workspaceMutationJdbcRepository = mockk() + val service = + ExternalWorkspaceSyncService( + workspaceService, + workspaceMemberRepository, + eventPublisher, + workspaceMutationJdbcRepository, + ) + + fun user(email: String = "aip@example.com") = + UserEntity( + name = "AIP User", + email = email, + ) + + beforeEach { + io.mockk.clearMocks(workspaceService, workspaceMemberRepository, eventPublisher, workspaceMutationJdbcRepository) + } + + describe("syncForUser") { + it("claim에서 사라진 external workspace membership을 제거한다") { + val user = user() + val activeWorkspace = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + val staleWorkspace = + WorkspaceEntity( + name = "Legacy Org", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-stale"), + ) + val staleMembership = + WorkspaceMemberEntity( + workspaceId = staleWorkspace.id, + userId = user.id, + isOwner = false, + ) + + every { + workspaceService.upsertExternalWorkspace(ExternalReference(ExternalSource.AIP, "aip-org-1"), "Acme", null) + } returns activeWorkspace + every { workspaceService.findByUser(user.id) } returns listOf(activeWorkspace, staleWorkspace) + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(activeWorkspace.id, user.id) } returns true + every { workspaceMemberRepository.findByWorkspaceIdAndUserId(staleWorkspace.id, user.id) } returns staleMembership + every { workspaceMemberRepository.delete(staleMembership) } just Runs + + val result = + service.syncForUser( + user = user, + externalOrganizationSnapshot = + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme")), + ), + enabled = true, + reason = "aip_external_organization", + ) + + result.workspaces shouldBe listOf(activeWorkspace) + result.addedWorkspaces shouldBe emptyList() + result.removedWorkspaceIds shouldBe setOf(staleWorkspace.id) + verify(exactly = 1) { workspaceMemberRepository.delete(staleMembership) } + verify(exactly = 1) { + eventPublisher.publishEvent( + match { + it is WorkspaceMemberRemovedEvent && + it.workspaceId == staleWorkspace.id && + it.memberId.value == user.id && + it.removedBy.value == SYSTEM_ACTOR_ID + }, + ) + } + } + + it("normalized claim을 workspace service upsert로 위임하고 membership을 연결한다") { + val user = user() + val workspace = + WorkspaceEntity( + name = "Acme", + description = "Imported workspace", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + + every { + workspaceService.upsertExternalWorkspace( + ExternalReference(ExternalSource.AIP, "aip-org-1"), + "Acme", + "Imported workspace", + ) + } returns workspace + every { workspaceService.findByUser(user.id) } returns emptyList() + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(any(), user.id) } returns false + every { workspaceMutationJdbcRepository.insertWorkspaceMemberIfAbsent(any(), user.id, false) } returns UUID.randomUUID() + + val result = + service.syncForUser( + user = user, + externalOrganizationSnapshot = + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim( + externalId = " aip-org-1 ", + name = " Acme ", + description = " Imported workspace ", + ), + ), + ), + enabled = true, + reason = "aip_external_organization", + ) + + result.workspaces shouldBe listOf(workspace) + result.addedWorkspaces shouldBe listOf(workspace) + result.removedWorkspaceIds shouldBe emptySet() + verify(exactly = 1) { + workspaceService.upsertExternalWorkspace( + ExternalReference(ExternalSource.AIP, "aip-org-1"), + "Acme", + "Imported workspace", + ) + } + verify(exactly = 1) { workspaceMutationJdbcRepository.insertWorkspaceMemberIfAbsent(workspace.id, user.id, false) } + verify(exactly = 1) { + eventPublisher.publishEvent( + match { + it is WorkspaceMemberAddedEvent && + it.workspaceId == workspace.id && + it.memberId.value == user.id && + it.addedBy.value == SYSTEM_ACTOR_ID && + it.reason == "aip_external_organization" + }, + ) + } + } + + it("empty snapshot이면 기존 external membership을 모두 제거한다") { + val user = user() + val staleWorkspace = + WorkspaceEntity( + name = "Legacy Org", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-stale"), + ) + val staleMembership = + WorkspaceMemberEntity( + workspaceId = staleWorkspace.id, + userId = user.id, + isOwner = false, + ) + + every { workspaceService.findByUser(user.id) } returns listOf(staleWorkspace) + every { workspaceMemberRepository.findByWorkspaceIdAndUserId(staleWorkspace.id, user.id) } returns staleMembership + every { workspaceMemberRepository.delete(staleMembership) } just Runs + + val result = + service.syncForUser( + user = user, + externalOrganizationSnapshot = + ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, emptyList()), + enabled = true, + reason = "aip_external_organization", + ) + + result.workspaces shouldBe emptyList() + result.addedWorkspaces shouldBe emptyList() + result.removedWorkspaceIds shouldBe setOf(staleWorkspace.id) + verify(exactly = 1) { workspaceMemberRepository.delete(staleMembership) } + } + + it("membership 추가 경합으로 insert-if-absent가 null을 반환하면 기존 membership으로 간주한다") { + val user = user() + val existingWorkspace = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + + every { + workspaceService.upsertExternalWorkspace(ExternalReference(ExternalSource.AIP, "aip-org-1"), "Acme", null) + } returns existingWorkspace + every { workspaceService.findByUser(user.id) } returns emptyList() + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(existingWorkspace.id, user.id) } returns false + every { workspaceMutationJdbcRepository.insertWorkspaceMemberIfAbsent(existingWorkspace.id, user.id, false) } returns null + + val result = + service.syncForUser( + user = user, + externalOrganizationSnapshot = + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme")), + ), + enabled = true, + reason = "aip_external_organization", + ) + + result.workspaces shouldBe listOf(existingWorkspace) + result.addedWorkspaces shouldBe emptyList() + result.removedWorkspaceIds shouldBe emptySet() + verify(exactly = 1) { workspaceMutationJdbcRepository.insertWorkspaceMemberIfAbsent(existingWorkspace.id, user.id, false) } + verify(exactly = 0) { + eventPublisher.publishEvent( + match { it is WorkspaceMemberAddedEvent }, + ) + } + } + } + }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/MenuSeedCommandImplTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/MenuSeedCommandImplTest.kt index 01a7fd4d6..b0b8cb8ca 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/MenuSeedCommandImplTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/MenuSeedCommandImplTest.kt @@ -1,5 +1,6 @@ package io.deck.iam.service +import io.deck.iam.ManagementType import io.deck.iam.api.SeedMenuDefinition import io.deck.iam.domain.MenuEntity import io.deck.iam.domain.RoleEntity @@ -84,6 +85,37 @@ class MenuSeedCommandImplTest : savedMenus.first().namesI18n shouldBe mapOf("en" to "Contacts", "ko" to "연락처", "ja" to "連絡先") } + it("seed 정의의 managementType을 MenuEntity에 전달한다") { + val role = RoleEntity(name = "ADMIN", label = "관리자", id = UUID.randomUUID()) + every { roleRepository.findAllOrderBySortOrder() } returns listOf(role) + every { menuRepository.findAllByRoleIdWithChildren(role.id) } returns emptyList() + every { menuRepository.findMaxSortOrderByRoleIdForRoot(role.id) } returns null + + val savedMenus = mutableListOf() + every { menuRepository.save(capture(savedMenus)) } answers { savedMenus.last() } + + command.seedMenusForAllRoles( + listOf( + SeedMenuDefinition( + name = "Platform", + programType = "NONE", + managementType = ManagementType.PLATFORM_MANAGED, + children = + listOf( + SeedMenuDefinition( + name = "Users", + programType = "USER_MANAGEMENT", + managementType = ManagementType.PLATFORM_MANAGED, + ), + ), + ), + ), + ) + + savedMenus[0].managementType shouldBe ManagementType.PLATFORM_MANAGED + savedMenus[1].managementType shouldBe ManagementType.PLATFORM_MANAGED + } + it("이미 존재하는 메뉴는 중복 생성하지 않는다") { val role = RoleEntity(name = "ADMIN", label = "관리자", id = UUID.randomUUID()) val existingMenu = @@ -261,6 +293,36 @@ class MenuSeedCommandImplTest : existingChild.sortOrder shouldBe 0 verify(exactly = 2) { menuRepository.save(any()) } } + + it("sync 시 정의된 managementType으로 기존 메뉴를 갱신한다") { + val role = RoleEntity(name = "ADMIN", label = "관리자", id = UUID.randomUUID()) + val existingRoot = + MenuEntity( + roleId = role.id, + name = "Platform", + icon = "folder", + programType = "NONE", + sortOrder = 0, + managementType = ManagementType.USER_MANAGED, + ) + + every { roleRepository.findAllOrderBySortOrder() } returns listOf(role) + every { menuRepository.findAllByRoleIdWithChildren(role.id) } returns listOf(existingRoot) + every { menuRepository.save(any()) } answers { firstArg() } + + command.syncMenusForAllRoles( + listOf( + SeedMenuDefinition( + name = "Platform", + icon = "shield", + programType = "NONE", + managementType = ManagementType.PLATFORM_MANAGED, + ), + ), + ) + + existingRoot.managementType shouldBe ManagementType.PLATFORM_MANAGED + } } describe("seedMenusForRoles") { diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/MenuServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/MenuServiceTest.kt index 984d21f51..d0d4b6a01 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/MenuServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/MenuServiceTest.kt @@ -158,7 +158,7 @@ class MenuServiceTest : } describe("createRootMenu") { - it("programType에 NONE을 명시하면 NONE으로 저장되어야 한다") { + it("programType에 NONE을 명시하면 NONE으로 저장되고 managementType은 USER_MANAGED가 기본이어야 한다") { val roleId = UUID.randomUUID() every { menuRepository.findMaxSortOrderByRoleIdForRoot(roleId) } returns null @@ -175,6 +175,7 @@ class MenuServiceTest : ) result.programType shouldBe "NONE" + result.managementType shouldBe io.deck.iam.ManagementType.USER_MANAGED verify(exactly = 1) { menuRepository.save(any()) } } } @@ -191,6 +192,7 @@ class MenuServiceTest : namesI18n = mapOf("en" to "Old"), icon = "old-icon", programType = "OLD", + managementType = io.deck.iam.ManagementType.USER_MANAGED, sortOrder = 0, permissions = setOf("old"), ) @@ -205,6 +207,7 @@ class MenuServiceTest : namesI18n = mapOf("en" to "New", "ko" to "새 이름"), icon = "new-icon", programType = "NEW", + managementType = io.deck.iam.ManagementType.PLATFORM_MANAGED, permissions = setOf("read", "write"), ) @@ -212,6 +215,7 @@ class MenuServiceTest : result.namesI18n shouldBe mapOf("en" to "New", "ko" to "새 이름") result.icon shouldBe "new-icon" result.programType shouldBe "NEW" + result.managementType shouldBe io.deck.iam.ManagementType.PLATFORM_MANAGED result.permissions shouldBe setOf("read", "write") verify(exactly = 1) { menuRepository.findById(menuId) } @@ -247,6 +251,7 @@ class MenuServiceTest : name = "Booking", namesI18n = mapOf("en" to "Booking", "ko" to "예약"), programType = MenuEntity.NONE_PROGRAM_CODE, + managementType = io.deck.iam.ManagementType.PLATFORM_MANAGED, sortOrder = 0, ) @@ -257,6 +262,7 @@ class MenuServiceTest : val copied = menuService.copyFromRole(sourceRoleId, targetRoleId) copied.first().namesI18n shouldBe mapOf("en" to "Booking", "ko" to "예약") + copied.first().managementType shouldBe io.deck.iam.ManagementType.PLATFORM_MANAGED } } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthLoginProvisioningServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthLoginProvisioningServiceTest.kt new file mode 100644 index 000000000..5fe690133 --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthLoginProvisioningServiceTest.kt @@ -0,0 +1,156 @@ +package io.deck.iam.service + +import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalSource +import io.deck.iam.domain.UserEntity +import io.deck.iam.domain.UserStatus +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class OAuthLoginProvisioningServiceTest : + DescribeSpec({ + val userService = mockk() + val provisioningService = OAuthLoginProvisioningService(userService) + + beforeTest { + clearMocks(userService) + } + + fun user(status: UserStatus = UserStatus.ACTIVE) = + UserEntity( + name = "OAuth User", + email = "oauth@example.com", + status = status, + ) + + describe("resolveUser") { + it("AIP active 사용자는 user 생성/조회 뒤 external sync를 이어서 수행한다") { + val user = user() + val externalOrganizations = listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme")) + + every { + userService.findOrCreateByOAuth( + AuthProvider.AIP, + "aip-sub", + "oauth@example.com", + "OAuth User", + externalOrganizations, + ExternalSource.AIP, + ) + } returns user + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations) + + every { userService.syncExternalOrganizationsForOAuthUser(user, authoritativeSnapshot) } returns emptyList() + + val result = + provisioningService.resolveUser( + provider = AuthProvider.AIP, + providerUserId = "aip-sub", + email = "oauth@example.com", + name = "OAuth User", + externalOrganizationSync = authoritativeSnapshot, + ) + + result.user shouldBe user + result.statusError.shouldBeNull() + verify(exactly = 1) { userService.syncExternalOrganizationsForOAuthUser(user, authoritativeSnapshot) } + } + + it("잠긴 AIP 사용자는 sync 없이 상태 오류만 반환한다") { + val user = user(status = UserStatus.LOCKED) + val externalOrganizations = listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme")) + + every { + userService.findOrCreateByOAuth( + AuthProvider.AIP, + "aip-sub", + "oauth@example.com", + "OAuth User", + externalOrganizations, + ExternalSource.AIP, + ) + } returns user + + val result = + provisioningService.resolveUser( + provider = AuthProvider.AIP, + providerUserId = "aip-sub", + email = "oauth@example.com", + name = "OAuth User", + externalOrganizationSync = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations), + ) + + result.user shouldBe user + result.statusError shouldBe + OAuthLoginStatusError( + failReason = "ACCOUNT_LOCKED", + errorType = AuthErrorType.ACCOUNT_LOCKED, + message = "Account is locked", + ) + verify(exactly = 0) { userService.syncExternalOrganizationsForOAuthUser(any(), any()) } + } + + it("일반 OAuth provider는 active 사용자여도 external sync를 호출하지 않는다") { + val user = user() + + every { + userService.findOrCreateByOAuth( + AuthProvider.GOOGLE, + "google-sub", + "oauth@example.com", + "OAuth User", + emptyList(), + null, + ) + } returns user + + val result = + provisioningService.resolveUser( + provider = AuthProvider.GOOGLE, + providerUserId = "google-sub", + email = "oauth@example.com", + name = "OAuth User", + ) + + result.user shouldBe user + result.statusError.shouldBeNull() + verify(exactly = 0) { userService.syncExternalOrganizationsForOAuthUser(any(), any()) } + } + + it("AIP가 아닌 provider에 authoritative snapshot이 와도 external sync를 무시한다") { + val user = user() + val externalOrganizations = listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme")) + + every { + userService.findOrCreateByOAuth( + AuthProvider.GOOGLE, + "google-sub", + "oauth@example.com", + "OAuth User", + emptyList(), + null, + ) + } returns user + + val result = + provisioningService.resolveUser( + provider = AuthProvider.GOOGLE, + providerUserId = "google-sub", + email = "oauth@example.com", + name = "OAuth User", + externalOrganizationSync = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations), + ) + + result.user shouldBe user + result.statusError.shouldBeNull() + verify(exactly = 0) { userService.syncExternalOrganizationsForOAuthUser(any(), any()) } + } + } + }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt index 014b56022..8a813337d 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt @@ -8,7 +8,7 @@ import io.deck.iam.domain.IdentityEntity import io.deck.iam.domain.OAuthProviderConfig import io.deck.iam.domain.OAuthProviderEntity import io.deck.iam.domain.OAuthProviderType -import io.deck.iam.domain.SystemSettingEntity +import io.deck.iam.domain.PlatformSettingEntity import io.deck.iam.domain.UserEntity import io.deck.iam.domain.UserStatus import io.deck.iam.repository.OAuthProviderRepository @@ -38,7 +38,7 @@ class OAuthProviderServiceTest : val oauthProviderRepository = mockk() val userRepository = mockk() val identityService = mockk() - val systemSettingService = mockk() + val platformSettingService = mockk() val eventPublisher = mockk(relaxed = true) val oauthProviderService = @@ -46,7 +46,7 @@ class OAuthProviderServiceTest : oauthProviderRepository = oauthProviderRepository, userRepository = userRepository, identityService = identityService, - systemSettingService = systemSettingService, + platformSettingService = platformSettingService, eventPublisher = eventPublisher, ) @@ -77,7 +77,7 @@ class OAuthProviderServiceTest : } beforeEach { - clearMocks(oauthProviderRepository, userRepository, identityService, systemSettingService, eventPublisher) + clearMocks(oauthProviderRepository, userRepository, identityService, platformSettingService, eventPublisher) } afterEach { @@ -149,12 +149,12 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.GOOGLE) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -180,12 +180,12 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.NAVER) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.NAVER) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -210,12 +210,12 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.MICROSOFT) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.MICROSOFT) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -241,12 +241,12 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.KAKAO) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.KAKAO) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -274,11 +274,11 @@ class OAuthProviderServiceTest : describe("Lockout 방지 검증") { it("Owner가 없으면 에러") { // given - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns emptyList() // when & then @@ -297,12 +297,12 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.GOOGLE) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) val existingProvider = createOAuthProvider(OAuthProviderType.GOOGLE, true, "id", "secret") every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.of(existingProvider) every { oauthProviderRepository.findAll() } returns listOf(existingProvider) - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -323,11 +323,11 @@ class OAuthProviderServiceTest : val owner = createOwner() // Owner는 Internal Identity만 있음 val identity = createIdentity(owner, AuthProvider.INTERNAL) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -347,13 +347,13 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.INTERNAL) - val settings = SystemSettingEntity(internalLoginEnabled = true) + val settings = PlatformSettingEntity(internalLoginEnabled = true) val existingProvider = createOAuthProvider(OAuthProviderType.GOOGLE, true, "id", "secret") every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.of(existingProvider) every { oauthProviderRepository.findAll() } returns listOf(existingProvider) every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -439,13 +439,13 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.GOOGLE) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) val existingEntity = createOAuthProvider(OAuthProviderType.GOOGLE, false, "old-id", "existing-secret") every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.of(existingEntity) every { oauthProviderRepository.findAll() } returns listOf(existingEntity) every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -524,12 +524,12 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.MICROSOFT) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.MICROSOFT) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -589,12 +589,12 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.OKTA) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.OKTA) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -618,12 +618,12 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.AUTH0) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.AUTH0) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -649,12 +649,12 @@ class OAuthProviderServiceTest : // given val owner = createOwner() val identity = createIdentity(owner, AuthProvider.INTERNAL) - val settings = SystemSettingEntity(internalLoginEnabled = true) + val settings = PlatformSettingEntity(internalLoginEnabled = true) every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) @@ -685,12 +685,12 @@ class OAuthProviderServiceTest : val owner = createOwner() val googleIdentity = createIdentity(owner, AuthProvider.GOOGLE) val naverIdentity = createIdentity(owner, AuthProvider.NAVER) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.empty() every { oauthProviderRepository.findById(OAuthProviderType.NAVER) } returns Optional.empty() every { oauthProviderRepository.saveAll(any>()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(googleIdentity, naverIdentity) @@ -726,12 +726,12 @@ class OAuthProviderServiceTest : val actorId = UUID.randomUUID() val owner = createOwner() val identity = createIdentity(owner, AuthProvider.GOOGLE) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) MDC.put(RequestContext.KEY_USER_ID, actorId.toString()) every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.empty() every { oauthProviderRepository.findAll() } returns emptyList() every { oauthProviderRepository.save(any()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) every { eventPublisher.publishEvent(any()) } just runs @@ -756,12 +756,12 @@ class OAuthProviderServiceTest : val owner = createOwner() val googleIdentity = createIdentity(owner, AuthProvider.GOOGLE) val naverIdentity = createIdentity(owner, AuthProvider.NAVER) - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) MDC.put(RequestContext.KEY_USER_ID, actorId.toString()) every { oauthProviderRepository.findById(OAuthProviderType.GOOGLE) } returns Optional.empty() every { oauthProviderRepository.findById(OAuthProviderType.NAVER) } returns Optional.empty() every { oauthProviderRepository.saveAll(any>()) } answers { firstArg() } - every { systemSettingService.getSettings() } returns settings + every { platformSettingService.getSettings() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(googleIdentity, naverIdentity) every { eventPublisher.publishEvent(any()) } just runs diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceCacheTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceCacheTest.kt similarity index 54% rename from backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceCacheTest.kt rename to backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceCacheTest.kt index a837623fe..dcbe4d7e9 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceCacheTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceCacheTest.kt @@ -1,8 +1,8 @@ package io.deck.iam.service import io.deck.iam.domain.LogoType -import io.deck.iam.domain.SystemSettingEntity -import io.deck.iam.repository.SystemSettingRepository +import io.deck.iam.domain.PlatformSettingEntity +import io.deck.iam.repository.PlatformSettingRepository import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk @@ -22,30 +22,30 @@ import org.springframework.test.context.junit.jupiter.SpringExtension import java.util.UUID /** - * SystemSettingService 캐시 통합 테스트 + * PlatformSettingService 캐시 통합 테스트 * * @Cacheable / @CacheEvict 는 Spring AOP 기반이므로 Spring 컨텍스트가 필요합니다. * 단위 테스트(mockk 직접 호출)에서는 캐시 어노테이션이 적용되지 않습니다. */ @ExtendWith(SpringExtension::class) -@ContextConfiguration(classes = [SystemSettingServiceCacheTest.Config::class]) -class SystemSettingServiceCacheTest { +@ContextConfiguration(classes = [PlatformSettingServiceCacheTest.Config::class]) +class PlatformSettingServiceCacheTest { @Configuration @EnableCaching(proxyTargetClass = true) class Config { - val systemSettingRepository: SystemSettingRepository = mockk() + val platformSettingRepository: PlatformSettingRepository = mockk() val userRepository: io.deck.iam.repository.UserRepository = mockk() val identityService: IdentityService = mockk() val ownerService: OwnerService = mockk() val eventPublisher: ApplicationEventPublisher = mockk(relaxed = true) @Bean - fun cacheManager(): CacheManager = ConcurrentMapCacheManager(SystemSettingService.CACHE_NAME) + fun cacheManager(): CacheManager = ConcurrentMapCacheManager(PlatformSettingService.CACHE_NAME) @Bean - fun systemSettingService() = - SystemSettingService( - systemSettingRepository = systemSettingRepository, + fun platformSettingService() = + PlatformSettingService( + platformSettingRepository = platformSettingRepository, userRepository = userRepository, identityService = identityService, ownerService = ownerService, @@ -57,102 +57,102 @@ class SystemSettingServiceCacheTest { private lateinit var config: Config @Autowired - private lateinit var systemSettingService: SystemSettingService + private lateinit var platformSettingService: PlatformSettingService @Autowired private lateinit var cacheManager: CacheManager @BeforeEach fun setUp() { - clearMocks(config.systemSettingRepository, config.ownerService) - cacheManager.getCache(SystemSettingService.CACHE_NAME)?.clear() + clearMocks(config.platformSettingRepository, config.ownerService) + cacheManager.getCache(PlatformSettingService.CACHE_NAME)?.clear() } @Test fun `getSettings는 결과를 캐시해야 한다`() { // given - val settings = SystemSettingEntity(brandName = "Deck") - every { config.systemSettingRepository.findFirst() } returns settings + val settings = PlatformSettingEntity(brandName = "Deck") + every { config.platformSettingRepository.findFirst() } returns settings // when - 두 번 호출 - systemSettingService.getSettings() - systemSettingService.getSettings() + platformSettingService.getSettings() + platformSettingService.getSettings() // then - repository는 한 번만 호출 - verify(exactly = 1) { config.systemSettingRepository.findFirst() } + verify(exactly = 1) { config.platformSettingRepository.findFirst() } } @Test fun `setLogoUrl 호출 후 캐시가 evict되어야 한다`() { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() every { config.ownerService.isOwner(ownerId) } returns true - every { config.systemSettingRepository.findFirst() } returns settings - every { config.systemSettingRepository.save(any()) } answers { firstArg() } + every { config.platformSettingRepository.findFirst() } returns settings + every { config.platformSettingRepository.save(any()) } answers { firstArg() } // when - 캐시 채우기 (2번 호출해도 repo는 1번만) - systemSettingService.getSettings() - systemSettingService.getSettings() - verify(exactly = 1) { config.systemSettingRepository.findFirst() } + platformSettingService.getSettings() + platformSettingService.getSettings() + verify(exactly = 1) { config.platformSettingRepository.findFirst() } // when - setLogoUrl: // 내부 getSettings() self-invocation → proxy 우회 → findFirst() 1회 직접 호출 // → @CacheEvict 로 캐시 무효화 - systemSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, "https://cdn.example.com/logo.svg") + platformSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, "https://cdn.example.com/logo.svg") // when - evict 후 외부 getSettings() → 캐시 미스 → findFirst() 1회 추가 - systemSettingService.getSettings() + platformSettingService.getSettings() // then - 1(초기) + 1(self-invocation) + 1(evict 후 재조회) = 3 - verify(exactly = 3) { config.systemSettingRepository.findFirst() } + verify(exactly = 3) { config.platformSettingRepository.findFirst() } } @Test fun `uploadLogo 호출 후 캐시가 evict되어야 한다`() { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() every { config.ownerService.isOwner(ownerId) } returns true - every { config.systemSettingRepository.findFirst() } returns settings - every { config.systemSettingRepository.save(any()) } answers { firstArg() } + every { config.platformSettingRepository.findFirst() } returns settings + every { config.platformSettingRepository.save(any()) } answers { firstArg() } // when - 캐시 채우기 - systemSettingService.getSettings() - systemSettingService.getSettings() - verify(exactly = 1) { config.systemSettingRepository.findFirst() } + platformSettingService.getSettings() + platformSettingService.getSettings() + verify(exactly = 1) { config.platformSettingRepository.findFirst() } // when - uploadLogo → self-invocation 1회 + @CacheEvict - systemSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, "data:image/svg+xml;base64,abc") + platformSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, "data:image/svg+xml;base64,abc") // when - evict 후 재조회 - systemSettingService.getSettings() + platformSettingService.getSettings() // then - 1 + 1(self) + 1(evict 후) = 3 - verify(exactly = 3) { config.systemSettingRepository.findFirst() } + verify(exactly = 3) { config.platformSettingRepository.findFirst() } } @Test fun `updateSettings 호출 후 캐시가 evict되어야 한다`() { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity(brandName = "Old") + val settings = PlatformSettingEntity(brandName = "Old") every { config.ownerService.isOwner(ownerId) } returns true - every { config.systemSettingRepository.findFirst() } returns settings - every { config.systemSettingRepository.save(any()) } answers { firstArg() } + every { config.platformSettingRepository.findFirst() } returns settings + every { config.platformSettingRepository.save(any()) } answers { firstArg() } // when - 캐시 채우기 - systemSettingService.getSettings() - systemSettingService.getSettings() - verify(exactly = 1) { config.systemSettingRepository.findFirst() } + platformSettingService.getSettings() + platformSettingService.getSettings() + verify(exactly = 1) { config.platformSettingRepository.findFirst() } // when - updateGeneral → self-invocation 1회 + @CacheEvict - systemSettingService.updateGeneral(ownerId, brandName = "New", contactEmail = "privacy@deck.io") + platformSettingService.updateGeneral(ownerId, brandName = "New", contactEmail = "privacy@deck.io") // when - evict 후 재조회 - systemSettingService.getSettings() + platformSettingService.getSettings() // then - 1 + 1(self) + 1(evict 후) = 3 - verify(exactly = 3) { config.systemSettingRepository.findFirst() } + verify(exactly = 3) { config.platformSettingRepository.findFirst() } } } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceTest.kt similarity index 71% rename from backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt rename to backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceTest.kt index 40ee28e14..4b2f6064b 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceTest.kt @@ -7,11 +7,11 @@ import io.deck.iam.domain.CountryPolicy import io.deck.iam.domain.CurrencyPolicy import io.deck.iam.domain.IdentityEntity import io.deck.iam.domain.LogoType -import io.deck.iam.domain.SystemSettingEntity +import io.deck.iam.domain.PlatformSettingEntity import io.deck.iam.domain.UserEntity import io.deck.iam.domain.UserStatus import io.deck.iam.domain.WorkspacePolicy -import io.deck.iam.repository.SystemSettingRepository +import io.deck.iam.repository.PlatformSettingRepository import io.deck.iam.repository.UserRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -28,12 +28,12 @@ import org.springframework.security.access.AccessDeniedException import java.util.UUID /** - * SystemSettingService 단위 테스트 (Kotest DescribeSpec) + * PlatformSettingService 단위 테스트 (Kotest DescribeSpec) */ -class SystemSettingServiceTest : +class PlatformSettingServiceTest : DescribeSpec({ - val systemSettingRepository = mockk() + val platformSettingRepository = mockk() val userRepository = mockk() val identityService = mockk() val ownerService = mockk() @@ -56,9 +56,9 @@ class SystemSettingServiceTest : providerUserId = "test-${provider.name.lowercase()}", ) - val systemSettingService = - SystemSettingService( - systemSettingRepository = systemSettingRepository, + val platformSettingService = + PlatformSettingService( + platformSettingRepository = platformSettingRepository, userRepository = userRepository, identityService = identityService, ownerService = ownerService, @@ -66,44 +66,73 @@ class SystemSettingServiceTest : ) beforeEach { - clearMocks(systemSettingRepository, userRepository, identityService, ownerService, eventPublisher) + clearMocks(platformSettingRepository, userRepository, identityService, ownerService, eventPublisher) } describe("시스템 설정 조회 (getSettings)") { context("시스템 설정이 이미 존재하는 경우") { it("기존 설정을 반환해야 한다") { // given - val existingSettings = SystemSettingEntity(brandName = "My Brand") + val existingSettings = PlatformSettingEntity(brandName = "My Brand") - every { systemSettingRepository.findFirst() } returns existingSettings + every { platformSettingRepository.findFirst() } returns existingSettings // when - val result = systemSettingService.getSettings() + val result = platformSettingService.getSettings() // then result shouldBe existingSettings result.brandName shouldBe "My Brand" - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } + } + + it("비정규 workspace policy는 조회 시 정규화해야 한다") { + val existingSettings = + PlatformSettingEntity(brandName = "My Brand").apply { + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = true, + useExternalSync = true, + useSelector = true, + ).apply { + usePlatformManaged = false + useExternalSync = true + } + } + + every { platformSettingRepository.findFirst() } returns existingSettings + + val result = platformSettingService.getSettings() + + result.workspacePolicy shouldBe + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = false, + useSelector = true, + ) + verify(exactly = 0) { platformSettingRepository.save(any()) } } } context("시스템 설정이 존재하지 않는 경우") { it("새 설정을 생성하여 반환해야 한다") { // given - val newSettings = SystemSettingEntity() + val newSettings = PlatformSettingEntity() - every { systemSettingRepository.findFirst() } returns null - every { systemSettingRepository.save(any()) } returns newSettings + every { platformSettingRepository.findFirst() } returns null + every { platformSettingRepository.save(any()) } returns newSettings // when - val result = systemSettingService.getSettings() + val result = platformSettingService.getSettings() // then result shouldNotBe null result.brandName shouldBe "Deck" // 기본값 - verify { systemSettingRepository.save(any()) } + verify { platformSettingRepository.save(any()) } } } } @@ -113,15 +142,15 @@ class SystemSettingServiceTest : it("Owner이면 설정 정보가 정상적으로 수정되어야 한다") { // given val ownerId = UUID.randomUUID() - val existingSettings = SystemSettingEntity(brandName = "Old Brand", contactEmail = "old@deck.io") + val existingSettings = PlatformSettingEntity(brandName = "Old Brand", contactEmail = "old@deck.io") every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns existingSettings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns existingSettings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val result = - systemSettingService.updateGeneral( + platformSettingService.updateGeneral( userId = ownerId, brandName = "New Brand", contactEmail = "privacy@deck.io", @@ -130,7 +159,7 @@ class SystemSettingServiceTest : // then result.brandName shouldBe "New Brand" result.contactEmail shouldBe "privacy@deck.io" - verify { systemSettingRepository.save(existingSettings) } + verify { platformSettingRepository.save(existingSettings) } } it("Owner가 아니면 수정을 거부해야 한다") { @@ -140,13 +169,13 @@ class SystemSettingServiceTest : // when & then shouldThrow { - systemSettingService.updateGeneral( + platformSettingService.updateGeneral( userId = userId, brandName = "New Brand", contactEmail = "privacy@deck.io", ) } - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } } } @@ -154,15 +183,15 @@ class SystemSettingServiceTest : it("새 설정을 생성하고 수정해야 한다") { // given val ownerId = UUID.randomUUID() - val newSettings = SystemSettingEntity() + val newSettings = PlatformSettingEntity() every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns null - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns null + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val result = - systemSettingService.updateGeneral( + platformSettingService.updateGeneral( userId = ownerId, brandName = "First Brand", contactEmail = "privacy@deck.io", @@ -179,25 +208,25 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val existingSettings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Old Brand", - workspacePolicy = WorkspacePolicy(useUserManaged = false, useSystemManaged = true, useSelector = false), + workspacePolicy = WorkspacePolicy(useUserManaged = false, usePlatformManaged = true, useSelector = false), ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns existingSettings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns existingSettings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val result = - systemSettingService.updateGeneral( + platformSettingService.updateGeneral( userId = ownerId, brandName = "New Brand", contactEmail = "privacy@deck.io", ) // then - result.workspacePolicy shouldBe WorkspacePolicy(useUserManaged = false, useSystemManaged = true, useSelector = false) + result.workspacePolicy shouldBe WorkspacePolicy(useUserManaged = false, usePlatformManaged = true, useSelector = false) } } } @@ -207,45 +236,45 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val existingSettings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Enterprise", - workspacePolicy = WorkspacePolicy(useUserManaged = true, useSystemManaged = true, useSelector = true), + workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = true, useSelector = true), ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns existingSettings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns existingSettings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val result = - systemSettingService.updateWorkspacePolicy( + platformSettingService.updateWorkspacePolicy( userId = ownerId, - workspacePolicy = WorkspacePolicy(useUserManaged = false, useSystemManaged = true, useSelector = false), + workspacePolicy = WorkspacePolicy(useUserManaged = false, usePlatformManaged = true, useSelector = false), ) // then result.brandName shouldBe "Deck Enterprise" - result.workspacePolicy shouldBe WorkspacePolicy(useUserManaged = false, useSystemManaged = true, useSelector = false) - verify { systemSettingRepository.save(existingSettings) } + result.workspacePolicy shouldBe WorkspacePolicy(useUserManaged = false, usePlatformManaged = true, useSelector = false) + verify { platformSettingRepository.save(existingSettings) } } it("둘 다 false면 workspace policy를 null로 정규화해야 한다") { // given val ownerId = UUID.randomUUID() - val existingSettings = SystemSettingEntity(brandName = "Deck Enterprise") + val existingSettings = PlatformSettingEntity(brandName = "Deck Enterprise") every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns existingSettings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns existingSettings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val result = - systemSettingService.updateWorkspacePolicy( + platformSettingService.updateWorkspacePolicy( userId = ownerId, workspacePolicy = WorkspacePolicy( useUserManaged = false, - useSystemManaged = false, + usePlatformManaged = false, useSelector = true, ), ) @@ -260,9 +289,9 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val existingSettings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Enterprise", - workspacePolicy = WorkspacePolicy(useUserManaged = true, useSystemManaged = false, useSelector = true), + workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false, useSelector = true), currencyPolicy = CurrencyPolicy( defaultCurrencyCode = "KRW", @@ -271,12 +300,12 @@ class SystemSettingServiceTest : ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns existingSettings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns existingSettings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val result = - systemSettingService.updateCountryPolicy( + platformSettingService.updateCountryPolicy( userId = ownerId, countryPolicy = CountryPolicy( @@ -287,10 +316,10 @@ class SystemSettingServiceTest : // then result.brandName shouldBe "Deck Enterprise" - result.workspacePolicy shouldBe WorkspacePolicy(useUserManaged = true, useSystemManaged = false, useSelector = true) + result.workspacePolicy shouldBe WorkspacePolicy(useUserManaged = true, usePlatformManaged = false, useSelector = true) result.currencyPolicy shouldBe CurrencyPolicy(defaultCurrencyCode = "KRW", preferredCurrencyCodes = listOf("KRW", "USD", "JPY")) result.countryPolicy shouldBe CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "US") - verify { systemSettingRepository.save(existingSettings) } + verify { platformSettingRepository.save(existingSettings) } } it("형식이 잘못된 raw country code는 정규화로 숨기지 않고 거부해야 한다") { @@ -299,7 +328,7 @@ class SystemSettingServiceTest : every { ownerService.isOwner(ownerId) } returns true shouldThrow { - systemSettingService.updateCountryPolicy( + platformSettingService.updateCountryPolicy( userId = ownerId, countryPolicy = CountryPolicy( @@ -309,7 +338,7 @@ class SystemSettingServiceTest : ) } - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } } } @@ -317,7 +346,7 @@ class SystemSettingServiceTest : it("country/currency policy를 한 트랜잭션에서 저장하고 activity log는 한 번만 발행해야 한다") { val ownerId = UUID.randomUUID() val existingSettings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Enterprise", countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR"), defaultCountryCode = "KR"), currencyPolicy = @@ -328,12 +357,12 @@ class SystemSettingServiceTest : ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns existingSettings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns existingSettings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { eventPublisher.publishEvent(any()) } just runs val result = - systemSettingService.updateGlobalizationPolicy( + platformSettingService.updateGlobalizationPolicy( userId = ownerId, countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "US"), currencyPolicy = @@ -349,19 +378,19 @@ class SystemSettingServiceTest : defaultCurrencyCode = "USD", preferredCurrencyCodes = listOf("USD", "KRW", "EUR"), ) - verify(exactly = 1) { systemSettingRepository.save(existingSettings) } + verify(exactly = 1) { platformSettingRepository.save(existingSettings) } verify(exactly = 1) { eventPublisher.publishEvent(match { it is ActivitySource<*> }) } } it("표준이 아닌 country/currency code는 거부해야 한다") { val ownerId = UUID.randomUUID() - val existingSettings = SystemSettingEntity() + val existingSettings = PlatformSettingEntity() every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns existingSettings + every { platformSettingRepository.findFirst() } returns existingSettings shouldThrow { - systemSettingService.updateGlobalizationPolicy( + platformSettingService.updateGlobalizationPolicy( userId = ownerId, countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "UK"), defaultCountryCode = "UK"), currencyPolicy = @@ -372,7 +401,7 @@ class SystemSettingServiceTest : ) } - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } } it("형식이 잘못된 raw code는 정규화로 숨기지 않고 거부해야 한다") { @@ -381,7 +410,7 @@ class SystemSettingServiceTest : every { ownerService.isOwner(ownerId) } returns true shouldThrow { - systemSettingService.updateGlobalizationPolicy( + platformSettingService.updateGlobalizationPolicy( userId = ownerId, countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "U1"), defaultCountryCode = "KR"), currencyPolicy = @@ -392,7 +421,7 @@ class SystemSettingServiceTest : ) } - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } } } @@ -401,7 +430,7 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val existingSettings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Enterprise", countryPolicy = CountryPolicy( @@ -411,12 +440,12 @@ class SystemSettingServiceTest : ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns existingSettings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns existingSettings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val result = - systemSettingService.updateCurrencyPolicy( + platformSettingService.updateCurrencyPolicy( userId = ownerId, currencyPolicy = CurrencyPolicy( @@ -433,7 +462,7 @@ class SystemSettingServiceTest : defaultCurrencyCode = "USD", preferredCurrencyCodes = listOf("USD", "KRW", "EUR"), ) - verify { systemSettingRepository.save(existingSettings) } + verify { platformSettingRepository.save(existingSettings) } } it("지원하지 않는 통화 코드는 거부해야 한다") { @@ -442,7 +471,7 @@ class SystemSettingServiceTest : every { ownerService.isOwner(ownerId) } returns true shouldThrow { - systemSettingService.updateCurrencyPolicy( + platformSettingService.updateCurrencyPolicy( userId = ownerId, currencyPolicy = CurrencyPolicy( @@ -452,7 +481,7 @@ class SystemSettingServiceTest : ) } - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } } } @@ -461,17 +490,17 @@ class SystemSettingServiceTest : it("URL이 설정되고 기존 데이터는 삭제되어야 한다") { // given val settings = - SystemSettingEntity( + PlatformSettingEntity( logoHorizontalData = "base64EncodedData", ) - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val ownerId = UUID.randomUUID() every { ownerService.isOwner(ownerId) } returns true - val result = systemSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, "https://example.com/logo.png") + val result = platformSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, "https://example.com/logo.png") // then result.logoHorizontalUrl shouldBe "https://example.com/logo.png" @@ -484,16 +513,16 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val settings = - SystemSettingEntity( + PlatformSettingEntity( logoPublicData = "publicLogoData", ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when - val result = systemSettingService.setLogoUrl(ownerId, LogoType.PUBLIC, "https://example.com/public-logo.png") + val result = platformSettingService.setLogoUrl(ownerId, LogoType.PUBLIC, "https://example.com/public-logo.png") // then result.logoPublicUrl shouldBe "https://example.com/public-logo.png" @@ -506,16 +535,16 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val settings = - SystemSettingEntity( + PlatformSettingEntity( faviconData = "faviconData", ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when - val result = systemSettingService.setLogoUrl(ownerId, LogoType.FAVICON, "https://example.com/favicon.ico") + val result = platformSettingService.setLogoUrl(ownerId, LogoType.FAVICON, "https://example.com/favicon.ico") // then result.faviconUrl shouldBe "https://example.com/favicon.ico" @@ -528,17 +557,17 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val settings = - SystemSettingEntity( + PlatformSettingEntity( faviconDarkData = "darkFaviconData", ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val result = - systemSettingService.setLogoUrl( + platformSettingService.setLogoUrl( ownerId, LogoType.FAVICON, "https://example.com/favicon-dark.ico", @@ -556,16 +585,16 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val settings = - SystemSettingEntity( + PlatformSettingEntity( logoHorizontalUrl = "https://example.com/logo.png", ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when - val result = systemSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, null) + val result = platformSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, null) // then result.logoHorizontalUrl shouldBe null @@ -580,17 +609,17 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val settings = - SystemSettingEntity( + PlatformSettingEntity( logoHorizontalUrl = "https://example.com/old-logo.png", ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val base64Data = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..." - val result = systemSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, base64Data) + val result = platformSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, base64Data) // then result.logoHorizontalData shouldBe base64Data @@ -603,17 +632,17 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val settings = - SystemSettingEntity( + PlatformSettingEntity( logoPublicUrl = "https://example.com/public-logo.png", ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val base64Data = "data:image/png;base64,publicLogoBase64..." - val result = systemSettingService.uploadLogo(ownerId, LogoType.PUBLIC, base64Data) + val result = platformSettingService.uploadLogo(ownerId, LogoType.PUBLIC, base64Data) // then result.logoPublicData shouldBe base64Data @@ -626,17 +655,17 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val settings = - SystemSettingEntity( + PlatformSettingEntity( faviconUrl = "https://example.com/favicon.ico", ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val base64Data = "data:image/x-icon;base64,faviconBase64..." - val result = systemSettingService.uploadLogo(ownerId, LogoType.FAVICON, base64Data) + val result = platformSettingService.uploadLogo(ownerId, LogoType.FAVICON, base64Data) // then result.faviconData shouldBe base64Data @@ -649,17 +678,17 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val settings = - SystemSettingEntity( + PlatformSettingEntity( faviconDarkUrl = "https://example.com/favicon-dark.ico", ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when val base64Data = "data:image/x-icon;base64,darkFaviconBase64..." - val result = systemSettingService.uploadLogo(ownerId, LogoType.FAVICON, base64Data, dark = true) + val result = platformSettingService.uploadLogo(ownerId, LogoType.FAVICON, base64Data, dark = true) // then result.faviconDarkData shouldBe base64Data @@ -672,16 +701,16 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val settings = - SystemSettingEntity( + PlatformSettingEntity( logoHorizontalData = "existingData", ) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } // when - val result = systemSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, null) + val result = platformSettingService.uploadLogo(ownerId, LogoType.HORIZONTAL, null) // then result.logoHorizontalData shouldBe null @@ -695,14 +724,14 @@ class SystemSettingServiceTest : it("업로드된 데이터를 반환해야 한다") { // given val settings = - SystemSettingEntity( + PlatformSettingEntity( logoHorizontalData = "horizontalLogoData", ) - every { systemSettingRepository.findFirst() } returns settings + every { platformSettingRepository.findFirst() } returns settings // when - val result = systemSettingService.getLogoData(LogoType.HORIZONTAL) + val result = platformSettingService.getLogoData(LogoType.HORIZONTAL) // then result shouldBe "horizontalLogoData" @@ -713,14 +742,14 @@ class SystemSettingServiceTest : it("업로드된 데이터를 반환해야 한다") { // given val settings = - SystemSettingEntity( + PlatformSettingEntity( logoPublicData = "publicLogoData", ) - every { systemSettingRepository.findFirst() } returns settings + every { platformSettingRepository.findFirst() } returns settings // when - val result = systemSettingService.getLogoData(LogoType.PUBLIC) + val result = platformSettingService.getLogoData(LogoType.PUBLIC) // then result shouldBe "publicLogoData" @@ -731,14 +760,14 @@ class SystemSettingServiceTest : it("업로드된 데이터를 반환해야 한다") { // given val settings = - SystemSettingEntity( + PlatformSettingEntity( faviconData = "faviconData", ) - every { systemSettingRepository.findFirst() } returns settings + every { platformSettingRepository.findFirst() } returns settings // when - val result = systemSettingService.getLogoData(LogoType.FAVICON) + val result = platformSettingService.getLogoData(LogoType.FAVICON) // then result shouldBe "faviconData" @@ -748,14 +777,14 @@ class SystemSettingServiceTest : context("로고 데이터가 없는 경우") { it("null을 반환해야 한다") { // given - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() - every { systemSettingRepository.findFirst() } returns settings + every { platformSettingRepository.findFirst() } returns settings // when - val resultHorizontal = systemSettingService.getLogoData(LogoType.HORIZONTAL) - val resultPublic = systemSettingService.getLogoData(LogoType.PUBLIC) - val resultFavicon = systemSettingService.getLogoData(LogoType.FAVICON) + val resultHorizontal = platformSettingService.getLogoData(LogoType.HORIZONTAL) + val resultPublic = platformSettingService.getLogoData(LogoType.PUBLIC) + val resultFavicon = platformSettingService.getLogoData(LogoType.FAVICON) // then resultHorizontal shouldBe null @@ -767,16 +796,16 @@ class SystemSettingServiceTest : context("시스템 설정이 없는 경우") { it("새 설정을 생성하고 null을 반환해야 한다") { // given - every { systemSettingRepository.findFirst() } returns null - every { systemSettingRepository.save(any()) } returns SystemSettingEntity() + every { platformSettingRepository.findFirst() } returns null + every { platformSettingRepository.save(any()) } returns PlatformSettingEntity() // when - val result = systemSettingService.getLogoData(LogoType.HORIZONTAL) + val result = platformSettingService.getLogoData(LogoType.HORIZONTAL) // then result shouldBe null - verify { systemSettingRepository.save(any()) } + verify { platformSettingRepository.save(any()) } } } } @@ -807,19 +836,19 @@ class SystemSettingServiceTest : it("Internal 로그인을 활성화할 수 있다") { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity(internalLoginEnabled = false) + val settings = PlatformSettingEntity(internalLoginEnabled = false) val owner = createOwner() val identity = createIdentity(owner, AuthProvider.INTERNAL) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) // when val result = - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = ownerId, internalLoginEnabled = true, auth0Enabled = false, @@ -839,20 +868,20 @@ class SystemSettingServiceTest : it("Internal 로그인을 비활성화할 수 있다 (다른 로그인 방법이 있는 경우)") { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity(internalLoginEnabled = true) + val settings = PlatformSettingEntity(internalLoginEnabled = true) val owner = createOwner() val internalIdentity = createIdentity(owner, AuthProvider.INTERNAL) val auth0Identity = createIdentity(owner, AuthProvider.AUTH0) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(internalIdentity, auth0Identity) // when val result = - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = ownerId, internalLoginEnabled = false, auth0Enabled = true, @@ -875,19 +904,19 @@ class SystemSettingServiceTest : it("Auth0 로그인을 활성화할 수 있다") { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() val owner = createOwner() val identity = createIdentity(owner, AuthProvider.AUTH0) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) // when val result = - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = ownerId, internalLoginEnabled = false, auth0Enabled = true, @@ -911,19 +940,19 @@ class SystemSettingServiceTest : it("Okta 로그인을 활성화할 수 있다") { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() val owner = createOwner() val identity = createIdentity(owner, AuthProvider.OKTA) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) // when val result = - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = ownerId, internalLoginEnabled = false, auth0Enabled = false, @@ -946,15 +975,15 @@ class SystemSettingServiceTest : it("Owner가 없으면 에러") { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings + every { platformSettingRepository.findFirst() } returns settings every { userRepository.findOwners() } returns emptyList() // when & then shouldThrow { - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = ownerId, internalLoginEnabled = true, auth0Enabled = false, @@ -972,18 +1001,18 @@ class SystemSettingServiceTest : it("모든 로그인 방법을 비활성화하면 에러 (Owner 락아웃 방지)") { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity(internalLoginEnabled = true) + val settings = PlatformSettingEntity(internalLoginEnabled = true) val owner = createOwner() val identity = createIdentity(owner, AuthProvider.INTERNAL) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings + every { platformSettingRepository.findFirst() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) // when & then shouldThrow { - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = ownerId, internalLoginEnabled = false, auth0Enabled = false, @@ -1001,19 +1030,19 @@ class SystemSettingServiceTest : it("Owner가 활성화된 Provider의 Identity가 없으면 에러") { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity(internalLoginEnabled = true) + val settings = PlatformSettingEntity(internalLoginEnabled = true) val owner = createOwner() // Owner는 Internal Identity만 있는데 Internal을 비활성화하려 함 val identity = createIdentity(owner, AuthProvider.INTERNAL) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings + every { platformSettingRepository.findFirst() } returns settings every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) // when & then - Internal 비활성화하고 Auth0만 활성화하려 하지만 Owner에게 Auth0 Identity 없음 shouldThrow { - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = ownerId, internalLoginEnabled = false, auth0Enabled = true, @@ -1031,22 +1060,22 @@ class SystemSettingServiceTest : it("여러 Owner 중 한 명이라도 로그인 가능하면 성공") { // given val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() val owner1 = createOwner(UUID.randomUUID()) val owner2 = createOwner(UUID.randomUUID()) val owner1Identity = createIdentity(owner1, AuthProvider.INTERNAL) val owner2Identity = createIdentity(owner2, AuthProvider.AUTH0) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { userRepository.findOwners() } returns listOf(owner1, owner2) every { identityService.findAllByUserId(owner1.id) } returns listOf(owner1Identity) every { identityService.findAllByUserId(owner2.id) } returns listOf(owner2Identity) // when - Auth0만 활성화 (owner2가 Auth0 Identity 있음) val result = - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = ownerId, internalLoginEnabled = false, auth0Enabled = true, @@ -1069,20 +1098,20 @@ class SystemSettingServiceTest : describe("activity log 이벤트 발행") { it("updateGeneral 시 activity log 이벤트를 발행한다") { val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity(brandName = "Old Brand") + val settings = PlatformSettingEntity(brandName = "Old Brand") every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { eventPublisher.publishEvent(any()) } just runs - systemSettingService.updateGeneral(ownerId, "New Brand", "privacy@deck.io") + platformSettingService.updateGeneral(ownerId, "New Brand", "privacy@deck.io") verify(exactly = 1) { eventPublisher.publishEvent( match { it is ActivitySource<*> && - it.type.name == "SYSTEM_SETTINGS_GENERAL_UPDATED" && - it.targetType.name == "SYSTEM_SETTING" && + it.type.name == "PLATFORM_SETTINGS_GENERAL_UPDATED" && + it.targetType.name == "PLATFORM_SETTING" && it.targetId == settings.id.toString() && it.actorId == ownerId && it.metadata["brandName"] == "New Brand" @@ -1093,26 +1122,26 @@ class SystemSettingServiceTest : it("updateWorkspacePolicy 시 activity log 이벤트를 발행한다") { val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { eventPublisher.publishEvent(any()) } just runs - systemSettingService.updateWorkspacePolicy( + platformSettingService.updateWorkspacePolicy( ownerId, - WorkspacePolicy(useUserManaged = true, useSystemManaged = false, useSelector = true), + WorkspacePolicy(useUserManaged = true, usePlatformManaged = false, useSelector = true), ) verify(exactly = 1) { eventPublisher.publishEvent( match { it is ActivitySource<*> && - it.type.name == "SYSTEM_SETTINGS_WORKSPACE_POLICY_UPDATED" && - it.targetType.name == "SYSTEM_SETTING" && + it.type.name == "PLATFORM_SETTINGS_WORKSPACE_POLICY_UPDATED" && + it.targetType.name == "PLATFORM_SETTING" && it.targetId == settings.id.toString() && it.actorId == ownerId && - it.metadata["useSystemManaged"] == false + it.metadata["usePlatformManaged"] == false }, ) } @@ -1120,13 +1149,13 @@ class SystemSettingServiceTest : it("updateCountryPolicy 시 activity log 이벤트를 발행한다") { val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { eventPublisher.publishEvent(any()) } just runs - systemSettingService.updateCountryPolicy( + platformSettingService.updateCountryPolicy( ownerId, CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "US"), ) @@ -1135,8 +1164,8 @@ class SystemSettingServiceTest : eventPublisher.publishEvent( match { it is ActivitySource<*> && - it.type.name == "SYSTEM_SETTINGS_COUNTRY_POLICY_UPDATED" && - it.targetType.name == "SYSTEM_SETTING" && + it.type.name == "PLATFORM_SETTINGS_COUNTRY_POLICY_UPDATED" && + it.targetType.name == "PLATFORM_SETTING" && it.targetId == settings.id.toString() && it.actorId == ownerId && it.metadata["defaultCountryCode"] == "US" @@ -1147,13 +1176,13 @@ class SystemSettingServiceTest : it("updateCurrencyPolicy 시 activity log 이벤트를 발행한다") { val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { eventPublisher.publishEvent(any()) } just runs - systemSettingService.updateCurrencyPolicy( + platformSettingService.updateCurrencyPolicy( ownerId, CurrencyPolicy(defaultCurrencyCode = "USD", preferredCurrencyCodes = listOf("USD", "KRW")), ) @@ -1162,8 +1191,8 @@ class SystemSettingServiceTest : eventPublisher.publishEvent( match { it is ActivitySource<*> && - it.type.name == "SYSTEM_SETTINGS_CURRENCY_POLICY_UPDATED" && - it.targetType.name == "SYSTEM_SETTING" && + it.type.name == "PLATFORM_SETTINGS_CURRENCY_POLICY_UPDATED" && + it.targetType.name == "PLATFORM_SETTING" && it.targetId == settings.id.toString() && it.actorId == ownerId && it.metadata["defaultCurrencyCode"] == "USD" @@ -1174,19 +1203,19 @@ class SystemSettingServiceTest : it("setLogoUrl 시 activity log 이벤트를 발행한다") { val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { eventPublisher.publishEvent(any()) } just runs - systemSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, "https://example.com/logo.png", dark = true) + platformSettingService.setLogoUrl(ownerId, LogoType.HORIZONTAL, "https://example.com/logo.png", dark = true) verify(exactly = 1) { eventPublisher.publishEvent( match { it is ActivitySource<*> && - it.type.name == "SYSTEM_SETTINGS_LOGO_URL_UPDATED" && + it.type.name == "PLATFORM_SETTINGS_LOGO_URL_UPDATED" && it.actorId == ownerId && it.metadata["logoType"] == LogoType.HORIZONTAL.name && it.metadata["dark"] == true @@ -1197,19 +1226,19 @@ class SystemSettingServiceTest : it("uploadLogo 시 activity log 이벤트를 발행한다") { val ownerId = UUID.randomUUID() - val settings = SystemSettingEntity() + val settings = PlatformSettingEntity() every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { eventPublisher.publishEvent(any()) } just runs - systemSettingService.uploadLogo(ownerId, LogoType.PUBLIC, "data:image/png;base64,abc", dark = false) + platformSettingService.uploadLogo(ownerId, LogoType.PUBLIC, "data:image/png;base64,abc", dark = false) verify(exactly = 1) { eventPublisher.publishEvent( match { it is ActivitySource<*> && - it.type.name == "SYSTEM_SETTINGS_LOGO_UPLOADED" && + it.type.name == "PLATFORM_SETTINGS_LOGO_UPLOADED" && it.actorId == ownerId && it.metadata["logoType"] == LogoType.PUBLIC.name && it.metadata["dark"] == false @@ -1221,16 +1250,16 @@ class SystemSettingServiceTest : it("updateAuthSettings 시 activity log 이벤트를 발행한다") { val ownerId = UUID.randomUUID() val owner = createOwner() - val settings = SystemSettingEntity(internalLoginEnabled = true) + val settings = PlatformSettingEntity(internalLoginEnabled = true) val identity = createIdentity(owner, AuthProvider.INTERNAL) every { ownerService.isOwner(ownerId) } returns true - every { systemSettingRepository.findFirst() } returns settings - every { systemSettingRepository.save(any()) } answers { firstArg() } + every { platformSettingRepository.findFirst() } returns settings + every { platformSettingRepository.save(any()) } answers { firstArg() } every { userRepository.findOwners() } returns listOf(owner) every { identityService.findAllByUserId(owner.id) } returns listOf(identity) every { eventPublisher.publishEvent(any()) } just runs - systemSettingService.updateAuthSettings( + platformSettingService.updateAuthSettings( userId = ownerId, internalLoginEnabled = true, auth0Enabled = false, @@ -1247,7 +1276,7 @@ class SystemSettingServiceTest : eventPublisher.publishEvent( match { it is ActivitySource<*> && - it.type.name == "SYSTEM_SETTINGS_AUTH_UPDATED" && + it.type.name == "PLATFORM_SETTINGS_AUTH_UPDATED" && it.actorId == ownerId && it.metadata["internalLoginEnabled"] == true && it.metadata["auth0Enabled"] == false diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/ProgramRegistryTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/ProgramRegistryTest.kt index 70a6b25ac..580376f25 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/ProgramRegistryTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/ProgramRegistryTest.kt @@ -1,5 +1,6 @@ package io.deck.iam.service +import io.deck.iam.api.ProgramDefinition import io.deck.iam.registry.IamProgramRegistrar import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -8,15 +9,29 @@ import io.kotest.matchers.shouldNotBe class ProgramRegistryTest : DescribeSpec({ describe("findByCode") { - it("CODEBOOK_MANAGEMENT 프로그램이 등록되어 있어야 한다") { + it("DASHBOARD 프로그램은 console dashboard path를 사용해야 한다") { val registry = ProgramRegistry(listOf(IamProgramRegistrar())) - val program = registry.findByCode("CODEBOOK_MANAGEMENT") + val program = registry.findByCode("DASHBOARD") program shouldNotBe null - program?.path shouldBe "/system/codebook" - program?.permissions?.contains("CODEBOOK_MANAGEMENT_READ") shouldBe true - program?.permissions?.contains("CODEBOOK_MANAGEMENT_WRITE") shouldBe true + program?.path shouldBe "/console/dashboard" + } + + it("MENU_MANAGEMENT 프로그램은 platform settings path를 사용해야 한다") { + val registry = ProgramRegistry(listOf(IamProgramRegistrar())) + + val program = registry.findByCode("MENU_MANAGEMENT") + + program shouldNotBe null + program?.path shouldBe "/settings/platform/menus" + program?.permissions shouldBe setOf("MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE") + } + + it("실제 console page가 없는 capability 전용 프로그램은 top-level registry에 노출하지 않아야 한다") { + val registry = ProgramRegistry(listOf(IamProgramRegistrar())) + + registry.findByCode("CODEBOOK_MANAGEMENT") shouldBe null } it("메뉴 그룹용 NONE 프로그램이 등록되어 있어야 한다") { @@ -34,14 +49,29 @@ class ProgramRegistryTest : val apiAuditLogProgram = registry.findByCode("API_AUDIT_LOG") val activityLogProgram = registry.findByCode("ACTIVITY_LOG") + val loginHistoryProgram = registry.findByCode("LOGIN_HISTORY") apiAuditLogProgram shouldNotBe null - apiAuditLogProgram?.path shouldBe "/system/api-audit-logs" + apiAuditLogProgram?.path shouldBe "/console/api-audit-logs" apiAuditLogProgram?.permissions shouldBe setOf("API_AUDIT_LOG_READ", "API_AUDIT_LOG_WRITE") activityLogProgram shouldNotBe null - activityLogProgram?.path shouldBe "/system/activity-logs" + activityLogProgram?.path shouldBe "/console/activity-logs" activityLogProgram?.permissions shouldBe setOf("ACTIVITY_LOG_READ") + + loginHistoryProgram shouldNotBe null + loginHistoryProgram?.path shouldBe "/console/login-history" + loginHistoryProgram?.permissions shouldBe setOf("LOGIN_HISTORY_READ") + } + + it("my workspace 프로그램은 workspace feature만 필요하고 active workspace는 요구하지 않아야 한다") { + val registry = ProgramRegistry(listOf(IamProgramRegistrar())) + + val program = registry.findByCode("MY_WORKSPACE") + + program shouldNotBe null + program?.path shouldBe "/console/my-workspaces" + program?.workspace shouldBe ProgramDefinition.WorkspacePolicy(required = true) } } }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/UserServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/UserServiceTest.kt index b769e8f6c..c2d32e33e 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/UserServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/UserServiceTest.kt @@ -10,6 +10,7 @@ import io.deck.globalization.contactfield.api.ContactPhoneNumberNormalizer import io.deck.globalization.contactfield.api.ContactPhoneNumberValue import io.deck.globalization.contactfield.api.GenericContactPhoneNumberValue import io.deck.globalization.contactfield.api.KrContactPhoneNumberValue +import io.deck.iam.ManagementType import io.deck.iam.api.event.UserApprovedEvent import io.deck.iam.api.event.UserCreatedEvent import io.deck.iam.api.event.UserEventType @@ -17,16 +18,20 @@ import io.deck.iam.api.event.UserPasswordIssuedEvent import io.deck.iam.api.event.UserPendingEvent import io.deck.iam.domain.AuthProvider import io.deck.iam.domain.DateFormatType +import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync +import io.deck.iam.domain.ExternalReference +import io.deck.iam.domain.ExternalSource import io.deck.iam.domain.IdentityEntity import io.deck.iam.domain.LocaleType +import io.deck.iam.domain.PlatformSettingEntity import io.deck.iam.domain.RoleEntity -import io.deck.iam.domain.SystemSettingEntity import io.deck.iam.domain.UserEntity import io.deck.iam.domain.UserId import io.deck.iam.domain.UserStatus import io.deck.iam.domain.WorkspaceEntity -import io.deck.iam.domain.WorkspaceManagedType import io.deck.iam.domain.WorkspaceMemberEntity +import io.deck.iam.domain.WorkspacePolicy import io.deck.iam.event.UserActivityEvent import io.deck.iam.event.UserWithdrawnEvent import io.deck.iam.event.WorkspaceMemberAddedEvent @@ -87,11 +92,13 @@ class UserServiceTest : lateinit var eventPublisher: ApplicationEventPublisher lateinit var channelAvailability: ChannelAvailability lateinit var workspaceMemberRepository: WorkspaceMemberRepository + lateinit var workspaceMemberService: WorkspaceMemberService lateinit var workspaceRepository: WorkspaceRepository - lateinit var systemSettingService: SystemSettingService + lateinit var platformSettingService: PlatformSettingService lateinit var contactPhoneNumberNormalizer: ContactPhoneNumberNormalizer lateinit var partyCommand: PartyCommand lateinit var partyQuery: PartyQuery + lateinit var externalWorkspaceSyncService: ExternalWorkspaceSyncService lateinit var userService: UserService fun createTestUser( @@ -128,6 +135,16 @@ class UserServiceTest : isPrimary = true, ) + fun syncResult( + workspaces: List = emptyList(), + addedWorkspaces: List = emptyList(), + removedWorkspaceIds: Set = emptySet(), + ) = ExternalWorkspaceSyncResult( + workspaces = workspaces, + addedWorkspaces = addedWorkspaces, + removedWorkspaceIds = removedWorkspaceIds, + ) + fun createInternalIdentity( user: UserEntity, username: String = "testuser", @@ -261,8 +278,9 @@ class UserServiceTest : eventPublisher = mockk(relaxed = true) channelAvailability = mockk(relaxed = true) workspaceMemberRepository = mockk(relaxed = true) + workspaceMemberService = mockk(relaxed = true) workspaceRepository = mockk(relaxed = true) - systemSettingService = mockk(relaxed = true) + platformSettingService = mockk(relaxed = true) contactPhoneNumberNormalizer = object : ContactPhoneNumberNormalizer { override fun normalize( @@ -294,13 +312,23 @@ class UserServiceTest : } partyCommand = mockk(relaxed = true) partyQuery = mockk(relaxed = true) + externalWorkspaceSyncService = mockk(relaxed = true) every { channelAvailability.isEmailActive() } returns true every { workspaceRepository.findById(any()) } returns Optional.empty() every { workspaceRepository.findAllByAllowedDomain(any()) } returns emptyList() - every { systemSettingService.getSettings() } returns SystemSettingEntity() + every { platformSettingService.getSettings() } returns PlatformSettingEntity() every { partyCommand.upsertPersonProfile(any(), any()) } answers { arg(0) ?: UUID.randomUUID() } every { partyCommand.softDeleteProfile(any(), any()) } just Runs every { partyQuery.getPersonProfile(any()) } returns null + every { + externalWorkspaceSyncService.syncForUser( + any(), + any(), + any(), + any(), + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, + ) + } returns syncResult() userService = UserService( @@ -315,11 +343,13 @@ class UserServiceTest : eventPublisher = eventPublisher, channelAvailability = channelAvailability, workspaceMemberRepository = workspaceMemberRepository, + workspaceMemberService = workspaceMemberService, workspaceRepository = workspaceRepository, - systemSettingService = systemSettingService, + platformSettingService = platformSettingService, contactPhoneNumberNormalizer = contactPhoneNumberNormalizer, partyCommand = partyCommand, partyQuery = partyQuery, + externalWorkspaceSyncService = externalWorkspaceSyncService, ) } @@ -616,8 +646,8 @@ class UserServiceTest : it("countryCode가 없으면 system setting 기본 국가를 사용하고 비어 있는 연락처는 party에 만들지 않는다") { val savedUser = slot() - every { systemSettingService.getSettings() } returns - SystemSettingEntity( + every { platformSettingService.getSettings() } returns + PlatformSettingEntity( countryPolicy = io.deck.iam.domain.CountryPolicy( enabledCountryCodes = listOf("US"), @@ -1022,6 +1052,211 @@ class UserServiceTest : describe("findOrCreateByOAuth") { describe("기존 Identity가 있는 경우") { + it("syncExternalOrganizationsForOAuthUser는 같은 externalId workspace를 재사용하고 누락된 membership만 추가한다") { + val user = createTestUser(name = "AIP User", email = "aip@example.com") + val workspaceA = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + val workspaceB = + WorkspaceEntity( + name = "Beta", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-2"), + ) + + every { + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta"), + ), + ), + true, + "AIP_EXTERNAL_ORGANIZATION", + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, + ) + } returns syncResult(workspaces = listOf(workspaceA, workspaceB), addedWorkspaces = listOf(workspaceA, workspaceB)) + + val result = + userService.syncExternalOrganizationsForOAuthUser( + user = user, + externalOrganizationSync = + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta"), + ), + ), + ) + + result shouldBe listOf(workspaceA, workspaceB) + verify(exactly = 1) { + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta"), + ), + ), + true, + "AIP_EXTERNAL_ORGANIZATION", + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, + ) + } + } + + it("syncExternalOrganizationsForOAuthUser는 claim에서 사라진 external workspace membership을 제거한다") { + val user = createTestUser(name = "AIP User", email = "aip@example.com") + val activeWorkspace = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + + every { + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ), + ), + true, + "AIP_EXTERNAL_ORGANIZATION", + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, + ) + } returns syncResult(workspaces = listOf(activeWorkspace), removedWorkspaceIds = setOf(UUID.randomUUID())) + + val result = + userService.syncExternalOrganizationsForOAuthUser( + user = user, + externalOrganizationSync = + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ), + ), + ) + + result shouldBe listOf(activeWorkspace) + verify(exactly = 1) { + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ), + ), + true, + "AIP_EXTERNAL_ORGANIZATION", + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, + ) + } + } + + it("syncExternalOrganizationsForOAuthUser는 membership 변화가 없으면 승인 이벤트를 다시 발행하지 않는다") { + val user = createTestUser(name = "AIP User", email = "aip@example.com") + val workspace = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + + every { + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ), + ), + true, + "AIP_EXTERNAL_ORGANIZATION", + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, + ) + } returns syncResult(workspaces = listOf(workspace)) + every { eventPublisher.publishEvent(any()) } just Runs + + val result = + userService.syncExternalOrganizationsForOAuthUser( + user = user, + externalOrganizationSync = + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ), + ), + ) + + result shouldBe listOf(workspace) + verify(exactly = 0) { + eventPublisher.publishEvent( + match { + it is InternalUserApprovedEvent && + it.reason == "AIP_EXTERNAL_ORGANIZATION" + }, + ) + } + } + + it("syncExternalOrganizationsForOAuthUser는 external sync policy가 꺼져 있으면 external sync를 건너뛴다") { + val user = createTestUser(name = "AIP User", email = "aip@example.com") + + every { platformSettingService.getSettings() } returns + PlatformSettingEntity( + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = true, + useExternalSync = false, + ), + ) + + val result = + userService.syncExternalOrganizationsForOAuthUser( + user = user, + externalOrganizationSync = + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-locked", name = "Locked Org"), + ), + ), + ) + + result shouldBe emptyList() + verify(exactly = 1) { + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, + organizations = listOf( + ExternalOrganizationClaim(externalId = "aip-org-locked", name = "Locked Org"), + ), + ), + false, + "AIP_EXTERNAL_ORGANIZATION", + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, + ) + } + } + it("기존 사용자 정보를 유지하고 반환한다 (OAuth 정보로 덮어쓰지 않음)") { // given val user = createTestUser(name = "Old Name", email = "old@example.com") @@ -1149,8 +1384,8 @@ class UserServiceTest : it("신규 OAuth 사용자는 system setting 기본 국가를 party primaryCountryCode로 사용한다") { val savedUser = slot() - every { systemSettingService.getSettings() } returns - SystemSettingEntity( + every { platformSettingService.getSettings() } returns + PlatformSettingEntity( countryPolicy = io.deck.iam.domain.CountryPolicy( enabledCountryCodes = listOf("US"), @@ -1182,6 +1417,66 @@ class UserServiceTest : } describe("OAuth 신규 사용자 PENDING 상태") { + it("AIP 조직 claim이 있으면 ACTIVE로 생성한다") { + val savedUser = slot() + + every { + identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-sub-new") + } returns null + every { userRepository.save(capture(savedUser)) } answers { savedUser.captured } + + val result = + userService.findOrCreateByOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-sub-new", + email = "member@aip.example.com", + name = "AIP Member", + externalOrganizations = + listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta", description = "Beta team"), + ), + externalOrganizationSource = ExternalSource.AIP, + ) + + result.status shouldBe UserStatus.ACTIVE + verify(exactly = 0) { externalWorkspaceSyncService.syncForUser(any(), any(), any(), any(), any()) } + } + + it("external sync policy가 꺼져 있으면 AIP 조직 claim이 있어도 PENDING으로 남긴다") { + val savedUser = slot() + + every { platformSettingService.getSettings() } returns + PlatformSettingEntity( + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = true, + useExternalSync = false, + ), + ) + every { + identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-sub-disabled") + } returns null + every { userRepository.save(capture(savedUser)) } answers { savedUser.captured } + + val result = + userService.findOrCreateByOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-sub-disabled", + email = "pending@aip.example.com", + name = "Pending AIP Member", + externalOrganizations = + listOf( + ExternalOrganizationClaim(externalId = "aip-org-disabled", name = "Disabled Org"), + ), + externalOrganizationSource = ExternalSource.AIP, + ) + + result.status shouldBe UserStatus.PENDING + verify(exactly = 0) { externalWorkspaceSyncService.syncForUser(any(), any(), any(), any(), any()) } + } + it("신규 OAuth 사용자는 PENDING 상태로 생성된다") { // given val savedUser = slot() @@ -1204,11 +1499,11 @@ class UserServiceTest : result.status shouldBe UserStatus.PENDING } - it("허용 domain과 매칭되면 ACTIVE로 생성하고 매칭된 모든 workspace에 member로 추가한다") { + it("auto join domain과 매칭되면 ACTIVE로 생성하고 매칭된 모든 workspace에 member로 추가한다") { // given val savedUser = slot() - val workspaceA = WorkspaceEntity(name = "Acme", managedType = WorkspaceManagedType.SYSTEM_MANAGED, allowedDomains = listOf("acme.com")) - val workspaceB = WorkspaceEntity(name = "Dev", managedType = WorkspaceManagedType.SYSTEM_MANAGED, allowedDomains = listOf("acme.com")) + val workspaceA = WorkspaceEntity(name = "Acme", managedType = ManagementType.PLATFORM_MANAGED, autoJoinDomains = listOf("acme.com")) + val workspaceB = WorkspaceEntity(name = "Dev", managedType = ManagementType.PLATFORM_MANAGED, autoJoinDomains = listOf("acme.com")) val publishedEvents = mutableListOf() every { @@ -1216,7 +1511,13 @@ class UserServiceTest : } returns null every { workspaceRepository.findAllByAllowedDomain("acme.com") } returns listOf(workspaceA, workspaceB) every { userRepository.save(capture(savedUser)) } answers { savedUser.captured } - every { workspaceMemberRepository.save(any()) } answers { firstArg() } + every { workspaceMemberService.addMember(any(), any(), any(), any(), any()) } answers { + WorkspaceMemberEntity( + workspaceId = firstArg(), + userId = secondArg(), + isOwner = false, + ) + } every { eventPublisher.publishEvent(any()) } answers { publishedEvents += firstArg() Unit @@ -1234,19 +1535,15 @@ class UserServiceTest : // then result.status shouldBe UserStatus.ACTIVE verify(exactly = 2) { - workspaceMemberRepository.save( - match { - it.userId == result.id && - !it.isOwner && - it.workspaceId in setOf(workspaceA.id, workspaceB.id) - }, + workspaceMemberService.addMember( + workspaceId = match { it in setOf(workspaceA.id, workspaceB.id) }, + userId = result.id, + addedBy = io.deck.common.api.id.SYSTEM_ACTOR_ID, + reason = "WORKSPACE_ALLOWED_DOMAIN", + domain = "acme.com", ) } publishedEvents.filterIsInstance() shouldHaveSize 0 - publishedEvents.filterIsInstance().map { it.workspaceId } shouldBe listOf(workspaceA.id, workspaceB.id) - publishedEvents.filterIsInstance().forEach { event -> - event.addedBy.value shouldBe io.deck.common.api.id.SYSTEM_ACTOR_ID - } val approvedEvent = publishedEvents.filterIsInstance().single() approvedEvent.email shouldBe "member@acme.com" @@ -1254,6 +1551,84 @@ class UserServiceTest : approvedEvent.actorType.name shouldBe "SYSTEM" } + it("workspace policy가 해당 managed type을 막으면 auto join domain이 있어도 가입 승인과 membership을 만들지 않는다") { + val savedUser = slot() + val blockedWorkspace = + WorkspaceEntity( + name = "Blocked", + managedType = ManagementType.PLATFORM_MANAGED, + autoJoinDomains = listOf("acme.com"), + ) + val publishedEvents = mutableListOf() + + every { platformSettingService.getSettings() } returns + PlatformSettingEntity( + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = true, + ), + ) + every { + identityService.findByProviderAndProviderUserId(AuthProvider.GOOGLE, "google-blocked-auto-join") + } returns null + every { workspaceRepository.findAllByAllowedDomain("acme.com") } returns listOf(blockedWorkspace) + every { userRepository.save(capture(savedUser)) } answers { savedUser.captured } + every { eventPublisher.publishEvent(any()) } answers { + publishedEvents += firstArg() + Unit + } + + val result = + userService.findOrCreateByOAuth( + provider = AuthProvider.GOOGLE, + providerUserId = "google-blocked-auto-join", + email = "member@acme.com", + name = "Blocked Auto Join User", + ) + + result.status shouldBe UserStatus.PENDING + verify(exactly = 0) { workspaceMemberService.addMember(any(), any(), any(), any(), any()) } + publishedEvents.filterIsInstance() shouldHaveSize 0 + publishedEvents.filterIsInstance() shouldHaveSize 0 + } + + it("external workspace에 남아 있는 auto join domain은 일반 OAuth auto join 대상으로 취급하지 않는다") { + val savedUser = slot() + val externalWorkspace = + WorkspaceEntity( + name = "External", + managedType = ManagementType.PLATFORM_MANAGED, + autoJoinDomains = listOf("acme.com"), + externalReference = ExternalReference(ExternalSource.AIP, "external-org-1"), + ) + val publishedEvents = mutableListOf() + + every { + identityService.findByProviderAndProviderUserId(AuthProvider.GOOGLE, "google-external-auto-join") + } returns null + every { workspaceRepository.findAllByAllowedDomain("acme.com") } returns listOf(externalWorkspace) + every { userRepository.save(capture(savedUser)) } answers { savedUser.captured } + every { eventPublisher.publishEvent(any()) } answers { + publishedEvents += firstArg() + Unit + } + + val result = + userService.findOrCreateByOAuth( + provider = AuthProvider.GOOGLE, + providerUserId = "google-external-auto-join", + email = "member@acme.com", + name = "External Auto Join User", + ) + + result.status shouldBe UserStatus.PENDING + verify(exactly = 0) { workspaceMemberService.addMember(any(), any(), any(), any(), any()) } + publishedEvents.filterIsInstance() shouldHaveSize 0 + publishedEvents.filterIsInstance() shouldHaveSize 0 + } + it("신규 OAuth 사용자 생성 시 UserPendingEvent가 발행된다") { // given val savedUser = slot() @@ -1326,6 +1701,32 @@ class UserServiceTest : result.status shouldBe UserStatus.PENDING verify(exactly = 0) { userRepository.save(any()) } } + + it("기존 PENDING AIP 사용자는 external claim이 있으면 ACTIVE로 승격한다") { + val user = createTestUser(status = UserStatus.PENDING) + val existingIdentity = createOAuthIdentity(user, provider = AuthProvider.AIP, providerUserId = "aip-pending-again") + + every { + identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-pending-again") + } returns existingIdentity + every { userRepository.save(user) } returns user + + val result = + userService.findOrCreateByOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-pending-again", + email = "pending@aip.example.com", + name = "Pending AIP User", + externalOrganizations = + listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ), + externalOrganizationSource = ExternalSource.AIP, + ) + + result.status shouldBe UserStatus.ACTIVE + verify(exactly = 1) { userRepository.save(user) } + } } } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceInviteServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceInviteServiceTest.kt index d8a7dcfb1..cac5334c4 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceInviteServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceInviteServiceTest.kt @@ -1,8 +1,11 @@ package io.deck.iam.service import io.deck.common.exception.BadRequestException +import io.deck.common.exception.ConflictException import io.deck.common.exception.NotFoundException import io.deck.common.utils.HashUtils +import io.deck.iam.domain.ExternalReference +import io.deck.iam.domain.ExternalSource import io.deck.iam.domain.InviteStatus import io.deck.iam.domain.UserEntity import io.deck.iam.domain.UserId @@ -15,7 +18,7 @@ import io.deck.iam.event.WorkspaceInviteSentEvent import io.deck.iam.repository.UserRepository import io.deck.iam.repository.WorkspaceInviteRepository import io.deck.iam.repository.WorkspaceMemberRepository -import io.deck.iam.repository.WorkspaceRepository +import io.deck.iam.repository.WorkspaceMutationJdbcRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -26,6 +29,8 @@ import io.mockk.just import io.mockk.mockk import io.mockk.verify import org.springframework.context.ApplicationEventPublisher +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.dao.OptimisticLockingFailureException import java.time.Instant import java.time.temporal.ChronoUnit import java.util.Optional @@ -35,11 +40,12 @@ class WorkspaceInviteServiceTest : DescribeSpec({ lateinit var inviteRepository: WorkspaceInviteRepository lateinit var userRepository: UserRepository - lateinit var workspaceRepository: WorkspaceRepository + lateinit var workspaceService: WorkspaceService lateinit var memberRepository: WorkspaceMemberRepository lateinit var userService: UserService lateinit var memberService: WorkspaceMemberService lateinit var eventPublisher: ApplicationEventPublisher + lateinit var workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository lateinit var workspaceInviteService: WorkspaceInviteService fun createUser( @@ -59,6 +65,9 @@ class WorkspaceInviteServiceTest : tokenHash: String = HashUtils.sha3("token"), status: InviteStatus = InviteStatus.PENDING, expiresAt: Instant = Instant.now().plus(1, ChronoUnit.DAYS), + createdBy: UUID = UUID.randomUUID(), + inviterId: UUID = createdBy, + updatedBy: UUID? = null, ) = WorkspaceInviteEntity( workspaceId = workspaceId, email = email, @@ -66,17 +75,53 @@ class WorkspaceInviteServiceTest : status = status, expiresAt = expiresAt, message = "메시지", - ) + inviterId = inviterId, + ).apply { + this.createdBy = createdBy + this.updatedBy = updatedBy + } beforeEach { inviteRepository = mockk() userRepository = mockk() - workspaceRepository = mockk() + workspaceService = mockk() memberRepository = mockk() userService = mockk() memberService = mockk() eventPublisher = mockk() - workspaceInviteService = WorkspaceInviteService(inviteRepository, userRepository, workspaceRepository, memberRepository, userService, memberService, eventPublisher) + workspaceMutationJdbcRepository = mockk() + workspaceInviteService = + WorkspaceInviteService( + inviteRepository, + userRepository, + workspaceService, + memberRepository, + userService, + memberService, + eventPublisher, + workspaceMutationJdbcRepository, + ) + every { workspaceService.findMutableById(any()) } answers { + io.deck.iam.domain + .WorkspaceEntity(name = "워크스페이스", id = firstArg()) + } + every { workspaceService.ensureWorkspaceAccessible(any(), any()) } answers { + io.deck.iam.domain + .WorkspaceEntity(name = "워크스페이스", id = firstArg()) + } + every { inviteRepository.save(any()) } answers { firstArg() } + every { inviteRepository.saveAndFlush(any()) } answers { firstArg() } + every { + workspaceMutationJdbcRepository.insertPendingInviteIfAbsent( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns true } describe("findAllByWorkspace") { @@ -89,6 +134,65 @@ class WorkspaceInviteServiceTest : result shouldBe invites } + + it("external workspace는 read path도 external_locked로 막아야 한다") { + val workspaceId = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") + + io.kotest.assertions.throwables + .shouldThrow { + workspaceInviteService.findAllByWorkspace(workspaceId) + }.messageCode shouldBe "iam.workspace.external_locked" + } + } + + describe("validateToken") { + it("workspace가 없으면 valid=false로 보여야 한다") { + val workspaceId = UUID.randomUUID() + val token = "missing-workspace-token" + val tokenHash = HashUtils.sha3(token) + val invite = createInvite(workspaceId = workspaceId, email = "invite@example.com", tokenHash = tokenHash) + + every { inviteRepository.findByTokenHash(tokenHash) } returns invite + every { workspaceService.ensureWorkspaceAccessible(workspaceId, any()) } throws + NotFoundException("iam.workspace.not_found") + every { userRepository.findByEmail("invite@example.com") } returns createUser(email = "invite@example.com") + + val result = workspaceInviteService.validateToken(token) + + result.valid shouldBe false + result.workspaceName shouldBe null + result.expired shouldBe false + result.alreadyMember shouldBe false + result.hasAccount shouldBe true + verify(exactly = 0) { memberRepository.existsByWorkspaceIdAndUserId(any(), any()) } + } + + it("external workspace 초대 토큰은 valid=false로 보여야 한다") { + val workspaceId = UUID.randomUUID() + val token = "external-token" + val tokenHash = HashUtils.sha3(token) + val invite = createInvite(workspaceId = workspaceId, email = "invite@example.com", tokenHash = tokenHash) + + every { inviteRepository.findByTokenHash(tokenHash) } returns invite + every { workspaceService.ensureWorkspaceAccessible(workspaceId, any()) } returns + io.deck.iam.domain.WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + managedType = io.deck.iam.ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + every { userRepository.findByEmail("invite@example.com") } returns null + + val result = workspaceInviteService.validateToken(token) + + result.valid shouldBe false + result.workspaceName shouldBe "AIP Workspace" + result.expired shouldBe false + result.alreadyMember shouldBe false + result.hasAccount shouldBe false + } } describe("invite") { @@ -99,12 +203,12 @@ class WorkspaceInviteServiceTest : .WorkspaceEntity(name = "Shared Workspace", id = workspaceId) every { memberService.isMember(any(), any()) } returns false - every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + every { workspaceService.findMutableById(workspaceId) } returns workspace every { userRepository.findByEmail("invite@example.com") } returns null every { inviteRepository.findByWorkspaceIdAndEmailAndStatus(workspaceId, "invite@example.com", InviteStatus.PENDING) } returns null - every { inviteRepository.save(any()) } answers { firstArg() } + every { inviteRepository.saveAndFlush(any()) } answers { firstArg() } every { userRepository.findById(invitedBy) } returns Optional.of(createUser(id = invitedBy, name = "초대한 사람")) every { eventPublisher.publishEvent(any()) } just Runs @@ -133,10 +237,9 @@ class WorkspaceInviteServiceTest : it("이미 멤버인 사용자면 BadRequestException(ALREADY_MEMBER)을 던진다") { val workspaceId = UUID.randomUUID() val user = createUser(email = "member@example.com") - every { workspaceRepository.findById(workspaceId) } returns Optional.of( + every { workspaceService.findMutableById(workspaceId) } returns io.deck.iam.domain - .WorkspaceEntity(name = "워크스페이스", id = workspaceId), - ) + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) every { userRepository.findByEmail("member@example.com") } returns user every { memberService.isMember(workspaceId, user.id) } returns true @@ -149,18 +252,184 @@ class WorkspaceInviteServiceTest : ex.code shouldBe "ALREADY_MEMBER" } + it("동시 생성으로 pending invite unique 제약이 깨지면 ConflictException을 던진다") { + val workspaceId = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } returns + io.deck.iam.domain + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) + every { userRepository.findByEmail("invite@example.com") } returns null + every { + inviteRepository.findByWorkspaceIdAndEmailAndStatus(workspaceId, "invite@example.com", InviteStatus.PENDING) + } returns null + every { + workspaceMutationJdbcRepository.insertPendingInviteIfAbsent( + any(), + workspaceId, + "invite@example.com", + any(), + any(), + any(), + any(), + ) + } returns false + + val ex = + shouldThrow { + workspaceInviteService.invite(workspaceId, "invite@example.com", null, UUID.randomUUID()) + } + + ex.code shouldBe "WORKSPACE_INVITE_PENDING_CONFLICT" + } + + it("ignore existing invite 경로는 이미 멤버인 사용자를 결과 코드로만 건너뛴다") { + val workspaceId = UUID.randomUUID() + val user = createUser(email = "member@example.com") + every { workspaceService.findMutableById(workspaceId) } returns + io.deck.iam.domain + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) + every { userRepository.findByEmail("member@example.com") } returns user + every { memberService.isMember(workspaceId, user.id) } returns true + + val result = + workspaceInviteService.inviteIgnoringExisting( + workspaceId = workspaceId, + email = "member@example.com", + message = null, + invitedBy = UUID.randomUUID(), + ) + + result shouldBe WorkspaceInviteIgnoreReason.ALREADY_MEMBER + } + + it("ignore existing invite 경로는 pending invite race를 결과 코드로만 건너뛴다") { + val workspaceId = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } returns + io.deck.iam.domain + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) + every { userRepository.findByEmail("invite@example.com") } returns null + every { + inviteRepository.findByWorkspaceIdAndEmailAndStatus(workspaceId, "invite@example.com", InviteStatus.PENDING) + } returns null + every { + workspaceMutationJdbcRepository.insertPendingInviteIfAbsent( + any(), + workspaceId, + "invite@example.com", + any(), + any(), + any(), + any(), + ) + } returns false + + val result = + workspaceInviteService.inviteIgnoringExisting( + workspaceId = workspaceId, + email = "invite@example.com", + message = null, + invitedBy = UUID.randomUUID(), + ) + + result shouldBe WorkspaceInviteIgnoreReason.PENDING_CONFLICT + } + + it("batch ignore existing 경로는 pending conflict가 나와도 다음 이메일 처리를 계속한다") { + val workspaceId = UUID.randomUUID() + val invitedBy = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } returns + io.deck.iam.domain + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) + every { userRepository.findByEmail("first@example.com") } returns null + every { userRepository.findByEmail("second@example.com") } returns null + every { + inviteRepository.findByWorkspaceIdAndEmailAndStatus(workspaceId, any(), InviteStatus.PENDING) + } returns null + every { + workspaceMutationJdbcRepository.insertPendingInviteIfAbsent( + any(), + workspaceId, + "first@example.com", + any(), + any(), + any(), + invitedBy, + ) + } returns false + every { + workspaceMutationJdbcRepository.insertPendingInviteIfAbsent( + any(), + workspaceId, + "second@example.com", + any(), + any(), + any(), + invitedBy, + ) + } returns true + every { userRepository.findById(invitedBy) } returns Optional.empty() + every { eventPublisher.publishEvent(any()) } just Runs + + workspaceInviteService.inviteAllIgnoringExisting( + workspaceId = workspaceId, + emails = listOf("first@example.com", "second@example.com"), + message = null, + invitedBy = invitedBy, + ) + + verify(exactly = 1) { + workspaceMutationJdbcRepository.insertPendingInviteIfAbsent( + any(), + workspaceId, + "first@example.com", + any(), + any(), + any(), + invitedBy, + ) + } + verify(exactly = 1) { + workspaceMutationJdbcRepository.insertPendingInviteIfAbsent( + any(), + workspaceId, + "second@example.com", + any(), + any(), + any(), + invitedBy, + ) + } + verify(exactly = 1) { + eventPublisher.publishEvent( + match { + it is WorkspaceInviteSentEvent && + it.workspaceId == workspaceId && + it.email == "second@example.com" + }, + ) + } + } + + it("external workspace에는 초대를 생성할 수 없다") { + val workspaceId = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") + + shouldThrow { + workspaceInviteService.invite(workspaceId, "invite@example.com", null, UUID.randomUUID()) + }.messageCode shouldBe "iam.workspace.external_locked" + } + it("기존 PENDING 초대가 있으면 취소 후 새 초대를 생성한다") { val workspaceId = UUID.randomUUID() val existingInvite = createInvite(workspaceId = workspaceId, email = "dup@example.com") - every { workspaceRepository.findById(workspaceId) } returns Optional.of( + every { workspaceService.findMutableById(workspaceId) } returns io.deck.iam.domain - .WorkspaceEntity(name = "워크스페이스", id = workspaceId), - ) + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) every { userRepository.findByEmail("dup@example.com") } returns null every { inviteRepository.findByWorkspaceIdAndEmailAndStatus(workspaceId, "dup@example.com", InviteStatus.PENDING) } returns existingInvite - every { inviteRepository.save(any()) } answers { firstArg() } + every { inviteRepository.saveAndFlush(any()) } answers { firstArg() } every { userRepository.findById(any()) } returns Optional.empty() every { eventPublisher.publishEvent(any()) } just Runs @@ -172,14 +441,40 @@ class WorkspaceInviteServiceTest : result.email shouldBe "dup@example.com" } + it("초대 이메일은 normalize해서 조회와 저장에 같은 key를 사용해야 한다") { + val workspaceId = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } returns + io.deck.iam.domain + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) + every { userRepository.findByEmail("invite@example.com") } returns null + every { + inviteRepository.findByWorkspaceIdAndEmailAndStatus( + workspaceId, + "invite@example.com", + InviteStatus.PENDING, + ) + } returns null + every { userRepository.findById(any()) } returns Optional.empty() + every { eventPublisher.publishEvent(any()) } just Runs + + val result = + workspaceInviteService.invite( + workspaceId = workspaceId, + email = " Invite@Example.com ", + message = null, + invitedBy = UUID.randomUUID(), + ) + + result.email shouldBe "invite@example.com" + } + it("기존 PENDING 초대가 있으면 취소 이벤트도 발행한다") { val workspaceId = UUID.randomUUID() val invitedBy = UUID.randomUUID() val existingInvite = createInvite(workspaceId = workspaceId, email = "dup@example.com") - every { workspaceRepository.findById(workspaceId) } returns Optional.of( + every { workspaceService.findMutableById(workspaceId) } returns io.deck.iam.domain - .WorkspaceEntity(name = "워크스페이스", id = workspaceId), - ) + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) every { userRepository.findByEmail("dup@example.com") } returns null every { inviteRepository.findByWorkspaceIdAndEmailAndStatus(workspaceId, "dup@example.com", InviteStatus.PENDING) @@ -197,7 +492,7 @@ class WorkspaceInviteServiceTest : it.workspaceId == workspaceId && it.inviteId == existingInvite.id && it.email == existingInvite.email && - it.cancelledBy?.value == invitedBy + it.cancelledBy.value == invitedBy }, ) } @@ -237,6 +532,27 @@ class WorkspaceInviteServiceTest : } } + it("수락 직전에 이미 멤버가 되었으면 invite를 소비하지 않고 ALREADY_MEMBER를 유지한다") { + val token = "already-member-token" + val tokenHash = HashUtils.sha3(token) + val workspaceId = UUID.randomUUID() + val invite = createInvite(workspaceId = workspaceId, email = "join@example.com", tokenHash = tokenHash) + val user = createUser(name = "가입 사용자", email = "join@example.com") + every { inviteRepository.findByTokenHash(tokenHash) } returns invite + every { userRepository.findByEmail("join@example.com") } returns user + every { memberService.addMember(workspaceId, user.id, user.id) } throws + BadRequestException("iam.workspace_member.already_member", "ALREADY_MEMBER") + + val ex = + shouldThrow { + workspaceInviteService.accept(token) + } + + ex.code shouldBe "ALREADY_MEMBER" + invite.status shouldBe InviteStatus.PENDING + invite.acceptedUserId shouldBe null + } + it("유효하지 않은 토큰이면 BadRequestException을 던진다") { val token = "invalid-token" every { inviteRepository.findByTokenHash(HashUtils.sha3(token)) } returns null @@ -250,7 +566,14 @@ class WorkspaceInviteServiceTest : val token = "token-for-unregistered" val tokenHash = HashUtils.sha3(token) val workspaceId = UUID.randomUUID() - val invite = createInvite(workspaceId = workspaceId, email = "new@example.com", tokenHash = tokenHash) + val invitedBy = UUID.randomUUID() + val invite = + createInvite( + workspaceId = workspaceId, + email = "new@example.com", + tokenHash = tokenHash, + createdBy = invitedBy, + ) val createdUser = createUser(name = "새 사용자", email = "new@example.com") every { inviteRepository.findByTokenHash(tokenHash) } returns invite @@ -262,7 +585,7 @@ class WorkspaceInviteServiceTest : name = "새 사용자", email = "new@example.com", roleIds = emptySet(), - createdBy = UserId(workspaceId), + createdBy = UserId(invitedBy), contactProfile = null, ) } returns createdUser @@ -281,11 +604,74 @@ class WorkspaceInviteServiceTest : name = "새 사용자", email = "new@example.com", roleIds = emptySet(), - createdBy = UserId(workspaceId), + createdBy = UserId(invitedBy), + contactProfile = null, + ) + } + } + + it("resend 이력이 있으면 마지막 invite actor를 신규 사용자 creator로 사용한다") { + val token = "token-for-resent-invite" + val tokenHash = HashUtils.sha3(token) + val workspaceId = UUID.randomUUID() + val originalInviter = UUID.randomUUID() + val resendBy = UUID.randomUUID() + val invite = + createInvite( + workspaceId = workspaceId, + email = "resent@example.com", + tokenHash = tokenHash, + createdBy = originalInviter, + inviterId = resendBy, + ) + val createdUser = createUser(name = "재초대 사용자", email = "resent@example.com") + + every { inviteRepository.findByTokenHash(tokenHash) } returns invite + every { userRepository.findByEmail("resent@example.com") } returns null + every { + userService.createWithRoles( + username = "resent@example.com", + password = "Password1!", + name = "재초대 사용자", + email = "resent@example.com", + roleIds = emptySet(), + createdBy = UserId(resendBy), + contactProfile = null, + ) + } returns createdUser + every { inviteRepository.save(any()) } answers { firstArg() } + every { memberService.addMember(workspaceId, createdUser.id, createdUser.id) } returns WorkspaceMemberEntity(workspaceId, createdUser.id) + every { eventPublisher.publishEvent(any()) } just Runs + + val result = workspaceInviteService.accept(token, name = "재초대 사용자", password = "Password1!") + + result shouldBe createdUser.id + verify(exactly = 1) { + userService.createWithRoles( + username = "resent@example.com", + password = "Password1!", + name = "재초대 사용자", + email = "resent@example.com", + roleIds = emptySet(), + createdBy = UserId(resendBy), contactProfile = null, ) } } + + it("external workspace 초대는 수락할 수 없다") { + val token = "valid-token" + val tokenHash = HashUtils.sha3(token) + val workspaceId = UUID.randomUUID() + val invite = createInvite(workspaceId = workspaceId, email = "join@example.com", tokenHash = tokenHash) + every { inviteRepository.findByTokenHash(tokenHash) } returns invite + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") + + shouldThrow { + workspaceInviteService.accept(token) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("cancel") { @@ -347,6 +733,19 @@ class WorkspaceInviteServiceTest : invite1.status shouldBe InviteStatus.CANCELLED invite2.status shouldBe InviteStatus.CANCELLED } + + it("external workspace 초대는 취소할 수 없다") { + val workspaceId = UUID.randomUUID() + val inviteId = UUID.randomUUID() + val invite = createInvite(workspaceId = workspaceId) + every { inviteRepository.findById(inviteId) } returns Optional.of(invite) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") + + shouldThrow { + workspaceInviteService.cancel(inviteId, workspaceId) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("resend") { @@ -356,10 +755,9 @@ class WorkspaceInviteServiceTest : val resendBy = UUID.randomUUID() val invite = createInvite(workspaceId = workspaceId, email = "resend@example.com") val originalTokenHash = invite.tokenHash - every { workspaceRepository.findById(workspaceId) } returns Optional.of( + every { workspaceService.findMutableById(workspaceId) } returns io.deck.iam.domain - .WorkspaceEntity(name = "Shared Workspace", id = workspaceId), - ) + .WorkspaceEntity(name = "Shared Workspace", id = workspaceId) every { inviteRepository.findById(inviteId) } returns Optional.of(invite) every { inviteRepository.save(any()) } answers { firstArg() } every { userRepository.findById(resendBy) } returns Optional.of(createUser(id = resendBy, name = "재발송자")) @@ -368,6 +766,7 @@ class WorkspaceInviteServiceTest : workspaceInviteService.resend(inviteId, resendBy, workspaceId) invite.tokenHash shouldNotBe originalTokenHash + invite.inviterId shouldBe resendBy verify(exactly = 1) { eventPublisher.publishEvent( match { @@ -387,10 +786,9 @@ class WorkspaceInviteServiceTest : val workspaceId = UUID.randomUUID() val inviteId = UUID.randomUUID() val invite = createInvite(workspaceId = workspaceId, status = InviteStatus.ACCEPTED) - every { workspaceRepository.findById(workspaceId) } returns Optional.of( + every { workspaceService.findMutableById(workspaceId) } returns io.deck.iam.domain - .WorkspaceEntity(name = "워크스페이스", id = workspaceId), - ) + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) every { inviteRepository.findById(inviteId) } returns Optional.of(invite) shouldThrow { @@ -398,6 +796,26 @@ class WorkspaceInviteServiceTest : } } + it("동시 재발송으로 optimistic lock이 깨지면 ConflictException을 던진다") { + val workspaceId = UUID.randomUUID() + val inviteId = UUID.randomUUID() + val resendBy = UUID.randomUUID() + val invite = createInvite(workspaceId = workspaceId, email = "resend@example.com") + every { workspaceService.findMutableById(workspaceId) } returns + io.deck.iam.domain + .WorkspaceEntity(name = "Shared Workspace", id = workspaceId) + every { inviteRepository.findById(inviteId) } returns Optional.of(invite) + every { inviteRepository.saveAndFlush(any()) } throws + OptimisticLockingFailureException("concurrent resend") + + val ex = + shouldThrow { + workspaceInviteService.resend(inviteId, resendBy, workspaceId) + } + + ex.code shouldBe "WORKSPACE_INVITE_CONCURRENT_RESEND" + } + it("다른 workspace의 초대이면 IllegalArgumentException을 던진다") { val inviteId = UUID.randomUUID() val invite = createInvite(workspaceId = UUID.randomUUID()) @@ -415,13 +833,12 @@ class WorkspaceInviteServiceTest : val invite2 = createInvite(workspaceId = workspaceId, email = "two@example.com") val tokenHash1 = invite1.tokenHash val tokenHash2 = invite2.tokenHash - every { workspaceRepository.findById(workspaceId) } returns Optional.of( + every { workspaceService.findMutableById(workspaceId) } returns io.deck.iam.domain - .WorkspaceEntity(name = "워크스페이스", id = workspaceId), - ) + .WorkspaceEntity(name = "워크스페이스", id = workspaceId) every { inviteRepository.findById(invite1.id) } returns Optional.of(invite1) every { inviteRepository.findById(invite2.id) } returns Optional.of(invite2) - every { inviteRepository.save(any()) } answers { firstArg() } + every { inviteRepository.saveAndFlush(any()) } answers { firstArg() } every { userRepository.findById(resendBy) } returns Optional.of(createUser(id = resendBy)) every { eventPublisher.publishEvent(any()) } just Runs @@ -430,5 +847,18 @@ class WorkspaceInviteServiceTest : invite1.tokenHash shouldNotBe tokenHash1 invite2.tokenHash shouldNotBe tokenHash2 } + + it("external workspace 초대는 재발송할 수 없다") { + val workspaceId = UUID.randomUUID() + val inviteId = UUID.randomUUID() + val invite = createInvite(workspaceId = workspaceId, email = "resend@example.com") + every { inviteRepository.findById(inviteId) } returns Optional.of(invite) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") + + shouldThrow { + workspaceInviteService.resend(inviteId, UUID.randomUUID(), workspaceId) + }.messageCode shouldBe "iam.workspace.external_locked" + } } }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceMemberServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceMemberServiceTest.kt index ceffaa875..2207137e9 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceMemberServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceMemberServiceTest.kt @@ -8,7 +8,6 @@ import io.deck.iam.event.WorkspaceMemberAddedEvent import io.deck.iam.event.WorkspaceMemberOwnershipChangedEvent import io.deck.iam.event.WorkspaceMemberRemovedEvent import io.deck.iam.repository.WorkspaceMemberRepository -import io.deck.iam.repository.WorkspaceRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -19,22 +18,24 @@ import io.mockk.mockk import io.mockk.verify import org.springframework.context.ApplicationEventPublisher import org.springframework.security.access.AccessDeniedException -import java.util.Optional import java.util.UUID class WorkspaceMemberServiceTest : DescribeSpec({ lateinit var memberRepository: WorkspaceMemberRepository - lateinit var workspaceRepository: WorkspaceRepository + lateinit var workspaceService: WorkspaceService lateinit var eventPublisher: ApplicationEventPublisher lateinit var workspaceMemberService: WorkspaceMemberService beforeEach { memberRepository = mockk() - workspaceRepository = mockk() + workspaceService = mockk() eventPublisher = mockk() - workspaceMemberService = WorkspaceMemberService(memberRepository, workspaceRepository, eventPublisher) + workspaceMemberService = WorkspaceMemberService(memberRepository, workspaceService, eventPublisher) every { memberRepository.countByWorkspaceIdAndIsOwnerTrue(any()) } returns 2L + every { workspaceService.findMutableById(any()) } answers { + WorkspaceEntity(name = "워크스페이스", id = firstArg()) + } } describe("findMembers") { @@ -131,6 +132,7 @@ class WorkspaceMemberServiceTest : val userId = UUID.randomUUID() val addedBy = UUID.randomUUID() val memberSlot = io.mockk.slot() + every { workspaceService.findMutableById(workspaceId) } returns WorkspaceEntity(name = "워크스페이스", id = workspaceId) every { memberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId) } returns false every { memberRepository.save(capture(memberSlot)) } answers { firstArg() } @@ -164,6 +166,17 @@ class WorkspaceMemberServiceTest : ex.code shouldBe "ALREADY_MEMBER" } + + it("external workspace에는 멤버를 추가할 수 없다") { + val workspaceId = UUID.randomUUID() + val userId = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") + + shouldThrow { + workspaceMemberService.addMember(workspaceId, userId, UUID.randomUUID()) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("removeMember") { @@ -172,6 +185,7 @@ class WorkspaceMemberServiceTest : val userId = UUID.randomUUID() val removedBy = UUID.randomUUID() val member = WorkspaceMemberEntity(workspaceId = workspaceId, userId = userId) + every { workspaceService.findMutableById(workspaceId) } returns WorkspaceEntity(name = "워크스페이스", id = workspaceId) every { memberRepository.findByWorkspaceIdAndUserId(workspaceId, userId) } returns member every { memberRepository.delete(member) } just Runs every { eventPublisher.publishEvent(any()) } just Runs @@ -200,6 +214,17 @@ class WorkspaceMemberServiceTest : workspaceMemberService.removeMember(workspaceId, userId, UUID.randomUUID()) } } + + it("external workspace의 멤버는 제거할 수 없다") { + val workspaceId = UUID.randomUUID() + val userId = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") + + shouldThrow { + workspaceMemberService.removeMember(workspaceId, userId, UUID.randomUUID()) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("leave") { @@ -209,7 +234,7 @@ class WorkspaceMemberServiceTest : val workspace = WorkspaceEntity(name = "워크스페이스", id = workspaceId) val ownerMember = WorkspaceMemberEntity(workspaceId = workspaceId, userId = ownerId, isOwner = true) - every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + every { workspaceService.findMutableById(workspaceId) } returns workspace every { memberRepository.findByWorkspaceIdAndUserId(workspaceId, ownerId) } returns ownerMember every { memberRepository.countByWorkspaceIdAndIsOwnerTrue(workspaceId) } returns 1L @@ -224,7 +249,7 @@ class WorkspaceMemberServiceTest : val workspace = WorkspaceEntity(name = "워크스페이스", id = workspaceId) val ownerMember = WorkspaceMemberEntity(workspaceId = workspaceId, userId = ownerId, isOwner = true) - every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + every { workspaceService.findMutableById(workspaceId) } returns workspace every { memberRepository.findByWorkspaceIdAndUserId(workspaceId, ownerId) } returns ownerMember every { memberRepository.delete(ownerMember) } just Runs every { eventPublisher.publishEvent(any()) } just Runs @@ -240,7 +265,7 @@ class WorkspaceMemberServiceTest : val workspace = WorkspaceEntity(name = "워크스페이스", id = workspaceId) val member = WorkspaceMemberEntity(workspaceId = workspaceId, userId = memberId) - every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + every { workspaceService.findMutableById(workspaceId) } returns workspace every { memberRepository.findByWorkspaceIdAndUserId(workspaceId, memberId) } returns member every { memberRepository.delete(member) } just Runs every { eventPublisher.publishEvent(any()) } just Runs @@ -254,12 +279,23 @@ class WorkspaceMemberServiceTest : val workspaceId = UUID.randomUUID() val userId = UUID.randomUUID() - every { workspaceRepository.findById(workspaceId) } returns Optional.empty() + every { workspaceService.findMutableById(workspaceId) } throws NotFoundException("iam.workspace.not_found") shouldThrow { workspaceMemberService.leave(workspaceId, userId) } } + + it("external workspace는 탈퇴할 수 없다") { + val workspaceId = UUID.randomUUID() + val userId = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") + + shouldThrow { + workspaceMemberService.leave(workspaceId, userId) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("removeMember") { @@ -398,5 +434,15 @@ class WorkspaceMemberServiceTest : workspaceMemberService.replaceOwners(workspaceId, emptyList()) }.messageCode shouldBe "iam.workspace.last_owner_required" } + + it("external workspace는 owner를 교체할 수 없다") { + val workspaceId = UUID.randomUUID() + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") + + shouldThrow { + workspaceMemberService.replaceOwners(workspaceId, listOf(UUID.randomUUID())) + }.messageCode shouldBe "iam.workspace.external_locked" + } } }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceProvisioningCommandImplTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceProvisioningCommandImplTest.kt index d4f3478fe..cc15a0c7a 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceProvisioningCommandImplTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceProvisioningCommandImplTest.kt @@ -1,6 +1,5 @@ package io.deck.iam.service -import io.deck.iam.domain.WorkspaceManagedType import io.kotest.core.spec.style.DescribeSpec import io.mockk.mockk import io.mockk.verify @@ -12,7 +11,7 @@ class WorkspaceProvisioningCommandImplTest : val command = WorkspaceProvisioningCommandImpl(workspaceService) describe("createPersonalWorkspace") { - it("개인 워크스페이스 생성 규칙으로 WorkspaceService.create를 위임한다") { + it("개인 워크스페이스 생성 규칙으로 WorkspaceService.ensurePersonalWorkspace를 위임한다") { val userId = UUID.randomUUID() command.createPersonalWorkspace( @@ -21,11 +20,10 @@ class WorkspaceProvisioningCommandImplTest : ) verify(exactly = 1) { - workspaceService.create( + workspaceService.ensurePersonalWorkspace( name = "홍길동의 워크스페이스", description = null, initialOwnerId = userId, - managedType = WorkspaceManagedType.USER_MANAGED, ) } } diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt index 9e686b387..4213cba79 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt @@ -2,15 +2,18 @@ package io.deck.iam.service import io.deck.common.exception.BadRequestException import io.deck.common.exception.NotFoundException -import io.deck.iam.domain.SystemSettingEntity +import io.deck.iam.ManagementType +import io.deck.iam.domain.ExternalReference +import io.deck.iam.domain.ExternalSource +import io.deck.iam.domain.PlatformSettingEntity import io.deck.iam.domain.WorkspaceEntity -import io.deck.iam.domain.WorkspaceManagedType import io.deck.iam.domain.WorkspaceMemberEntity import io.deck.iam.domain.WorkspacePolicy import io.deck.iam.event.WorkspaceCreatedEvent import io.deck.iam.event.WorkspaceDeletedEvent import io.deck.iam.event.WorkspaceUpdatedEvent import io.deck.iam.repository.WorkspaceMemberRepository +import io.deck.iam.repository.WorkspaceMutationJdbcRepository import io.deck.iam.repository.WorkspaceRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -21,6 +24,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.verify import org.springframework.context.ApplicationEventPublisher +import org.springframework.dao.DataIntegrityViolationException import org.springframework.security.access.AccessDeniedException import java.util.Optional import java.util.UUID @@ -30,20 +34,29 @@ class WorkspaceServiceTest : lateinit var workspaceRepository: WorkspaceRepository lateinit var memberRepository: WorkspaceMemberRepository lateinit var eventPublisher: ApplicationEventPublisher - lateinit var systemSettingService: SystemSettingService + lateinit var platformSettingService: PlatformSettingService + lateinit var workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository lateinit var workspaceService: WorkspaceService beforeEach { workspaceRepository = mockk() memberRepository = mockk() eventPublisher = mockk() - systemSettingService = mockk() - workspaceService = WorkspaceService(workspaceRepository, memberRepository, eventPublisher, systemSettingService) + platformSettingService = mockk() + workspaceMutationJdbcRepository = mockk() + workspaceService = + WorkspaceService( + workspaceRepository, + memberRepository, + eventPublisher, + platformSettingService, + workspaceMutationJdbcRepository, + ) every { - systemSettingService.getSettings() - } returns SystemSettingEntity( - workspacePolicy = WorkspacePolicy(useUserManaged = true, useSystemManaged = true, useSelector = true), + platformSettingService.getSettings() + } returns PlatformSettingEntity( + workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = true, useSelector = true), ) every { memberRepository.existsByWorkspaceIdAndUserIdAndIsOwnerTrue(any(), any()) } returns true } @@ -70,7 +83,7 @@ class WorkspaceServiceTest : describe("findVisibleByUser") { it("workspace policy가 null이면 빈 목록을 반환한다") { val userId = UUID.randomUUID() - every { systemSettingService.getSettings() } returns SystemSettingEntity(workspacePolicy = null) + every { platformSettingService.getSettings() } returns PlatformSettingEntity(workspacePolicy = null) workspaceService.findVisibleByUser(userId) shouldBe emptyList() verify(exactly = 0) { workspaceRepository.findAllByMemberUserId(any()) } @@ -78,12 +91,12 @@ class WorkspaceServiceTest : it("활성화된 managed type만 노출한다") { val userId = UUID.randomUUID() - val userManaged = WorkspaceEntity(name = "A", managedType = WorkspaceManagedType.USER_MANAGED) - val ownerManaged = WorkspaceEntity(name = "B", managedType = WorkspaceManagedType.SYSTEM_MANAGED) + val userManaged = WorkspaceEntity(name = "A", managedType = ManagementType.USER_MANAGED) + val ownerManaged = WorkspaceEntity(name = "B", managedType = ManagementType.PLATFORM_MANAGED) every { - systemSettingService.getSettings() - } returns SystemSettingEntity( - workspacePolicy = WorkspacePolicy(useUserManaged = false, useSystemManaged = true, useSelector = true), + platformSettingService.getSettings() + } returns PlatformSettingEntity( + workspacePolicy = WorkspacePolicy(useUserManaged = false, usePlatformManaged = true, useSelector = true), ) every { workspaceRepository.findAllByMemberUserId(userId) } returns listOf(userManaged, ownerManaged) @@ -91,14 +104,14 @@ class WorkspaceServiceTest : } } - describe("create") { - it("workspace와 초기 owner membership을 저장하고 생성 이벤트를 발행한다") { + describe("createPlatformManaged") { + it("platform-managed workspace와 초기 owner membership을 저장하고 생성 이벤트를 발행한다") { val initialOwnerId = UUID.randomUUID() every { workspaceRepository.save(any()) } answers { firstArg() } every { memberRepository.save(any()) } answers { firstArg() } every { eventPublisher.publishEvent(any()) } just Runs - val workspace = workspaceService.create("새 워크스페이스", "설명", initialOwnerId) + val workspace = workspaceService.createPlatformManaged("새 워크스페이스", "설명", initialOwnerId) workspace.name shouldBe "새 워크스페이스" workspace.description shouldBe "설명" @@ -122,35 +135,33 @@ class WorkspaceServiceTest : } } - it("allowedDomains는 소문자 trim deduplicate 후 저장한다") { + it("autoJoinDomains는 소문자 trim deduplicate 후 저장한다") { val initialOwnerId = UUID.randomUUID() every { workspaceRepository.save(any()) } answers { firstArg() } every { memberRepository.save(any()) } answers { firstArg() } every { eventPublisher.publishEvent(any()) } just Runs val workspace = - workspaceService.create( + workspaceService.createPlatformManaged( name = "새 워크스페이스", description = null, initialOwnerId = initialOwnerId, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = listOf(" ACME.COM ", "acme.com", "dev.acme.com"), + autoJoinDomains = listOf(" ACME.COM ", "acme.com", "dev.acme.com"), ) - workspace.allowedDomains shouldBe listOf("acme.com", "dev.acme.com") + workspace.autoJoinDomains shouldBe listOf("acme.com", "dev.acme.com") } - it("유효하지 않은 allowed domain이 있으면 생성할 수 없다") { + it("유효하지 않은 auto join domain이 있으면 생성할 수 없다") { val initialOwnerId = UUID.randomUUID() val exception = shouldThrow { - workspaceService.create( + workspaceService.createPlatformManaged( name = "새 워크스페이스", description = null, initialOwnerId = initialOwnerId, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = listOf("invalid domain"), + autoJoinDomains = listOf("invalid domain"), ) } @@ -158,10 +169,117 @@ class WorkspaceServiceTest : } } + describe("upsertExternalWorkspace") { + it("기존 external workspace가 있으면 identity만 동기화하고 기존 description을 보존한다") { + val existingWorkspace = + WorkspaceEntity( + name = "Acme", + description = "Existing description", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + every { + workspaceRepository.findByExternalReferenceSourceAndExternalReferenceExternalId(ExternalSource.AIP, "aip-org-1") + } returns existingWorkspace + + val result = + workspaceService.upsertExternalWorkspace( + reference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + name = "Renamed Acme", + description = null, + ) + + result shouldBe existingWorkspace + result.name shouldBe "Renamed Acme" + result.description shouldBe "Existing description" + verify(exactly = 0) { workspaceRepository.save(any()) } + verify(exactly = 0) { memberRepository.save(any()) } + verify(exactly = 0) { eventPublisher.publishEvent(any()) } + } + + it("external workspace가 없으면 PLATFORM_MANAGED external workspace를 생성한다") { + every { + workspaceRepository.findByExternalReferenceSourceAndExternalReferenceExternalId(ExternalSource.AIP, "aip-org-1") + } returns null + val savedWorkspace = + WorkspaceEntity( + name = "Acme", + description = "Imported workspace", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + every { + workspaceMutationJdbcRepository.upsertExternalWorkspace( + ExternalReference(ExternalSource.AIP, "aip-org-1"), + "Acme", + "Imported workspace", + ) + } returns savedWorkspace.id + every { workspaceRepository.findById(savedWorkspace.id) } returns Optional.of(savedWorkspace) + + val result = + workspaceService.upsertExternalWorkspace( + reference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + name = "Acme", + description = "Imported workspace", + ) + + result.name shouldBe "Acme" + result.description shouldBe "Imported workspace" + result.managedType shouldBe ManagementType.PLATFORM_MANAGED + result.externalReference?.externalId shouldBe "aip-org-1" + verify(exactly = 1) { + workspaceMutationJdbcRepository.upsertExternalWorkspace( + ExternalReference(ExternalSource.AIP, "aip-org-1"), + "Acme", + "Imported workspace", + ) + } + verify(exactly = 0) { memberRepository.save(any()) } + verify(exactly = 0) { eventPublisher.publishEvent(any()) } + } + + it("동시 생성 경합이면 재조회한 external workspace를 재사용한다") { + val existingWorkspace = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + every { + workspaceRepository.findByExternalReferenceSourceAndExternalReferenceExternalId(ExternalSource.AIP, "aip-org-1") + } returns null + every { + workspaceMutationJdbcRepository.upsertExternalWorkspace( + ExternalReference(ExternalSource.AIP, "aip-org-1"), + "Acme", + null, + ) + } returns existingWorkspace.id + every { workspaceRepository.findById(existingWorkspace.id) } returns Optional.of(existingWorkspace) + + val result = + workspaceService.upsertExternalWorkspace( + reference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + name = "Acme", + description = null, + ) + + result shouldBe existingWorkspace + verify(exactly = 1) { + workspaceMutationJdbcRepository.upsertExternalWorkspace( + ExternalReference(ExternalSource.AIP, "aip-org-1"), + "Acme", + null, + ) + } + } + } + describe("createForUser") { it("user-managed 정책이 꺼져 있으면 생성할 수 없다") { val initialOwnerId = UUID.randomUUID() - every { systemSettingService.getSettings() } returns SystemSettingEntity(workspacePolicy = null) + every { platformSettingService.getSettings() } returns PlatformSettingEntity(workspacePolicy = null) shouldThrow { workspaceService.createForUser("새 워크스페이스", null, initialOwnerId) @@ -169,6 +287,69 @@ class WorkspaceServiceTest : } } + describe("ensurePersonalWorkspace") { + it("이미 owner인 user-managed workspace가 있으면 재사용한다") { + val userId = UUID.randomUUID() + val workspaceId = UUID.randomUUID() + val existingMembership = + WorkspaceMemberEntity( + workspaceId = workspaceId, + userId = userId, + isOwner = true, + ) + val existingWorkspace = + WorkspaceEntity( + name = "기존 개인 워크스페이스", + id = workspaceId, + managedType = ManagementType.USER_MANAGED, + ) + + every { memberRepository.findActiveOwnerMembershipsByUserId(userId) } returns listOf(existingMembership) + every { workspaceRepository.findAllById(listOf(workspaceId)) } returns listOf(existingWorkspace) + + val result = workspaceService.ensurePersonalWorkspace("새 워크스페이스", null, userId) + + result shouldBe existingWorkspace + verify(exactly = 0) { workspaceRepository.save(any()) } + verify(exactly = 0) { eventPublisher.publishEvent(any()) } + } + + it("owner workspace가 없으면 새로 생성한다") { + val userId = UUID.randomUUID() + val savedWorkspace = + WorkspaceEntity( + name = "홍길동의 워크스페이스", + id = UUID.randomUUID(), + managedType = ManagementType.USER_MANAGED, + ) + + every { memberRepository.findActiveOwnerMembershipsByUserId(userId) } returns emptyList() + every { workspaceRepository.save(any()) } returns savedWorkspace + every { memberRepository.save(any()) } answers { firstArg() } + every { eventPublisher.publishEvent(any()) } just Runs + + val result = + workspaceService.ensurePersonalWorkspace( + name = "홍길동의 워크스페이스", + description = null, + initialOwnerId = userId, + ) + + result shouldBe savedWorkspace + verify(exactly = 1) { workspaceRepository.save(any()) } + verify(exactly = 1) { memberRepository.save(any()) } + verify(exactly = 1) { + eventPublisher.publishEvent( + match { + it is WorkspaceCreatedEvent && + it.workspaceId == savedWorkspace.id && + it.initialOwnerId.value == userId + }, + ) + } + } + } + describe("update") { it("owner membership이 있으면 수정 이벤트를 발행한다") { val workspaceId = UUID.randomUUID() @@ -204,22 +385,73 @@ class WorkspaceServiceTest : workspaceService.update(workspaceId, "변경", null, requesterId) } } + + it("external workspace는 owner라도 수정할 수 없다") { + val workspaceId = UUID.randomUUID() + val requesterId = UUID.randomUUID() + val workspace = + WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + + shouldThrow { + workspaceService.update(workspaceId, "변경", null, requesterId) + }.messageCode shouldBe "iam.workspace.external_locked" + } } - describe("updateByAdmin") { + describe("updatePlatformManaged") { it("기본값 경로에서도 워크스페이스 조회는 한 번만 수행한다") { val workspaceId = UUID.randomUUID() val updatedBy = UUID.randomUUID() - val workspace = WorkspaceEntity(name = "기존", description = "기존 설명", id = workspaceId) + val workspace = + WorkspaceEntity( + name = "기존", + description = "기존 설명", + id = workspaceId, + managedType = ManagementType.PLATFORM_MANAGED, + ) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) every { eventPublisher.publishEvent(any()) } just Runs - val result = workspaceService.updateByAdmin(workspaceId, "변경", "새 설명", updatedBy) + val result = workspaceService.updatePlatformManaged(workspaceId, "변경", "새 설명", updatedBy) result.name shouldBe "변경" result.description shouldBe "새 설명" verify(exactly = 1) { workspaceRepository.findById(workspaceId) } } + + it("external workspace는 관리자도 수정할 수 없다") { + val workspaceId = UUID.randomUUID() + val updatedBy = UUID.randomUUID() + val workspace = + WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + + shouldThrow { + workspaceService.updatePlatformManaged(workspaceId, "변경", null, updatedBy) + }.messageCode shouldBe "iam.workspace.external_locked" + } + + it("user-managed workspace는 관리자 경로로 수정할 수 없다") { + val workspaceId = UUID.randomUUID() + val updatedBy = UUID.randomUUID() + val workspace = WorkspaceEntity(name = "개인 워크스페이스", id = workspaceId, managedType = ManagementType.USER_MANAGED) + every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + + shouldThrow { + workspaceService.updatePlatformManaged(workspaceId, "변경", null, updatedBy) + }.messageCode shouldBe "iam.workspace.not_found" + } } describe("delete") { @@ -243,14 +475,14 @@ class WorkspaceServiceTest : } } - it("관리자 삭제도 삭제 이벤트를 발행한다") { + it("platform-managed 관리자 삭제도 삭제 이벤트를 발행한다") { val workspaceId = UUID.randomUUID() val deletedBy = UUID.randomUUID() - val workspace = WorkspaceEntity(name = "관리자 삭제 대상", id = workspaceId) + val workspace = WorkspaceEntity(name = "관리자 삭제 대상", id = workspaceId, managedType = ManagementType.PLATFORM_MANAGED) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) every { eventPublisher.publishEvent(any()) } just Runs - workspaceService.deleteByAdmin(workspaceId, deletedBy) + workspaceService.deletePlatformManaged(workspaceId, deletedBy) verify(exactly = 1) { eventPublisher.publishEvent( @@ -262,6 +494,51 @@ class WorkspaceServiceTest : ) } } + + it("external workspace는 owner라도 삭제할 수 없다") { + val workspaceId = UUID.randomUUID() + val deletedBy = UUID.randomUUID() + val workspace = + WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + + shouldThrow { + workspaceService.delete(workspaceId, deletedBy) + }.messageCode shouldBe "iam.workspace.external_locked" + } + + it("external workspace는 관리자도 삭제할 수 없다") { + val workspaceId = UUID.randomUUID() + val deletedBy = UUID.randomUUID() + val workspace = + WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) + every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + + shouldThrow { + workspaceService.deletePlatformManaged(workspaceId, deletedBy) + }.messageCode shouldBe "iam.workspace.external_locked" + } + + it("user-managed workspace는 관리자 경로로 삭제할 수 없다") { + val workspaceId = UUID.randomUUID() + val deletedBy = UUID.randomUUID() + val workspace = WorkspaceEntity(name = "개인 워크스페이스", id = workspaceId, managedType = ManagementType.USER_MANAGED) + every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + + shouldThrow { + workspaceService.deletePlatformManaged(workspaceId, deletedBy) + }.messageCode shouldBe "iam.workspace.not_found" + } } describe("verifyOwner") { diff --git a/backend/meetpie/src/main/kotlin/io/deck/meetpie/calendar/capability/CalendarCapabilitySupport.kt b/backend/meetpie/src/main/kotlin/io/deck/meetpie/calendar/capability/CalendarCapabilitySupport.kt index 5450fa629..de8d3871e 100644 --- a/backend/meetpie/src/main/kotlin/io/deck/meetpie/calendar/capability/CalendarCapabilitySupport.kt +++ b/backend/meetpie/src/main/kotlin/io/deck/meetpie/calendar/capability/CalendarCapabilitySupport.kt @@ -1,6 +1,7 @@ package io.deck.meetpie.calendar.capability import io.deck.ax.api.AxDescription +import io.deck.iam.api.consoleProgramPath import io.deck.meetpie.calendar.domain.CalendarConnectionEntity import io.deck.meetpie.calendar.domain.CalendarEvent import io.deck.meetpie.contact.domain.ContactEntity @@ -199,10 +200,11 @@ data class ResolvedCalendar( object CalendarCapabilityPayloads { private val timeFormatter = DateTimeFormatter.ofPattern("H:mm") + private val calendarIntegrationsPath = consoleProgramPath("/calendar-integrations/") fun needsConnection( message: String, - connectUrl: String = "/calendar-integrations/", + connectUrl: String = calendarIntegrationsPath, ): ManageCalendarOutput.NeedsConnection = ManageCalendarOutput.NeedsConnection( message = message, diff --git a/backend/meetpie/src/main/kotlin/io/deck/meetpie/registry/ProgramRegistrar.kt b/backend/meetpie/src/main/kotlin/io/deck/meetpie/registry/ProgramRegistrar.kt index 47fd27597..d54a19704 100644 --- a/backend/meetpie/src/main/kotlin/io/deck/meetpie/registry/ProgramRegistrar.kt +++ b/backend/meetpie/src/main/kotlin/io/deck/meetpie/registry/ProgramRegistrar.kt @@ -2,6 +2,7 @@ package io.deck.meetpie.registry import io.deck.iam.api.ProgramDefinition import io.deck.iam.api.ProgramRegistrar +import io.deck.iam.api.consoleProgramPath import org.springframework.stereotype.Component @Component @@ -12,7 +13,18 @@ class ProgramRegistrar : ProgramRegistrar { listOf( ProgramDefinition( "CALENDAR_INTEGRATION", - "/calendar-integrations", + consoleProgramPath("/calendar-integrations"), + setOf( + "CALENDAR_INTEGRATION_READ", + "CALENDAR_INTEGRATION_WRITE", + "HOLIDAY_MANAGEMENT_READ", + "HOLIDAY_MANAGEMENT_WRITE", + ), + workspace = workspacePolicy, + ), + ProgramDefinition( + "CALENDAR_INTEGRATION_MANAGE", + consoleProgramPath("/calendar-integrations/manage"), setOf( "CALENDAR_INTEGRATION_READ", "CALENDAR_INTEGRATION_WRITE", @@ -23,49 +35,49 @@ class ProgramRegistrar : ProgramRegistrar { ), ProgramDefinition( "BOOKING_PROFILE", - "/booking/profile", + consoleProgramPath("/booking/profile"), setOf("CALENDAR_INTEGRATION_READ"), workspace = workspacePolicy, ), ProgramDefinition( "BOOKING_SCHEDULES", - "/booking/schedules", + consoleProgramPath("/booking/schedules"), setOf("CALENDAR_INTEGRATION_READ"), workspace = workspacePolicy, ), ProgramDefinition( "BOOKING_EVENT_TYPES", - "/booking/event-types", + consoleProgramPath("/booking/event-types"), setOf("CALENDAR_INTEGRATION_READ"), workspace = workspacePolicy, ), ProgramDefinition( "BOOKING_DASHBOARD", - "/booking", + consoleProgramPath("/booking"), setOf("CALENDAR_INTEGRATION_READ"), workspace = workspacePolicy, ), ProgramDefinition( "BOOKING_SETTINGS", - "/booking/settings", + consoleProgramPath("/booking/settings"), setOf("CALENDAR_INTEGRATION_READ"), workspace = workspacePolicy, ), ProgramDefinition( "BOOKING_BOOKINGS", - "/booking/bookings", + consoleProgramPath("/booking/bookings"), setOf("CALENDAR_INTEGRATION_READ"), workspace = workspacePolicy, ), ProgramDefinition( "CONTACT", - "/contacts", + consoleProgramPath("/contacts"), setOf("CONTACT_MANAGEMENT_READ", "CONTACT_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), ProgramDefinition( "MY_NAMECARD", - "/my-namecards", + consoleProgramPath("/my-namecards"), setOf("MY_NAMECARD_MANAGEMENT_READ", "MY_NAMECARD_MANAGEMENT_WRITE"), workspace = workspacePolicy, ), diff --git a/backend/meetpie/src/main/resources/mcp/calendar-widget-preview.html b/backend/meetpie/src/main/resources/mcp/calendar-widget-preview.html index 1a2f6380c..a7e79c3f3 100644 --- a/backend/meetpie/src/main/resources/mcp/calendar-widget-preview.html +++ b/backend/meetpie/src/main/resources/mcp/calendar-widget-preview.html @@ -395,7 +395,7 @@

%%MSG:meetpie.widget.preview.heading%%

input: { action: 'list', from: '2026-03-16', to: '2026-03-23' }, result: { message: '%%MSG:meetpie.widget.preview.fixture.calendar.connect.message%%', - connectUrl: '/calendar-integrations/', + connectUrl: '/console/calendar-integrations/', providerOptions: ['google', 'microsoft', 'caldav'] } }, diff --git a/backend/meetpie/src/test/kotlin/io/deck/meetpie/calendar/capability/ManageCalendarCapabilityTest.kt b/backend/meetpie/src/test/kotlin/io/deck/meetpie/calendar/capability/ManageCalendarCapabilityTest.kt index 1d95b28ff..c2f2a258a 100644 --- a/backend/meetpie/src/test/kotlin/io/deck/meetpie/calendar/capability/ManageCalendarCapabilityTest.kt +++ b/backend/meetpie/src/test/kotlin/io/deck/meetpie/calendar/capability/ManageCalendarCapabilityTest.kt @@ -128,7 +128,7 @@ class ManageCalendarCapabilityTest : when (result) { is AxTypedResult.Success -> { val output = result.output as ManageCalendarOutput.NeedsConnection - output.connectUrl shouldBe "/calendar-integrations/" + output.connectUrl shouldBe "/console/calendar-integrations" capability.uiFor(output)?.resourceUri shouldBe "ui://deck/calendar-widget.html" } diff --git a/backend/meetpie/src/test/kotlin/io/deck/meetpie/mcp/ManageCalendarMcpScenarioTest.kt b/backend/meetpie/src/test/kotlin/io/deck/meetpie/mcp/ManageCalendarMcpScenarioTest.kt index 563a027c0..320f3b70e 100644 --- a/backend/meetpie/src/test/kotlin/io/deck/meetpie/mcp/ManageCalendarMcpScenarioTest.kt +++ b/backend/meetpie/src/test/kotlin/io/deck/meetpie/mcp/ManageCalendarMcpScenarioTest.kt @@ -187,7 +187,7 @@ class ManageCalendarMcpScenarioTest : val result = call(mapOf("action" to "list", "fromDate" to "2026-03-16", "toDate" to "2026-03-23")) result.isError shouldBe false - structured(result)["connectUrl"] shouldBe "/calendar-integrations/" + structured(result)["connectUrl"] shouldBe "/console/calendar-integrations" @Suppress("UNCHECKED_CAST") val outcome = structured(result)["_toolOutcome"] as Map diff --git a/backend/meetpie/src/test/kotlin/io/deck/meetpie/registry/ProgramRegistrarTest.kt b/backend/meetpie/src/test/kotlin/io/deck/meetpie/registry/ProgramRegistrarTest.kt index 6bb8a7917..ba19eec4c 100644 --- a/backend/meetpie/src/test/kotlin/io/deck/meetpie/registry/ProgramRegistrarTest.kt +++ b/backend/meetpie/src/test/kotlin/io/deck/meetpie/registry/ProgramRegistrarTest.kt @@ -1,6 +1,7 @@ package io.deck.meetpie.registry import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.shouldBe @@ -13,5 +14,11 @@ class ProgramRegistrarTest : programs.shouldNotBeEmpty() programs.all { it.workspace?.required == false } shouldBe true } + + it("calendar manage leaf도 독립 console program으로 등록해야 한다") { + val programs = ProgramRegistrar().programs() + + programs.map { it.path } shouldContain "/console/calendar-integrations/manage" + } } }) diff --git a/backend/notification/src/main/kotlin/io/deck/notification/registry/NotificationProgramRegistrar.kt b/backend/notification/src/main/kotlin/io/deck/notification/registry/NotificationProgramRegistrar.kt index 39228557a..c5e3b9567 100644 --- a/backend/notification/src/main/kotlin/io/deck/notification/registry/NotificationProgramRegistrar.kt +++ b/backend/notification/src/main/kotlin/io/deck/notification/registry/NotificationProgramRegistrar.kt @@ -10,22 +10,22 @@ class NotificationProgramRegistrar : ProgramRegistrar { listOf( ProgramDefinition( "NOTIFICATION_CHANNEL_MANAGEMENT", - "/system/notification-channels", + "/console/notification-channels", setOf("NOTIFICATION_MANAGEMENT_READ", "NOTIFICATION_MANAGEMENT_WRITE"), ), ProgramDefinition( "NOTIFICATION_RULE_MANAGEMENT", - "/system/notification-rules", + "/console/notification-rules", setOf("NOTIFICATION_MANAGEMENT_READ", "NOTIFICATION_MANAGEMENT_WRITE"), ), ProgramDefinition( "EMAIL_TEMPLATE_MANAGEMENT", - "/system/email-templates", + "/console/email-templates", setOf("EMAIL_TEMPLATE_MANAGEMENT_READ", "EMAIL_TEMPLATE_MANAGEMENT_WRITE"), ), ProgramDefinition( "SLACK_TEMPLATE_MANAGEMENT", - "/system/slack-templates", + "/console/slack-templates", setOf("SLACK_TEMPLATE_MANAGEMENT_READ", "SLACK_TEMPLATE_MANAGEMENT_WRITE"), ), ) diff --git a/docs/manual/assets/screenshots/app/account-info.png b/docs/manual/assets/screenshots/app/account-info.png index 2a1bf5d19..e3fa8558d 100644 Binary files a/docs/manual/assets/screenshots/app/account-info.png and b/docs/manual/assets/screenshots/app/account-info.png differ diff --git a/docs/manual/assets/screenshots/app/account-password.png b/docs/manual/assets/screenshots/app/account-password.png index a717a6e4f..515bddbc6 100644 Binary files a/docs/manual/assets/screenshots/app/account-password.png and b/docs/manual/assets/screenshots/app/account-password.png differ diff --git a/docs/manual/assets/screenshots/app/account-preferences.png b/docs/manual/assets/screenshots/app/account-preferences.png index c58af72ca..9b3377eaa 100644 Binary files a/docs/manual/assets/screenshots/app/account-preferences.png and b/docs/manual/assets/screenshots/app/account-preferences.png differ diff --git a/docs/manual/assets/screenshots/app/account-security.png b/docs/manual/assets/screenshots/app/account-security.png index 67ecbdae5..b0f2b3910 100644 Binary files a/docs/manual/assets/screenshots/app/account-security.png and b/docs/manual/assets/screenshots/app/account-security.png differ diff --git a/docs/manual/assets/screenshots/app/account-sessions.png b/docs/manual/assets/screenshots/app/account-sessions.png index c882e81c0..3f58a4cd2 100644 Binary files a/docs/manual/assets/screenshots/app/account-sessions.png and b/docs/manual/assets/screenshots/app/account-sessions.png differ diff --git a/docs/manual/assets/screenshots/app/console-activity-logs.png b/docs/manual/assets/screenshots/app/console-activity-logs.png new file mode 100644 index 000000000..07901801c Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-activity-logs.png differ diff --git a/docs/manual/assets/screenshots/app/console-audit-logs.png b/docs/manual/assets/screenshots/app/console-audit-logs.png new file mode 100644 index 000000000..51751500f Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-audit-logs.png differ diff --git a/docs/manual/assets/screenshots/app/console-email-templates.png b/docs/manual/assets/screenshots/app/console-email-templates.png new file mode 100644 index 000000000..2b90fdd97 Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-email-templates.png differ diff --git a/docs/manual/assets/screenshots/app/console-error-logs.png b/docs/manual/assets/screenshots/app/console-error-logs.png new file mode 100644 index 000000000..e4cc0dde6 Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-error-logs.png differ diff --git a/docs/manual/assets/screenshots/app/console-login-history.png b/docs/manual/assets/screenshots/app/console-login-history.png new file mode 100644 index 000000000..e95e7a1e5 Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-login-history.png differ diff --git a/docs/manual/assets/screenshots/app/console-my-workspace-external-readonly.png b/docs/manual/assets/screenshots/app/console-my-workspace-external-readonly.png new file mode 100644 index 000000000..9e0d8eec0 Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-my-workspace-external-readonly.png differ diff --git a/docs/manual/assets/screenshots/app/console-my-workspaces.png b/docs/manual/assets/screenshots/app/console-my-workspaces.png new file mode 100644 index 000000000..779e8ea7b Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-my-workspaces.png differ diff --git a/docs/manual/assets/screenshots/app/console-notification-channels.png b/docs/manual/assets/screenshots/app/console-notification-channels.png new file mode 100644 index 000000000..d0215948e Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-notification-channels.png differ diff --git a/docs/manual/assets/screenshots/app/console-notification-rules.png b/docs/manual/assets/screenshots/app/console-notification-rules.png new file mode 100644 index 000000000..480b83684 Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-notification-rules.png differ diff --git a/docs/manual/assets/screenshots/app/console-slack-templates.png b/docs/manual/assets/screenshots/app/console-slack-templates.png new file mode 100644 index 000000000..59850e3f4 Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-slack-templates.png differ diff --git a/docs/manual/assets/screenshots/app/console-users.png b/docs/manual/assets/screenshots/app/console-users.png new file mode 100644 index 000000000..e380ad97f Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-users.png differ diff --git a/docs/manual/assets/screenshots/app/console-workspaces.png b/docs/manual/assets/screenshots/app/console-workspaces.png new file mode 100644 index 000000000..7421c9ac7 Binary files /dev/null and b/docs/manual/assets/screenshots/app/console-workspaces.png differ diff --git a/docs/manual/assets/screenshots/app/dashboard.png b/docs/manual/assets/screenshots/app/dashboard.png new file mode 100644 index 000000000..918e4df4f Binary files /dev/null and b/docs/manual/assets/screenshots/app/dashboard.png differ diff --git a/docs/manual/assets/screenshots/app/login-error.png b/docs/manual/assets/screenshots/app/login-error.png index 1a3b8667b..9a64879e6 100644 Binary files a/docs/manual/assets/screenshots/app/login-error.png and b/docs/manual/assets/screenshots/app/login-error.png differ diff --git a/docs/manual/assets/screenshots/app/login-form.png b/docs/manual/assets/screenshots/app/login-form.png index a303b5683..9c8160fa8 100644 Binary files a/docs/manual/assets/screenshots/app/login-form.png and b/docs/manual/assets/screenshots/app/login-form.png differ diff --git a/docs/manual/assets/screenshots/app/platform-menus.png b/docs/manual/assets/screenshots/app/platform-menus.png new file mode 100644 index 000000000..1fd5dd23c Binary files /dev/null and b/docs/manual/assets/screenshots/app/platform-menus.png differ diff --git a/docs/manual/assets/screenshots/app/settings-admin-visibility.png b/docs/manual/assets/screenshots/app/settings-admin-visibility.png new file mode 100644 index 000000000..766e8f020 Binary files /dev/null and b/docs/manual/assets/screenshots/app/settings-admin-visibility.png differ diff --git a/docs/manual/assets/screenshots/app/settings-manager-platform-denied.png b/docs/manual/assets/screenshots/app/settings-manager-platform-denied.png new file mode 100644 index 000000000..a25e33fd0 Binary files /dev/null and b/docs/manual/assets/screenshots/app/settings-manager-platform-denied.png differ diff --git a/docs/manual/assets/screenshots/app/settings-manager-visibility.png b/docs/manual/assets/screenshots/app/settings-manager-visibility.png new file mode 100644 index 000000000..a63235529 Binary files /dev/null and b/docs/manual/assets/screenshots/app/settings-manager-visibility.png differ diff --git a/docs/manual/assets/screenshots/app/settings-user-platform-denied.png b/docs/manual/assets/screenshots/app/settings-user-platform-denied.png new file mode 100644 index 000000000..b6d28b47e Binary files /dev/null and b/docs/manual/assets/screenshots/app/settings-user-platform-denied.png differ diff --git a/docs/manual/assets/screenshots/app/workspace-invite-external-invalid.png b/docs/manual/assets/screenshots/app/workspace-invite-external-invalid.png new file mode 100644 index 000000000..445d21fc5 Binary files /dev/null and b/docs/manual/assets/screenshots/app/workspace-invite-external-invalid.png differ diff --git a/docs/manual/assets/screenshots/deskpie/companies-list.png b/docs/manual/assets/screenshots/deskpie/companies-list.png index f07cdbf96..527de5c19 100644 Binary files a/docs/manual/assets/screenshots/deskpie/companies-list.png and b/docs/manual/assets/screenshots/deskpie/companies-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/contacts-list.png b/docs/manual/assets/screenshots/deskpie/contacts-list.png index 70acb8408..c8535c177 100644 Binary files a/docs/manual/assets/screenshots/deskpie/contacts-list.png and b/docs/manual/assets/screenshots/deskpie/contacts-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/contracting-parties-list.png b/docs/manual/assets/screenshots/deskpie/contracting-parties-list.png index d9ea682d4..e9a38b5e4 100644 Binary files a/docs/manual/assets/screenshots/deskpie/contracting-parties-list.png and b/docs/manual/assets/screenshots/deskpie/contracting-parties-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/contracts-list.png b/docs/manual/assets/screenshots/deskpie/contracts-list.png index 737525189..44291e109 100644 Binary files a/docs/manual/assets/screenshots/deskpie/contracts-list.png and b/docs/manual/assets/screenshots/deskpie/contracts-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/deals-list.png b/docs/manual/assets/screenshots/deskpie/deals-list.png index 4d2ba521a..6229640b3 100644 Binary files a/docs/manual/assets/screenshots/deskpie/deals-list.png and b/docs/manual/assets/screenshots/deskpie/deals-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/leads-list.png b/docs/manual/assets/screenshots/deskpie/leads-list.png index c7b5c07aa..1b7ba680c 100644 Binary files a/docs/manual/assets/screenshots/deskpie/leads-list.png and b/docs/manual/assets/screenshots/deskpie/leads-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/license-requests-list.png b/docs/manual/assets/screenshots/deskpie/license-requests-list.png index 2821e62f6..a741605ac 100644 Binary files a/docs/manual/assets/screenshots/deskpie/license-requests-list.png and b/docs/manual/assets/screenshots/deskpie/license-requests-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/licenses-edit.png b/docs/manual/assets/screenshots/deskpie/licenses-edit.png index 7b1e866bf..147bde5d1 100644 Binary files a/docs/manual/assets/screenshots/deskpie/licenses-edit.png and b/docs/manual/assets/screenshots/deskpie/licenses-edit.png differ diff --git a/docs/manual/assets/screenshots/deskpie/licenses-list.png b/docs/manual/assets/screenshots/deskpie/licenses-list.png index 7b1e866bf..147bde5d1 100644 Binary files a/docs/manual/assets/screenshots/deskpie/licenses-list.png and b/docs/manual/assets/screenshots/deskpie/licenses-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/login-form.png b/docs/manual/assets/screenshots/deskpie/login-form.png index 420839d2a..9c8160fa8 100644 Binary files a/docs/manual/assets/screenshots/deskpie/login-form.png and b/docs/manual/assets/screenshots/deskpie/login-form.png differ diff --git a/docs/manual/assets/screenshots/deskpie/login-success.png b/docs/manual/assets/screenshots/deskpie/login-success.png index 2bf73d95e..a903e7460 100644 Binary files a/docs/manual/assets/screenshots/deskpie/login-success.png and b/docs/manual/assets/screenshots/deskpie/login-success.png differ diff --git a/docs/manual/assets/screenshots/deskpie/pipelines-list.png b/docs/manual/assets/screenshots/deskpie/pipelines-list.png index 9cd6fd1f6..d2e78e3fb 100644 Binary files a/docs/manual/assets/screenshots/deskpie/pipelines-list.png and b/docs/manual/assets/screenshots/deskpie/pipelines-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/products-list.png b/docs/manual/assets/screenshots/deskpie/products-list.png index 88d9a85c1..a383265e4 100644 Binary files a/docs/manual/assets/screenshots/deskpie/products-list.png and b/docs/manual/assets/screenshots/deskpie/products-list.png differ diff --git a/docs/manual/assets/screenshots/deskpie/quotes-list.png b/docs/manual/assets/screenshots/deskpie/quotes-list.png index db2df63f3..e42d29825 100644 Binary files a/docs/manual/assets/screenshots/deskpie/quotes-list.png and b/docs/manual/assets/screenshots/deskpie/quotes-list.png differ diff --git a/docs/manual/assets/screenshots/meetpie/booking-bookings.png b/docs/manual/assets/screenshots/meetpie/booking-bookings.png index e414c8197..3014f0b1a 100644 Binary files a/docs/manual/assets/screenshots/meetpie/booking-bookings.png and b/docs/manual/assets/screenshots/meetpie/booking-bookings.png differ diff --git a/docs/manual/assets/screenshots/meetpie/booking-dashboard.png b/docs/manual/assets/screenshots/meetpie/booking-dashboard.png index 9f395fbc1..7371194ad 100644 Binary files a/docs/manual/assets/screenshots/meetpie/booking-dashboard.png and b/docs/manual/assets/screenshots/meetpie/booking-dashboard.png differ diff --git a/docs/manual/assets/screenshots/meetpie/booking-event-types.png b/docs/manual/assets/screenshots/meetpie/booking-event-types.png index 8f381b35d..da2a19a26 100644 Binary files a/docs/manual/assets/screenshots/meetpie/booking-event-types.png and b/docs/manual/assets/screenshots/meetpie/booking-event-types.png differ diff --git a/docs/manual/assets/screenshots/meetpie/booking-profile.png b/docs/manual/assets/screenshots/meetpie/booking-profile.png index c81bf5631..8ee109b9b 100644 Binary files a/docs/manual/assets/screenshots/meetpie/booking-profile.png and b/docs/manual/assets/screenshots/meetpie/booking-profile.png differ diff --git a/docs/manual/assets/screenshots/meetpie/booking-public-confirmed.png b/docs/manual/assets/screenshots/meetpie/booking-public-confirmed.png index ec7493b20..2f2ccd3d8 100644 Binary files a/docs/manual/assets/screenshots/meetpie/booking-public-confirmed.png and b/docs/manual/assets/screenshots/meetpie/booking-public-confirmed.png differ diff --git a/docs/manual/assets/screenshots/meetpie/booking-public-form.png b/docs/manual/assets/screenshots/meetpie/booking-public-form.png index cb6dd4103..b71abdc5a 100644 Binary files a/docs/manual/assets/screenshots/meetpie/booking-public-form.png and b/docs/manual/assets/screenshots/meetpie/booking-public-form.png differ diff --git a/docs/manual/assets/screenshots/meetpie/booking-public-listing.png b/docs/manual/assets/screenshots/meetpie/booking-public-listing.png index 01ba71496..deb7ac3e2 100644 Binary files a/docs/manual/assets/screenshots/meetpie/booking-public-listing.png and b/docs/manual/assets/screenshots/meetpie/booking-public-listing.png differ diff --git a/docs/manual/assets/screenshots/meetpie/booking-public-slots.png b/docs/manual/assets/screenshots/meetpie/booking-public-slots.png index e36a307d1..4394fcead 100644 Binary files a/docs/manual/assets/screenshots/meetpie/booking-public-slots.png and b/docs/manual/assets/screenshots/meetpie/booking-public-slots.png differ diff --git a/docs/manual/assets/screenshots/meetpie/booking-schedules.png b/docs/manual/assets/screenshots/meetpie/booking-schedules.png index 9ee8c3e97..7334347b8 100644 Binary files a/docs/manual/assets/screenshots/meetpie/booking-schedules.png and b/docs/manual/assets/screenshots/meetpie/booking-schedules.png differ diff --git a/docs/manual/assets/screenshots/meetpie/calendar-caldav-form.png b/docs/manual/assets/screenshots/meetpie/calendar-caldav-form.png index 8eb14c5ee..368fa9e31 100644 Binary files a/docs/manual/assets/screenshots/meetpie/calendar-caldav-form.png and b/docs/manual/assets/screenshots/meetpie/calendar-caldav-form.png differ diff --git a/docs/manual/assets/screenshots/meetpie/calendar-holidays.png b/docs/manual/assets/screenshots/meetpie/calendar-holidays.png index 4f9062191..7963fbaa4 100644 Binary files a/docs/manual/assets/screenshots/meetpie/calendar-holidays.png and b/docs/manual/assets/screenshots/meetpie/calendar-holidays.png differ diff --git a/docs/manual/assets/screenshots/meetpie/calendar-manage.png b/docs/manual/assets/screenshots/meetpie/calendar-manage.png index a8cd9baa1..7189c19e4 100644 Binary files a/docs/manual/assets/screenshots/meetpie/calendar-manage.png and b/docs/manual/assets/screenshots/meetpie/calendar-manage.png differ diff --git a/docs/manual/assets/screenshots/meetpie/calendar-settings.png b/docs/manual/assets/screenshots/meetpie/calendar-settings.png index 8eb14c5ee..e036a66af 100644 Binary files a/docs/manual/assets/screenshots/meetpie/calendar-settings.png and b/docs/manual/assets/screenshots/meetpie/calendar-settings.png differ diff --git a/docs/manual/assets/screenshots/meetpie/contacts-edit.png b/docs/manual/assets/screenshots/meetpie/contacts-edit.png index 32fe8f7d8..d3023a4ea 100644 Binary files a/docs/manual/assets/screenshots/meetpie/contacts-edit.png and b/docs/manual/assets/screenshots/meetpie/contacts-edit.png differ diff --git a/docs/manual/assets/screenshots/meetpie/contacts-list.png b/docs/manual/assets/screenshots/meetpie/contacts-list.png index 32fe8f7d8..786bc3d96 100644 Binary files a/docs/manual/assets/screenshots/meetpie/contacts-list.png and b/docs/manual/assets/screenshots/meetpie/contacts-list.png differ diff --git a/docs/manual/assets/screenshots/meetpie/login-form.png b/docs/manual/assets/screenshots/meetpie/login-form.png index f22239fe1..9c8160fa8 100644 Binary files a/docs/manual/assets/screenshots/meetpie/login-form.png and b/docs/manual/assets/screenshots/meetpie/login-form.png differ diff --git a/docs/manual/assets/screenshots/meetpie/login-success.png b/docs/manual/assets/screenshots/meetpie/login-success.png index 7a3e5fcc6..cb1443a0a 100644 Binary files a/docs/manual/assets/screenshots/meetpie/login-success.png and b/docs/manual/assets/screenshots/meetpie/login-success.png differ diff --git a/docs/manual/assets/screenshots/meetpie/namecard-editor.png b/docs/manual/assets/screenshots/meetpie/namecard-editor.png index 83c2325b6..5ae4ecb5e 100644 Binary files a/docs/manual/assets/screenshots/meetpie/namecard-editor.png and b/docs/manual/assets/screenshots/meetpie/namecard-editor.png differ diff --git a/docs/manual/assets/screenshots/meetpie/namecard-public.png b/docs/manual/assets/screenshots/meetpie/namecard-public.png index eb49c6e5a..8b6929cfd 100644 Binary files a/docs/manual/assets/screenshots/meetpie/namecard-public.png and b/docs/manual/assets/screenshots/meetpie/namecard-public.png differ diff --git a/docs/manual/assets/screenshots/meetpie/namecard-share.png b/docs/manual/assets/screenshots/meetpie/namecard-share.png index 920cda0b6..2686004a6 100644 Binary files a/docs/manual/assets/screenshots/meetpie/namecard-share.png and b/docs/manual/assets/screenshots/meetpie/namecard-share.png differ diff --git a/docs/manual/assets/screenshots/meetpie/widget-preview.png b/docs/manual/assets/screenshots/meetpie/widget-preview.png index 12dbc679d..5e066669d 100644 Binary files a/docs/manual/assets/screenshots/meetpie/widget-preview.png and b/docs/manual/assets/screenshots/meetpie/widget-preview.png differ diff --git a/docs/plans/2026-03-23-workspace-detail-route.md b/docs/plans/2026-03-23-workspace-detail-route.md index 6877f2569..a7f02f331 100644 --- a/docs/plans/2026-03-23-workspace-detail-route.md +++ b/docs/plans/2026-03-23-workspace-detail-route.md @@ -1,5 +1,8 @@ # Workspace Detail Route Implementation Plan +> Superseded by `2026-04-03-platform-reset-and-workspace-scope-plan.md`. +> 이 문서는 `/system/*`, `/my-workspaces/*`, `/dashboard` legacy route 정리 당시의 기록이며, 현재 canonical path SSOT는 `/console/*`, `/settings/*` 기준이다. + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** `Workspace`와 `My Workspace` 상세 화면을 실제 URL(`/system/workspaces/:workspaceId`, `/my-workspaces/:workspaceId`)로 진입·복원할 수 있게 만들고, app shell 바깥 레이어가 dynamic path를 공통 route descriptor 기준으로 단순하게 해석하도록 정리한다. @@ -446,4 +449,3 @@ PR body: Expected: - PASS - diff --git a/docs/plans/2026-04-03-platform-reset-and-workspace-scope-plan.md b/docs/plans/2026-04-03-platform-reset-and-workspace-scope-plan.md new file mode 100644 index 000000000..174d523fa --- /dev/null +++ b/docs/plans/2026-04-03-platform-reset-and-workspace-scope-plan.md @@ -0,0 +1,623 @@ +--- +status: active +author: "@kelly" +created: 2026-04-03 +completed: +--- + +# Platform Reset And Workspace Scope Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `System`과 `Organization` 중심 흔적을 제거하고, `Platform / Workspace / Account` 용어와 `/console/*`, `/settings/*` 라우팅, service별 `workspace_id` 확장 포인트를 기준으로 app shell을 재정렬한다. + +**Architecture:** authenticated shell은 `Console`과 `Settings` 두 축으로 정리하고, `Workspace`는 공통 settings scope가 아니라 service별 optional context로 유지한다. 외부 조직 연동은 `Workspace.externalReference(source, externalId)`와 AIP 로그인 sync로 수렴시키고, external workspace는 `PLATFORM_MANAGED`로 잠가 로컬 수정 경계를 명확히 한다. + +**Tech Stack:** React 19, TypeScript, Vite SPA, Kotlin, Spring Boot, JPA, PostgreSQL, Flyway, Vitest, Playwright + +--- + +## Context + +- 기존 app shell은 `/system/*`, `/settings/system/*`, `SystemSetting`, `WorkspaceManagedType.SYSTEM_MANAGED` 같은 레거시 용어에 강하게 묶여 있다. +- 새 방향에서는 `Organization`을 도입하지 않고, `Platform / Workspace / Account`만 유지한다. +- `Workspace`는 공통 tenant route가 아니라 service별 optional scope다. `deskpie`는 `workspace_id`가 필요할 수 있지만 `meetpie`는 없어도 된다. +- `Roles`, `Menus`는 운영 콘솔이 아니라 `Platform Settings`의 전역 구성으로 본다. +- AIP 로그인 시 JWT의 외부 조직 목록을 기준으로 `Workspace`를 자동 생성 또는 재사용하고 membership을 자동 연결해야 한다. + +## Progress Notes + +- 2026-04-03: Chunk 1.1, 1.2, 2.1, 3.1, 4.1, 5.1은 red→green까지 완료했다. commit/PR/CI 전이라 Step 5는 계속 미체크로 둔다. +- 2026-04-03: `Platform Settings` IA를 `Account / Platform` 두 그룹으로 재편했고 `Menus` leaf를 settings shell 안으로 옮겼다. `/account/setting/*` legacy 진입과 command palette recent도 `/settings/*` canonical path로 수렴시켰다. +- 2026-04-03: `system_settings -> platform_settings`, `systemStatus -> platformStatus`, `SYSTEM_SETTINGS_* -> PLATFORM_SETTINGS_*` rename을 backend/frontend/DB/V1까지 반영했고 관련 targeted 회귀를 green으로 확인했다. +- 2026-04-03: menus/settings selector drift와 `useMenuForm` dependency loop를 정리한 뒤 menus 관련 targeted Vitest를 다시 green으로 통과시켰다. +- 2026-04-03: branch 전용 `5011` dev DB의 stale `V1__init.sql` checksum mismatch 때문에 live `/settings/platform/menus`가 500이었고, DB를 fresh로 재기동하고 `8011` backend를 현재 worktree 기준으로 다시 올려 `/api/v1/roles`, `/api/v1/accounts/me`, `/settings/platform/menus`를 200/정상 렌더로 복구했다. +- 2026-04-03: headless Playwright와 브라우저 캡처로 `/settings/platform/menus` canonical path와 legacy `/console/menus` redirect가 모두 `/settings/platform/menus`로 수렴하는 것을 직접 확인했다. +- 2026-04-03: runtime menu visibility는 admin에서 `Platform > Menus`가 보이고 일반 사용자 runtime sidebar에서는 platform-managed menu가 숨겨지는 것까지 브라우저/Playwright로 다시 확인했다. +- 2026-04-03: external workspace lock 회귀는 backend service/controller와 frontend read-only UI뿐 아니라 Playwright `workspace-detail-route.spec.ts`, `WorkspaceMemberServiceTest`까지 green으로 확인했다. external my workspace detail read-only smoke와 external self-withdraw 차단 회귀는 모두 PASS 상태다. +- 2026-04-03: AIP sync는 `AuthServiceTest`, `OAuth2LinkingTest`, `UserServiceTest`, `OAuth2MobileFlowIntegrationTest` 기준으로 external org claim 전달, workspace auto-provision/reuse, mobile OAuth callback 경로까지 green으로 확인했다. 비활성 AIP 사용자가 login failure 전에 sync side-effect를 일으키지 않도록 `AuthService` guard도 반영되어 있다. +- 2026-04-03: external workspace invite token이 validate에서 살아 보이던 경계를 `WorkspaceInviteServiceTest` red→green으로 정리했다. 이제 external invite는 validate 단계부터 `valid=false`로 내려간다. +- 2026-04-03: `/my-workspaces/*` legacy route는 `/console/my-workspaces/*` canonical path와 legacy redirect로 수렴시켰고, 관련 Vitest 75개와 Playwright `workspace-detail-route.spec.ts`를 green으로 확인했다. 현재 남은 문서 정리는 `docs/reference` 전체 legacy grep cleanup 쪽이다. +- 2026-04-03: Chunk 7 system Playwright 회귀는 `mockAppBootstrapApis` 누락과 create user 전용 `contact-field-config` mock pattern drift를 정리한 뒤 `users/logs/templates/notification-management/notification-channels-refresh` 묶음이 `28 passed`로 green 복구됐다. +- 2026-04-03: auth redirect 계열은 legacy `next` 입력을 canonical `/console/*`, `/settings/*`로 정규화하도록 `auth-redirect` helper를 보정했고, 관련 targeted Vitest 6 files / 54 tests와 frontend 전체 `pnpm vitest run` 318 files / 2401 tests를 다시 green으로 확인했다. +- 2026-04-03: live `/api/v1/my-workspaces` 500은 코드나 DB가 아니라 stale `8011` backend process 문제였다. 같은 worktree/current build를 `8012`에 띄우면 즉시 `200`이었고, `8011`을 current build로 재기동한 뒤 proxy `/api/v1/my-workspaces`와 로그인 후 `/console/dashboard/` shell 렌더까지 정상화했다. +- 2026-04-03: backend 전체 `./gradlew test`, frontend `pnpm build`, backend `./gradlew ktlintCheck`까지 모두 green이다. 남은 건 manual smoke 재확인과 commit/push/PR/CI다. +- 2026-04-03: 마지막 backend reviewer findings 3개를 정리했다. AIP empty snapshot도 authoritative sync로 처리하고, external sync는 `ExternalWorkspaceSyncResult`로 membership delta를 명시적으로 반환하며, invite validation은 missing/external workspace를 accept 전에 invalid로 확정한다. 이후 targeted backend/frontend 회귀를 다시 green으로 확인했다. +- 2026-04-03: app shell query key는 `workspace_id`를 canonical로 고정했고, runtime helper는 deep link 호환을 위해 legacy `workspaceId`도 fallback으로 읽도록 정리했다. route canonicalization과 workspace context resolution 책임은 분리했고, 관련 Vitest(`scope`, `route-descriptor`, `tabs`, `Sidebar`) 63개는 green이다. service API wire key는 각 서비스 계약이 계속 소유한다. +- 2026-04-03: 최신 `origin/develop`를 다시 fetch해 비교했을 때 현재 브랜치는 `ahead 2 / behind 0` 상태다. rebase로 끌어와야 할 신규 upstream 변경은 없었다. +- 2026-04-03: 마지막 backend/frontend/architecture 서브에이전트 재리뷰는 모두 `findings 없음`으로 종료했다. 이후 targeted backend/frontend 회귀도 다시 green으로 확인했다. +- 2026-04-03: unreleased 기준으로 invite provenance/concurrency 스키마는 별도 migration으로 빼지 않고 `V1__init.sql`에 다시 접었다. `AppMigrationMenuPermissionsTest`에 `workspace_invites.inviter_id`, `version`, pending unique index assertion을 추가했고 green 확인했다. +- 2026-04-03: 최신 develop에서 `ApiAuditLogEntity.createdAt` 타입이 이미 `Instant`로 바뀐 상태였는데 audit service에 예전 `toInstant(...)` 호출이 남아 있었다. 타입 계약에 맞춰 제거했고, 같은 `:app:test --tests 'io.deck.app.migration.AppMigrationMenuPermissionsTest'` 실행으로 audit compile까지 함께 green 확인했다. +- 2026-04-04: backend reviewer finding을 반영해 external workspace upsert/membership sync와 pending invite 생성의 race recovery를 예외 기반 재조회 대신 `WorkspaceMutationJdbcRepository`의 JDBC upsert/insert-if-absent 경계로 정리했다. 이후 `WorkspaceServiceTest`, `ExternalWorkspaceSyncServiceTest`, `WorkspaceInviteServiceTest`, `AuthServiceTest`, `UserServiceTest`, `OAuth2LinkingTest`, `ProgramRegistryTest`, `ProgramPathsTest`를 다시 green으로 확인했다. +- 2026-04-04: frontend/router SoT 재검증 결과 service canonical path는 계속 `/console/{menu_route}`이며 `/console/deskpie/*`, `/console/meetpie/*`는 금지 검증만 남아 있다. `console-path`, `page-registry`, `settings.page` targeted Vitest 42 tests를 다시 green으로 확인했다. +- 2026-04-04: `scripts/dev -p 11` 기준 app live 재검증 중 meetpie→app 전환 시 Flyway history drift가 재현됐다. `scripts/dev down -p 11 -v` 후 fresh DB로 `app`을 다시 올렸고, `settings-visibility` + `system(my-workspaces/external invite invalid)` manual Playwright 8 tests를 다시 green으로 확인했다. +- 2026-04-04: architecture/doc reviewer가 `flyway.md`, platform reset plan, legacy workspace-detail route plan의 stale 문구 3건을 잡았다. 현재 문서 SSOT를 최신 code/reference 계약과 다시 맞추는 중이며, 서브에이전트 최종 재리뷰는 아직 진행 중이다. + +## Target Contract + +- authenticated route + - `/console/*` + - `/settings/*` +- public route + - `/invite/*` + - `/workspace-invite/*` +- settings 그룹 + - `Account` + - `Platform` +- platform settings leaf + - `General` + - `Branding` + - `Authentication` + - `Workspace Policy` + - `Roles` + - `Menus` +- `Workspace`는 settings 그룹으로 승격하지 않는다. +- shared router는 pathname canonicalization만 담당한다. +- app shell access/sidebar/tabs는 FE workspace context resolver로 `workspace_id`를 읽는다. +- service page/API는 같은 workspace context 우선순위 개념을 각 레이어에서 구현하되, 실제 wire query key는 서비스가 소유한다. +- 공용 `ManagementType` + - `USER_MANAGED` + - `PLATFORM_MANAGED` +- `PLATFORM_MANAGED` menu는 runtime에서 `Platform Admin`만 보고, 메뉴 관리 화면에서는 조회/수정 가능하다. +- `Workspace.externalReference.source`는 현재 `AIP` 하나만 사용한다. +- `Workspace.externalReference.externalId`는 source 시스템의 실제 조직 ID다. 현재는 AIP 조직 ID다. +- `externalReference != null`인 workspace는 external workspace이며 반드시 `PLATFORM_MANAGED`다. +- external workspace의 identity/membership/invite는 Deck UI와 service 경계에서 직접 수정하지 않는다. +- `Workspace.autoJoinDomains`는 empty list면 off로 해석한다. + +## File Map + +| # | 파일 | 변경 | +|---|------|------| +| 1 | `backend/app/src/main/resources/db/migration/app/V1__init.sql` | unreleased 기준으로 workspace/platform/menu/invite 스키마 최종본을 V1에 직접 반영한다 | +| 2 | `backend/app/src/main/resources/db/migration/ci-seed/V9999__ci-seed.sql` | CI seed를 새 workspace shape에 맞게 갱신 | +| 3 | `backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt` | platform settings aggregate 정리 | +| 4 | `backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt` | platform settings service와 workspace policy rename 반영 | +| 5 | `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt` | platform settings API | +| 6 | `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderController.kt` | platform authentication settings API | +| 7 | `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt` | `useSystemManaged -> usePlatformManaged`, settings payload 조정 | +| 8 | `backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspacePolicy.kt` | `usePlatformManaged` 기준으로 정책 계약 변경 | +| 9 | `backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceManagedType.kt` | 공용 `ManagementType`로 치환 | +| 10 | `backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceManagedType.kt` | 공용 `ManagementType` API로 치환 | +| 11 | `backend/iam/src/main/kotlin/io/deck/iam/api/ProgramDefinition.kt` | workspace capability managed type 계약 갱신 | +| 12 | `backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceDirectory.kt` | 새 `ManagementType`, external fields를 포함한 조회 계약 반영 | +| 13 | `backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceRecord.kt` | `allowedDomains -> autoJoinDomains`, `externalReference` 추가 | +| 14 | `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceDirectoryImpl.kt` | WorkspaceRecord projection 갱신 | +| 15 | `backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt` | `external_id` 조회, unique lookup, `auto_join_domains` 조회 쿼리 추가 | +| 16 | `backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt` | `autoJoinDomains`, `externalReference`, `PLATFORM_MANAGED` 규칙 반영 | +| 17 | `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt` | workspace identity 변경 규칙과 policy mapping 반영 | +| 18 | `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceMemberService.kt` | external workspace membership mutation 차단 | +| 19 | `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInviteService.kt` | external workspace invite/create/accept 차단 | +| 20 | `backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt` | `allowedDomains` 기반 auto-join을 `autoJoinDomains`와 OAuth user upsert 규칙으로 대체 | +| 20a | `backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt` | authoritative external workspace sync와 membership reconciliation | +| 21 | `backend/iam/src/main/kotlin/io/deck/iam/service/AuthService.kt` | OAuth 로그인 후 external workspace sync 진입점과 연결 | +| 22 | `backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2AuthenticationSuccessHandler.kt` | 실제 AIP 로그인 콜백 경로에서 AuthService sync trigger | +| 23 | `backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt` | `/console/*`, `/settings/platform/*` program path로 갱신 | +| 24 | `backend/notification/src/main/kotlin/io/deck/notification/registry/NotificationProgramRegistrar.kt` | notification leaf path를 `/console/*`로 갱신 | +| 25 | `backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceController.kt` | admin workspace CRUD에 external lock 반영 | +| 26 | `backend/app/src/main/kotlin/io/deck/app/controller/MyWorkspaceController.kt` | my-workspace 수정/탈퇴 흐름에 external lock 반영 | +| 27 | `backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceInviteAcceptController.kt` | public invite accept 경로에서 external lock 반영 | +| 28 | `backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceDtos.kt` | `autoJoinDomains`, `externalReference`, `PLATFORM_MANAGED` DTO 반영 | +| 29 | `backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceControllerTest.kt` | admin workspace external lock red/green | +| 30 | `backend/app/src/test/kotlin/io/deck/app/controller/MyWorkspaceControllerTest.kt` | my-workspace external lock red/green | +| 31 | `backend/app/src/test/kotlin/io/deck/app/oauth2/OAuth2MobileFlowIntegrationTest.kt` | mobile/JWT flow regression은 유지, AIP sync는 보조 검증으로만 사용 | +| 32 | `backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt` | workspace rename/update rule red/green | +| 33 | `backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceMemberServiceTest.kt` | member add/remove/owner-transfer/leave lock red/green | +| 34 | `backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceInviteServiceTest.kt` | invite create/cancel/accept lock red/green | +| 35 | `backend/iam/src/test/kotlin/io/deck/iam/service/AuthServiceTest.kt` | OAuth auto-provision/service 연동 red/green | +| 36 | `backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2LinkingTest.kt` | AIP success handler/login linking regression 확장 | +| 37 | `frontend/app/src/app/page-registry.ts` | `/console/*` app-shell route registry로 전환 | +| 38 | `frontend/app/src/shared/router/route-descriptor.ts` | `/console/workspaces/:id` canonical path 판별 규칙 갱신 | +| 39 | `frontend/app/src/app/App.tsx` | `/settings/*` standalone settings shell 유지, leaf routing과 redirect 갱신 | +| 40 | `frontend/app/src/pages/settings/settings-nav.ts` | `Account`, `Platform`만 남기고 `Menus` leaf 추가 | +| 41 | `frontend/app/src/pages/settings/settings.page.tsx` | `Platform` leaf switch 재구성, `Workspace`/`Globalization` 제거 | +| 42 | `frontend/app/src/pages/settings/tabs/menus-tab.tsx` | settings shell 안에서 menu management를 렌더링하는 wrapper leaf 추가 | +| 43 | `frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx` | settings path/entry rename 회귀 테스트 | +| 44 | `frontend/app/src/features/command-palette/ui/CommandPalette.test.tsx` | settings/platform leaf 검색 회귀 테스트 | +| 45 | `frontend/app/src/app/tabs.ts` | `/console/workspaces/:id` 탭 복원과 canonical path 갱신 | +| 46 | `frontend/app/src/app/header/NotificationBell.tsx` | console deep link를 `/console/*`로 생성 | +| 47 | `frontend/app/src/features/logs/ui/log-detail-modal-body.tsx` | 연관 로그 deep link를 `/console/*`로 생성 | +| 48 | `frontend/app/src/pages/system/workspaces/workspaces.page.tsx` | list -> detail navigation을 `/console/workspaces/*`로 전환 | +| 49 | `frontend/app/src/pages/system/workspaces/workspace-detail.page.tsx` | back/detail canonical path를 `/console/workspaces/*`로 전환 | +| 50 | `frontend/app/src/pages/system/workspaces/workspace-detail.page.test.tsx` | detail/back red/green | +| 51 | `frontend/app/src/pages/system/workspaces/workspaces.page.test.tsx` | list navigation red/green | +| 52 | `frontend/app/src/entities/platform-settings/types.ts` | `usePlatformManaged`, `ManagementType`, platform settings contract 반영 | +| 53 | `frontend/app/src/entities/platform-settings/api.ts` | platform settings API payload rename | +| 54 | `frontend/app/src/entities/platform-settings/store.ts` | platform settings store rename 반영 | +| 55 | `frontend/app/src/entities/platform-settings/workspace-access.ts` | `PLATFORM_MANAGED` policy mapping 반영 | +| 56 | `frontend/app/src/entities/workspace/types.ts` | `autoJoinDomains`, `externalReference`, `PLATFORM_MANAGED` 반영 | +| 57 | `frontend/app/src/entities/workspace/visibility.ts` | `usePlatformManaged` 기준 selector visibility 갱신 | +| 58 | `frontend/app/src/entities/menu/types.ts` | menu `managementType` 공용화 | +| 59 | `frontend/app/src/features/menus/manage-menus/model/types.ts` | menu form/runtime에서 `PLATFORM_MANAGED` 지원 | +| 60 | `frontend/app/src/widgets/sidebar/Sidebar.tsx` | runtime menu visibility를 `PLATFORM_MANAGED` + Platform Admin 기준으로 필터링 | +| 61 | `frontend/app/src/widgets/sidebar/Sidebar.test.tsx` | platform-managed runtime visibility red/green | +| 62 | `frontend/tests/helpers/menu-smoke.ts` | 새 managementType/runtime visibility 반영 | +| 63 | `frontend/tests/system/workspace-detail-route.spec.ts` | `/console/workspaces/*` detail flow regression | +| 64 | `frontend/tests/system/menu-runtime-smoke.spec.ts` | `PLATFORM_MANAGED` runtime visibility regression | +| 65 | `frontend/tests/system/standalone-menu-smoke.spec.ts` | menu management 화면 row visibility regression | +| 66 | `frontend/tests/system/users.spec.ts` | `/console/users` 전환 | +| 67 | `frontend/tests/system/logs.spec.ts` | `/console/logs/*` 전환 | +| 68 | `frontend/tests/system/templates.spec.ts` | `/console/*` template route 전환 | +| 69 | `frontend/tests/system/notification-management.spec.ts` | `/console/*` notification route 전환 | +| 70 | `frontend/tests/system/menus.spec.ts` | 메뉴 관리 deep link와 active path 회귀 | +| 71 | `frontend/tests/system/menus-icon-picker.spec.ts` | menus path/icon picker 회귀 | +| 72 | `frontend/tests/system/sidebar.spec.ts` | sidebar path와 program visibility 회귀 | +| 73 | `docs/reference/workspace.md` | `SYSTEM_MANAGED`, `allowedDomains`, `/system/workspaces` 예시를 새 workspace contract로 갱신 | +| 74 | `docs/reference/frontend/router.md` | `/system/*` 예시와 canonical path 설명을 `/console/*`, `/settings/*`로 갱신 | +| 75 | `docs/reference/frontend/features.md` | 골든 레퍼런스 경로를 `pages/system/*`에서 새 shell 기준으로 갱신 | +| 76 | `docs/reference/frontend/tabulator.md` | grid 예시 경로를 `/console/users` 기준으로 갱신 | +| 77 | `docs/reference/frontend/rules.md` | ownership matrix와 page path 예시를 새 platform route 기준으로 갱신 | +| 78 | `docs/reference/backend/oauth-setup.md` | `System Settings -> Authentication` 문구와 AIP 설정 위치를 `Platform Settings` 기준으로 갱신 | +| 79 | `docs/reference/legal-pages.md` | `System Settings` 연동 문구를 `Platform Settings`로 갱신 | +| 80 | `docs/reference/infra/e2e-testing.md` | `workspacePolicy.usePlatformManaged`, 새 route smoke 기준으로 갱신 | +| 81 | `docs/reference/meetpie.md` | `workspace_id`가 service별 optional scope라는 설명을 현재 결정에 맞게 보강 | + +## Decisions Locked Before Implementation + +1. `/settings/*`는 `frontend/app/src/app/App.tsx`의 standalone route로 유지한다. page registry는 `console` app-shell만 책임하고, settings leaf access는 `pages/settings` 전용 parser/hook이 담당한다. +2. `Menus`는 `Platform Settings`에 포함한다. 기존 `pages/system/menus/menus.page.tsx`를 제거하지 않고, settings leaf wrapper에서 재사용한다. +3. `allowedDomains`는 이번 slice에서 **`autoJoinDomains`로 hard rename**한다. compatibility alias는 두지 않는다. +4. `useSystemManaged`는 **`usePlatformManaged`로 hard rename**한다. external workspace는 `PLATFORM_MANAGED`로 해석하고 selector/policy 필터를 그대로 탄다. 아직 릴리즈 전이므로 필요한 schema 정리는 `V1__init.sql`에 직접 합친다. +5. external workspace lock은 엔티티가 아니라 service/use-case 경계에서 강제한다. +6. AIP sync는 `OAuth2AuthenticationSuccessHandler -> AuthService` 실제 로그인 플로우에서 시작한다. `UserService`는 platform policy와 approval event를 조합하고, 실제 external reconciliation은 `ExternalWorkspaceSyncService`가 담당한다. + +## Chunk 1: Console Route Reset + +### Task 1.1: page registry와 route descriptor를 `/console/*` app-shell 전용으로 재정의 + +**Files:** +- Modify: `frontend/app/src/app/page-registry.ts` +- Modify: `frontend/app/src/shared/router/route-descriptor.ts` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt` +- Modify: `backend/notification/src/main/kotlin/io/deck/notification/registry/NotificationProgramRegistrar.kt` +- Modify: `frontend/app/src/app/page-registry.test.ts` +- Modify: `frontend/app/src/shared/router/route-descriptor.test.ts` +- Modify: `frontend/app/src/app/page-registry-routes.test.ts` + +- [x] **Step 1: 실패 테스트 추가** + - `/system/users/`, `/system/workspaces/`가 더 이상 app-shell canonical path가 아님을 검증한다. + - `/console/users/`, `/console/workspaces/`, `/console/workspaces/:id`만 page registry 대상으로 남는 테스트를 추가한다. +- [x] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm vitest run src/app/page-registry.test.ts src/shared/router/route-descriptor.test.ts src/app/page-registry-routes.test.ts` + - Expected: legacy `/system/*` expectation FAIL +- [x] **Step 3: 최소 구현** + - page registry는 dashboard, console leaf, workspace detail만 관리한다. + - route descriptor는 `/console/workspaces/:id`를 canonical `/console/workspaces/`로 수렴시킨다. + - backend program registrars가 `/console/*`, `/settings/platform/*` path를 내려주도록 맞춘다. +- [x] **Step 4: green 확인** + - 위 명령 재실행 +- [ ] **Step 5: 커밋** + - `git add frontend/app/src/app/page-registry.ts frontend/app/src/shared/router/route-descriptor.ts backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt backend/notification/src/main/kotlin/io/deck/notification/registry/NotificationProgramRegistrar.kt frontend/app/src/app/page-registry.test.ts frontend/app/src/shared/router/route-descriptor.test.ts frontend/app/src/app/page-registry-routes.test.ts` + - `git commit -m "refactor: move app shell routes under console"` + +### Task 1.2: workspace detail navigation과 탭 복원을 `/console/workspaces/*`로 전환 + +**Files:** +- Modify: `frontend/app/src/app/tabs.ts` +- Modify: `frontend/app/src/pages/system/workspaces/workspaces.page.tsx` +- Modify: `frontend/app/src/pages/system/workspaces/workspace-detail.page.tsx` +- Modify: `frontend/app/src/pages/system/workspaces/workspace-detail.page.test.tsx` +- Modify: `frontend/app/src/pages/system/workspaces/workspaces.page.test.tsx` +- Modify: `frontend/tests/system/workspace-detail-route.spec.ts` + +- [x] **Step 1: 실패 테스트 추가** + - 목록 더블클릭, detail refresh, back, tab restore가 `/console/workspaces/*`로 동작해야 한다는 테스트를 추가한다. +- [x] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm vitest run src/pages/system/workspaces/workspace-detail.page.test.tsx src/pages/system/workspaces/workspaces.page.test.tsx src/app/tabs.test.ts` + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend && pnpm vitest run tests/system/workspace-detail-route.spec.ts` + - Expected: legacy `/system/workspaces/*` expectation FAIL +- [x] **Step 3: 최소 구현** + - workspaces list/detail와 tabs restore path를 `/console/workspaces/*`로 바꾼다. +- [x] **Step 4: green 확인** + - 위 테스트 재실행 +- [ ] **Step 5: 커밋** + - `git add frontend/app/src/app/tabs.ts frontend/app/src/pages/system/workspaces/workspaces.page.tsx frontend/app/src/pages/system/workspaces/workspace-detail.page.tsx frontend/app/src/pages/system/workspaces/workspace-detail.page.test.tsx frontend/app/src/pages/system/workspaces/workspaces.page.test.tsx frontend/tests/system/workspace-detail-route.spec.ts` + - `git commit -m "refactor: move workspace detail flow under console routes"` + +## Chunk 2: Settings Shell Reset + +### Task 2.1: `/settings/*` standalone shell을 `Account`, `Platform` 구조로 재구성 + +**Files:** +- Modify: `frontend/app/src/app/App.tsx` +- Modify: `frontend/app/src/pages/settings/settings-nav.ts` +- Modify: `frontend/app/src/pages/settings/settings.page.tsx` +- Create: `frontend/app/src/pages/settings/tabs/menus-tab.tsx` +- Modify: `frontend/app/src/pages/settings/settings.page.test.tsx` +- Modify: `frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx` +- Modify: `frontend/app/src/features/command-palette/ui/CommandPalette.test.tsx` +- Modify: `frontend/app/src/app/app.test.tsx` + +- [x] **Step 1: 실패 테스트 추가** + - settings nav에서 `Workspace`, `Globalization`이 제거되고 `Platform -> Menus`가 보이는 테스트를 추가한다. + - command palette와 app bootstrap이 `/settings/platform/*` leaf를 인식하는 테스트를 추가한다. +- [x] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm vitest run src/pages/settings/settings.page.test.tsx src/app/command-palette/CommandPaletteProvider.test.tsx src/features/command-palette/ui/CommandPalette.test.tsx src/app/app.test.tsx` + - Expected: nav/leaf mismatch FAIL +- [x] **Step 3: 최소 구현** + - `/settings/*`는 `App.tsx` standalone route를 유지한다. + - settings group을 `account`, `platform`만 남기고 `Menus` leaf를 추가한다. + - `menus-tab.tsx`에서 기존 menu management page model을 settings shell 안에 재사용한다. +- [x] **Step 4: green 확인** + - 위 명령 재실행 +- [ ] **Step 5: 커밋** + - `git add frontend/app/src/app/App.tsx frontend/app/src/pages/settings/settings-nav.ts frontend/app/src/pages/settings/settings.page.tsx frontend/app/src/pages/settings/tabs/menus-tab.tsx frontend/app/src/pages/settings/settings.page.test.tsx frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx frontend/app/src/features/command-palette/ui/CommandPalette.test.tsx frontend/app/src/app/app.test.tsx` + - `git commit -m "refactor: reset settings shell to account and platform"` + +### Task 2.2: backend `PlatformSetting*` 계약으로 정리하고 settings payload rename 마무리 + +**Files:** +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/api/PlatformSettingQuery.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/repository/PlatformSettingRepository.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/OAuthProviderService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderController.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/AuthController.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/UserController.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/config/BrandingProviderImpl.kt` +- Modify: `backend/common/src/main/kotlin/io/deck/common/CacheNames.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/controller/BrandingControllerTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerLoginTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerMeTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/controller/AuthControllerRefreshTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/controller/UserControllerTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceCacheTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingControllerTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderControllerTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/OAuthProviderServiceTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/UserServiceTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/security/OwnerOnlyAnnotationTest.kt` + +- [x] **Step 1: 실패 테스트 추가** + - controller/service/entity naming과 JSON field에서 `system`이 더 이상 남지 않는 테스트를 추가하거나 기존 기대값을 `platform`으로 변경한다. +- [x] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/backend && ./gradlew :iam:test --tests 'io.deck.iam.service.PlatformSettingServiceTest' --tests 'io.deck.iam.service.PlatformSettingServiceCacheTest' --tests 'io.deck.iam.controller.PlatformSettingControllerTest' --tests 'io.deck.iam.controller.PlatformSettingAuthProviderControllerTest'` + - Expected: naming/payload mismatch FAIL +- [x] **Step 3: 최소 구현** + - `PlatformSetting*` 심볼과 API 문맥을 기준으로 정리하고 `workspacePolicy.usePlatformManaged`를 새 payload SSOT로 맞춘다. + - unreleased 기준으로 JPA 매핑, cache name, 초기 스키마를 새 심볼과 일치시킨다. +- [x] **Step 4: green 확인** + - 위 gradle 명령 재실행 +- [ ] **Step 5: 커밋** + - `git add backend/iam/src/main/kotlin/io/deck/iam/domain backend/iam/src/main/kotlin/io/deck/iam/api backend/iam/src/main/kotlin/io/deck/iam/repository backend/iam/src/main/kotlin/io/deck/iam/service backend/iam/src/main/kotlin/io/deck/iam/controller backend/iam/src/main/kotlin/io/deck/iam/config backend/common/src/main/kotlin/io/deck/common/CacheNames.kt backend/iam/src/test/kotlin/io/deck/iam/service backend/iam/src/test/kotlin/io/deck/iam/controller backend/iam/src/test/kotlin/io/deck/iam/security/OwnerOnlyAnnotationTest.kt` + - `git commit -m "refactor: rename system settings to platform settings"` + +## Chunk 3: ManagementType And Workspace Policy Reset + +### Task 3.1: `SYSTEM_MANAGED`를 `PLATFORM_MANAGED`로 hard rename + +**Files:** +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceManagedType.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceManagedType.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/api/ProgramDefinition.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceDirectory.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceRecord.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceDirectoryImpl.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt` +- Modify: `backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceDtos.kt` +- Modify: `backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceController.kt` +- Modify: `backend/app/src/main/kotlin/io/deck/app/controller/MyWorkspaceController.kt` +- Modify: `backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceControllerTest.kt` +- Modify: `backend/app/src/test/kotlin/io/deck/app/controller/MyWorkspaceControllerTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/ProgramRegistryTest.kt` +- Modify: `frontend/app/src/entities/platform-settings/types.ts` +- Modify: `frontend/app/src/entities/menu/types.ts` +- Modify: `frontend/app/src/entities/workspace/types.ts` +- Modify: `frontend/app/src/features/menus/manage-menus/model/types.ts` +- Modify: `frontend/app/src/entities/platform-settings/workspace-access.ts` +- Modify: `frontend/app/src/entities/workspace/visibility.ts` + +- [x] **Step 1: 실패 테스트 추가** + - `SYSTEM_MANAGED` literal이 남아 있지 않고 policy/access 계산이 `PLATFORM_MANAGED`로 동작해야 한다는 테스트를 추가한다. +- [x] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/backend && ./gradlew :app:test --tests 'io.deck.app.controller.WorkspaceControllerTest' --tests 'io.deck.app.controller.MyWorkspaceControllerTest' && ./gradlew :iam:test --tests 'io.deck.iam.service.WorkspaceServiceTest' --tests 'io.deck.iam.service.ProgramRegistryTest'` + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm vitest run src/entities/platform-settings/api.test.ts src/widgets/sidebar/Sidebar.test.tsx` + - Expected: enum mismatch FAIL +- [x] **Step 3: 최소 구현** + - 공용 `ManagementType = USER_MANAGED | PLATFORM_MANAGED`로 hard rename한다. + - `workspacePolicy.usePlatformManaged`와 selector visibility를 새 enum에 맞게 갱신한다. +- [x] **Step 4: green 확인** + - 위 테스트 재실행 +- [ ] **Step 5: 커밋** + - `git add backend/iam/src/main/kotlin/io/deck/iam backend/app/src/main/kotlin/io/deck/app/controller backend/app/src/test/kotlin/io/deck/app/controller backend/iam/src/test/kotlin/io/deck/iam frontend/app/src/entities/platform-settings frontend/app/src/entities/menu/types.ts frontend/app/src/entities/workspace frontend/app/src/features/menus/manage-menus/model/types.ts` + - `git commit -m "refactor: rename system managed workspace contract to platform managed"` + +### Task 3.2: menu runtime visibility와 settings leaf 재배치 + +**Files:** +- Modify: `frontend/app/src/widgets/sidebar/Sidebar.tsx` +- Modify: `frontend/app/src/widgets/sidebar/Sidebar.test.tsx` +- Modify: `frontend/app/src/app/header/NotificationBell.tsx` +- Modify: `frontend/app/src/features/logs/ui/log-detail-modal-body.tsx` +- Modify: `frontend/tests/helpers/menu-smoke.ts` +- Modify: `frontend/tests/system/menu-runtime-smoke.spec.ts` +- Modify: `frontend/tests/system/standalone-menu-smoke.spec.ts` +- Modify: `frontend/tests/system/menus.spec.ts` +- Modify: `frontend/tests/system/menus-icon-picker.spec.ts` +- Modify: `frontend/tests/system/sidebar.spec.ts` +- Modify: `frontend/app/src/widgets/tabbar/tab-bar.test.tsx` +- Modify: `frontend/app/src/entities/menu/types.ts` +- Modify: `frontend/app/src/features/menus/manage-menus/model/types.ts` + +- [ ] **Step 1: 실패 테스트 추가** + - `PLATFORM_MANAGED` menu는 일반 사용자 runtime navigation에서 숨겨지고 `Platform Admin`에게만 보이는 테스트를 추가한다. + - menu management 화면에서는 `PLATFORM_MANAGED` row가 보이는 테스트를 추가한다. +- [ ] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend && pnpm vitest run tests/system/menu-runtime-smoke.spec.ts tests/system/standalone-menu-smoke.spec.ts` + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm vitest run src/widgets/sidebar/Sidebar.test.tsx` + - Expected: visibility mismatch FAIL +- [ ] **Step 3: 최소 구현** + - sidebar/runtime filter만 `PLATFORM_MANAGED`를 숨기고, menu management data source는 그대로 노출한다. +- [ ] **Step 4: green 확인** + - 위 테스트 재실행 +- [ ] **Step 5: 커밋** + - `git add frontend/app/src/widgets/sidebar/Sidebar.tsx frontend/app/src/widgets/sidebar/Sidebar.test.tsx frontend/app/src/app/header/NotificationBell.tsx frontend/app/src/features/logs/ui/log-detail-modal-body.tsx frontend/tests/helpers/menu-smoke.ts frontend/tests/system/menu-runtime-smoke.spec.ts frontend/tests/system/standalone-menu-smoke.spec.ts frontend/tests/system/menus.spec.ts frontend/tests/system/menus-icon-picker.spec.ts frontend/tests/system/sidebar.spec.ts frontend/app/src/widgets/tabbar/tab-bar.test.tsx frontend/app/src/entities/menu/types.ts frontend/app/src/features/menus/manage-menus/model/types.ts` + - `git commit -m "feat: gate platform managed menus at runtime only"` + +## Chunk 4: DB Migration And Seed Closure + +### Task 4.1: workspace invite persistence와 초기 스키마를 V1에 직접 반영 + +**Files:** +- Modify: `backend/app/src/main/resources/db/migration/app/V1__init.sql` +- Modify: `backend/app/src/test/kotlin/io/deck/app/migration/AppMigrationMenuPermissionsTest.kt` + +- [x] **Step 1: migration regression 테스트 또는 flyway validation 기준 추가** + - invite provenance/concurrency가 `V1__init.sql`에 직접 포함되는지 검증을 추가한다. +- [x] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/backend && ./gradlew :app:test --tests 'io.deck.app.migration.AppMigrationMenuPermissionsTest'` + - Expected: V1 SQL assertion FAIL +- [x] **Step 3: 최소 구현** + - `inviter_id`, `version`, pending unique index를 `V1__init.sql`에 직접 반영한다. +- [x] **Step 4: green 확인** + - 위 테스트 재실행 +- [ ] **Step 5: 커밋** + - `git add backend/app/src/main/resources/db/migration/app/V1__init.sql backend/app/src/test/kotlin/io/deck/app/migration/AppMigrationMenuPermissionsTest.kt` + - `git commit -m "feat: fold workspace invite persistence into v1"` + +## Chunk 5: Workspace Model And External Lock + +### Task 5.1: `allowedDomains`를 `autoJoinDomains`로 hard rename하고 API 체인까지 연결 + +**Files:** +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceRecord.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceDirectoryImpl.kt` +- Modify: `backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceDtos.kt` +- Modify: `frontend/app/src/entities/workspace/types.ts` +- Modify: `frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.tsx` +- Modify: `frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-info-tab.tsx` +- Modify: related workspace form tests + +- [x] **Step 1: 실패 테스트 추가** + - DTO/API/UI가 `allowedDomains` 대신 `autoJoinDomains`를 사용해야 한다는 테스트를 추가한다. +- [x] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/backend && ./gradlew :app:test --tests 'io.deck.app.controller.WorkspaceControllerTest'` + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm vitest run src/pages/system/workspaces/workspaces.page.test.tsx src/features/workspaces/manage-workspaces/ui/workspace-info-tab.test.tsx src/features/workspaces/my-workspaces/ui/my-workspace-info-tab.test.tsx` + - Expected: field mismatch FAIL +- [x] **Step 3: 최소 구현** + - `allowedDomains`를 제거하고 `autoJoinDomains`를 end-to-end SSOT로 바꾼다. +- [x] **Step 4: green 확인** + - 위 테스트 재실행 +- [ ] **Step 5: 커밋** + - `git add backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceRecord.kt backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceDirectoryImpl.kt backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceDtos.kt frontend/app/src/entities/workspace/types.ts frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.tsx frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-info-tab.tsx` + - `git commit -m "refactor: rename workspace allowed domains to auto join domains"` + +### Task 5.2: external workspace lock을 service/use-case 경계에서 강제 + +**Files:** +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceMemberService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInviteService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/AccountController.kt` +- Modify: `backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceController.kt` +- Modify: `backend/app/src/main/kotlin/io/deck/app/controller/MyWorkspaceController.kt` +- Modify: `backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceInviteAcceptController.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/controller/AccountControllerWithdrawTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceMemberServiceTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceInviteServiceTest.kt` +- Modify: `backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceControllerTest.kt` +- Modify: `backend/app/src/test/kotlin/io/deck/app/controller/MyWorkspaceControllerTest.kt` +- Modify: `backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceInviteAcceptControllerTest.kt` + +- [ ] **Step 1: 실패 테스트 추가** + - external workspace에 대해 rename, delete, invite create, invite accept, add member, owner transfer, leave, user self-withdraw가 모두 차단되는 테스트를 나눠 추가한다. +- [ ] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/backend && ./gradlew :iam:test --tests 'io.deck.iam.service.WorkspaceServiceTest' --tests 'io.deck.iam.service.WorkspaceMemberServiceTest' --tests 'io.deck.iam.service.WorkspaceInviteServiceTest' --tests 'io.deck.iam.controller.AccountControllerWithdrawTest' && ./gradlew :app:test --tests 'io.deck.app.controller.WorkspaceControllerTest' --tests 'io.deck.app.controller.MyWorkspaceControllerTest' --tests 'io.deck.app.controller.WorkspaceInviteAcceptControllerTest'` + - Expected: lock rule FAIL +- [ ] **Step 3: 최소 구현** + - `externalReference != null`이면 identity/membership/invite mutation을 service/controller에서 차단한다. + - external workspace는 sync source 외 수동 수정 경로를 모두 막는다. +- [ ] **Step 4: green 확인** + - 위 테스트 재실행 +- [ ] **Step 5: 커밋** + - `git add backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceMemberService.kt backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceInviteService.kt backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt backend/iam/src/main/kotlin/io/deck/iam/controller/AccountController.kt backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceController.kt backend/app/src/main/kotlin/io/deck/app/controller/MyWorkspaceController.kt backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceInviteAcceptController.kt backend/iam/src/test/kotlin/io/deck/iam/controller/AccountControllerWithdrawTest.kt backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceMemberServiceTest.kt backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceInviteServiceTest.kt backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceControllerTest.kt backend/app/src/test/kotlin/io/deck/app/controller/MyWorkspaceControllerTest.kt backend/app/src/test/kotlin/io/deck/app/controller/WorkspaceInviteAcceptControllerTest.kt` + - `git commit -m "feat: enforce external workspace locks at service boundaries"` + +## Chunk 6: AIP Sync Foundation + +### Task 6.1: AIP 로그인 실제 경로에서 external workspace auto-provision + +**Files:** +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2AuthenticationSuccessHandler.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/AuthService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/service/AuthServiceTest.kt` +- Modify: `backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2LinkingTest.kt` +- Modify: `backend/app/src/test/kotlin/io/deck/app/oauth2/OAuth2MobileFlowIntegrationTest.kt` + +- [x] **Step 1: 실패 테스트 추가** + - AIP 조직 목록 두 개를 가진 로그인에서 workspace 2개 auto-provision + membership 2개 부여 시나리오를 `AuthServiceTest` 또는 `OAuth2LinkingTest`에 추가한다. + - 같은 `source + externalId`로 재로그인하면 duplicate workspace 없이 재사용되는 테스트를 추가한다. +- [x] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/backend && ./gradlew :iam:test --tests 'io.deck.iam.service.AuthServiceTest' --tests 'io.deck.iam.security.OAuth2LinkingTest' && ./gradlew :app:test --tests 'io.deck.app.oauth2.OAuth2MobileFlowIntegrationTest'` + - Expected: AIP mapping/sync FAIL +- [x] **Step 3: 최소 구현** + - `OAuth2AuthenticationSuccessHandler`에 `aip` registration 매핑을 추가한다. + - `AuthService -> UserService` 경로에서 external organization claims를 받아 `source + external_id` 기준 workspace upsert와 membership auto-provision을 수행한다. + - 같은 사용자가 여러 외부 조직에 속하면 여러 workspace membership을 허용한다. +- [x] **Step 4: green 확인** + - 위 테스트 재실행 +- [ ] **Step 5: 커밋** + - `git add backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2AuthenticationSuccessHandler.kt backend/iam/src/main/kotlin/io/deck/iam/service/AuthService.kt backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt backend/iam/src/test/kotlin/io/deck/iam/service/AuthServiceTest.kt backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2LinkingTest.kt backend/app/src/test/kotlin/io/deck/app/oauth2/OAuth2MobileFlowIntegrationTest.kt` + - `git commit -m "feat: sync aip organizations into external workspaces"` + +## Chunk 7: Final Frontend And End-to-End Verification + +### Task 7.1: legacy `/system/*` regression suite를 `/console/*`, `/settings/platform/*`로 전환 + +**Files:** +- Modify: `frontend/tests/system/users.spec.ts` +- Modify: `frontend/tests/system/logs.spec.ts` +- Modify: `frontend/tests/system/templates.spec.ts` +- Modify: `frontend/tests/system/notification-management.spec.ts` +- Modify: `frontend/tests/system/notification-channels-refresh.spec.ts` +- Modify: `frontend/tests/manual/app/system.spec.ts` + +- [x] **Step 1: 실패 시나리오 업데이트** + - URL, active menu, deep link expectation을 `/console/*`, `/settings/platform/*` 기준으로 바꾼다. +- [x] **Step 2: 실패 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm exec playwright test system/users.spec.ts system/logs.spec.ts system/templates.spec.ts system/notification-management.spec.ts system/notification-channels-refresh.spec.ts --config=playwright.config.ts` + - Expected: old `/system/*` expectation 또는 누락된 bootstrap/mock contract로 red 확인 +- [x] **Step 3: 최소 구현** + - runtime/test fixture/helper 경로를 새 route contract로 치환한다. +- [x] **Step 4: green 확인** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm exec playwright test system/users.spec.ts system/logs.spec.ts system/templates.spec.ts system/notification-management.spec.ts system/notification-channels-refresh.spec.ts --config=playwright.config.ts` + - Expected: PASS (`28 passed`) +- [ ] **Step 5: 커밋** + - `git add frontend/tests/system frontend/tests/manual/app/system.spec.ts` + - `git commit -m "test: update platform regressions to console and settings routes"` + +### Task 7.2: 최종 smoke, 전체 회귀, PR 준비 + +**Files:** +- Verify only + +- [ ] **Step 1: 기동** + - Run: `scripts/dev -p 11` + - Expected: backend/frontend/postgres 기동 완료 +- [ ] **Step 2: 브라우저 smoke** + - `/console/users` + - `/console/workspaces` + - `/console/workspaces/:id` + - `/settings/account/profile` + - `/settings/platform/roles` + - `/settings/platform/menus` + - `deskpie` with `?workspace_id=...` + - `meetpie` without `workspace_id` + - Expected: 직접 눈으로 shell, detail route, settings leaf, service별 workspace scope를 확인 +- [x] **Step 3: 회귀 테스트** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm vitest run` + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/backend && ./gradlew test` + - Expected: PASS +- [x] **Step 4: 품질 게이트** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/frontend/app && pnpm build` + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403/backend && ./gradlew ktlintCheck` + - Expected: PASS +- [ ] **Step 5: PR** + - `git push -u origin kelly/refactor-platform-reset` + - Draft PR 생성 + - CI 확인 + +## Chunk 8: Reference Documentation Alignment + +### Task 8.1: `docs/reference`에서 legacy `System`/`/system/*`/`SYSTEM_MANAGED` 설명 제거 + +**Files:** +- Modify: `docs/reference/workspace.md` +- Modify: `docs/reference/frontend/router.md` +- Modify: `docs/reference/frontend/features.md` +- Modify: `docs/reference/frontend/tabulator.md` +- Modify: `docs/reference/frontend/rules.md` +- Modify: `docs/reference/backend/oauth-setup.md` +- Modify: `docs/reference/legal-pages.md` +- Modify: `docs/reference/infra/e2e-testing.md` +- Modify: `docs/reference/meetpie.md` + +- [x] **Step 1: 문서 diff 기준 정리** + - `System Settings`, `/system/*`, `SYSTEM_MANAGED`, `allowedDomains` 같은 문자열이 남아 있는 문서를 grep으로 다시 확인한다. +- [x] **Step 2: 문서 수정** + - `Platform Settings`, `/console/*`, `/settings/*`, `PLATFORM_MANAGED`, `autoJoinDomains` 기준으로 용어를 맞춘다. + - `meetpie` 문서에는 `workspace_id`가 service별 optional scope라는 현재 결정만 최소한으로 반영한다. +- [x] **Step 3: 검토** + - Run: `cd /Users/kelly/w/deck/.worktrees/platform-reset-20260403 && rg -n \"\\bSystem Settings\\b|/system/|SYSTEM_MANAGED|allowedDomains\" docs/reference docs/*.md` + - Expected: 이번 slice에서 바꾸기로 한 legacy 문구가 남지 않음 +- [ ] **Step 4: 커밋** + - `git add docs/reference/workspace.md docs/reference/frontend/router.md docs/reference/frontend/features.md docs/reference/frontend/tabulator.md docs/reference/frontend/rules.md docs/reference/backend/oauth-setup.md docs/reference/legal-pages.md docs/reference/infra/e2e-testing.md docs/reference/meetpie.md` + - `git commit -m "docs: align reference docs with platform reset"` + +## Verification Matrix + +1. routing + - `dashboard`, `console` leaf, `/console/my-workspaces/*`까지 app-shell canonical path로 수렴 PASS + - `/settings/*`는 standalone settings shell로 PASS + - `/console/workspaces/:id` refresh/back/tab-restore PASS +2. settings IA + - `Account`, `Platform`만 노출 PASS + - `Menus`가 `/settings/platform/menus` leaf로 동작하고 legacy `/console/menus`는 redirect PASS +3. platform terminology + - `useSystemManaged`/`SYSTEM_MANAGED`가 제거되고 `usePlatformManaged`/`PLATFORM_MANAGED`로 수렴 PASS +4. workspace model + - `autoJoinDomains`가 새 SSOT PASS + - `externalReference(source, externalId)` lookup과 unique/idempotent reuse PASS +5. external lock + - rename/member/invite/accept/leave/owner-transfer/delete 차단 regression PASS + - external invite token은 validate 단계부터 `valid=false` PASS + - self-withdraw + 브라우저 read-only smoke PASS +6. AIP sync + - `AuthService`/`UserService`/`OAuth2Linking` 기준 login auto-provision, relogin reuse, multi-workspace membership PASS + - `OAuth2MobileFlowIntegrationTest` 포함 최종 closure PASS +7. runtime visibility + - `PLATFORM_MANAGED` menu는 Platform Admin만 runtime visible PASS + - menu management 화면 row visible PASS +8. system regression + - `users/logs/templates/notification-management/notification-channels-refresh` Playwright 묶음 PASS (`28 passed`) +9. reference docs + - 핵심 reference 문서는 갱신 PASS + - 추가 `docs/reference` cleanup은 후속 확인 필요 +10. automated verification + - frontend `pnpm vitest run` PASS (`318 files / 2401 tests`) + - backend `./gradlew test` PASS + - frontend `pnpm build` PASS + - backend `./gradlew ktlintCheck` PASS +11. manual/PR closure + - smoke, push, PR, CI는 아직 미완료 + +## Risks And Guardrails + +- 이번 slice에서는 `allowedDomains -> autoJoinDomains`, `useSystemManaged -> usePlatformManaged`, `SYSTEM_MANAGED -> PLATFORM_MANAGED`를 모두 hard rename 한다. compatibility alias를 만들지 않는다. +- `/settings/*`는 page registry에 넣지 않는다. settings shell은 `App.tsx` standalone route가 계속 책임진다. +- external workspace lock은 entity 메서드가 아니라 service/use-case guard가 책임진다. +- AIP sync는 `source + externalId`와 membership auto-provision만 한다. 외부 추가 metadata는 이번 범위에 넣지 않는다. +- workspace를 settings 그룹으로 승격하지 않는다. service별 `workspace_id` 확장 포인트는 그대로 유지한다. diff --git a/docs/plans/2026-04-03-platform-reset-and-workspace-scope.md b/docs/plans/2026-04-03-platform-reset-and-workspace-scope.md new file mode 100644 index 000000000..4670d2edd --- /dev/null +++ b/docs/plans/2026-04-03-platform-reset-and-workspace-scope.md @@ -0,0 +1,372 @@ +--- +status: active +author: "@kelly" +created: 2026-04-03 +completed: +--- + +# Platform Reset And Workspace Scope Implementation Plan + +> **For agentic workers:** REQUIRED: Use `superpowers:subagent-driven-development` (if subagents available) or `superpowers:executing-plans` to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `Organization` 도입 시도를 제거하고, `Platform / Workspace / Account` 기준으로 용어·라우팅·설정·메뉴 모델을 다시 정렬한다. + +**Architecture:** authenticated app shell은 `/console/*`, `/settings/*` 두 축으로 정규화하고, 기존 `System` 용어를 `Platform`으로 치환한다. `Workspace`는 tenant-like route segment가 아니라 서비스별 optional scope이며, `workspace_id` query param + 기존 workspace policy로 제어한다. shared router는 pathname canonicalization만 담당하고, app shell access/sidebar/tabs는 `resolveRouteAccess` 공용 계약을 사용한다. service page/API는 같은 workspace context 우선순위 개념을 각 레이어에서 해석하되, 실제 wire query key는 서비스 계약이 소유한다. 메뉴와 워크스페이스는 공용 `ManagementType(USER_MANAGED | PLATFORM_MANAGED)`를 사용하고, 외부(AIP) 연계는 `Workspace.externalReference(source, externalId)` 기반 foundation으로 정리한다. OAuth 로그인 이후 external organization sync는 `AuthService`가 진입하고, `UserService`가 policy/event를 조합하며, `ExternalOrganizationSync(NoSync | Unavailable | AuthoritativeSnapshot)` 계약을 거쳐 실제 reconciliation은 `ExternalWorkspaceSyncService`가 담당한다. + +**Tech Stack:** Kotlin, Spring Boot, JPA, Flyway, PostgreSQL, React 19, Vite, React Router, Vitest, Playwright + +--- + +## Context + +- 기존 `feature/org-foundation` 방향은 폐기한다. +- 새 worktree는 `/Users/kelly/w/deck/.worktrees/platform-reset-20260403` 이고 `origin/develop` 기준 clean branch다. +- 이번 계획은 **새 기준선에서 다시 구현**한다. +- 핵심 기준은 아래다. + - `System` → `Platform` + - `Organization` 제거 + - authenticated route → `/console/*`, `/settings/*` + - `Workspace`는 공통 settings 그룹이 아님 + - 서비스별로 필요할 때만 `workspace_id` query param 사용 + - `ManagementType = USER_MANAGED | PLATFORM_MANAGED` + - external workspace는 `externalReference(source, externalId)` + `PLATFORM_MANAGED` + +## Scope + +### 포함 + +- `System` 용어 제거 및 `Platform` 용어 고정 +- `/system/*` 계열 console route를 `/console/*` 계열로 재배치 +- `/settings/system/*`를 `/settings/platform/*`로 재배치 +- `SystemSetting*`를 `PlatformSetting*`로 rename +- `WorkspaceManagedType`를 공용 `ManagementType`으로 확장 +- menu의 `PLATFORM_MANAGED` 런타임 노출 규칙 추가 +- `Workspace.externalReference(source, externalId)` foundation 추가 +- AIP 로그인 시 external workspace upsert/membership sync foundation 추가 + +### 제외 + +- `Organization` 재도입 +- workspace slug / workspace route segment +- workspace 전용 settings 그룹 +- org-like multi-level membership +- AIP gateway 최종 토큰 포맷 확정 전의 상세 mapping UX + +## Final Contract Snapshot + +### 용어 + +- `Platform` +- `Workspace` +- `Account` + +### Routes + +- console: + - `/console/dashboard` + - `/console/users` + - `/console/workspaces` + - `/console/logs/*` + - service program path: `/console/{menu_route}` +- settings: + - `/settings/account/*` + - `/settings/platform/*` +- public: + - `/invite/*` + - `/workspace-invite/*` + +### Workspace + +- 공통 settings 그룹으로 올리지 않는다. +- 서비스가 필요할 때만 `workspace_id` query param으로 scope를 준다. +- shared router는 `workspace_id`를 canonical path로 해석하지 않고 그대로 보존한다. +- app shell access/sidebar/tabs는 FE workspace context resolver로 `workspace_id`를 읽는다. +- service page/API는 같은 workspace context 우선순위 개념을 각 레이어에서 구현하되, 실제 wire query key는 서비스가 소유한다. +- `deskpie`는 workspace-aware +- `meetpie`는 workspace-free 가능 + +### ManagementType + +- `USER_MANAGED` +- `PLATFORM_MANAGED` + +### External Workspace + +- `Workspace.externalReference: ExternalReference?` +- `ExternalReference.externalId` +- `externalReference != null` 이면 external +- external workspace는 반드시 `PLATFORM_MANAGED` +- Deck UI와 service 경계에서 identity/membership/invite 수정 불가 + +## File Structure Map + +| 영역 | 파일 | 역할 | +|---|---|---| +| FE routing | `frontend/app/src/app/page-registry.ts` | `/console/*` canonical loader mapping | +| FE routing | `frontend/app/src/shared/router/route-descriptor.ts` | pathname canonicalization과 detail route 규칙 관리 (`workspace_id`는 opaque query로 보존) | +| FE auth | `frontend/app/src/shared/auth-redirect.ts` | post-auth redirect target를 새 route로 정규화 | +| FE settings | `frontend/app/src/pages/settings/settings-nav.ts` | `Platform` 그룹과 leaf 경로 정의 | +| FE settings | `frontend/app/src/pages/settings/settings.page.tsx` | `System` leaf 제거, `Platform` leaf 렌더 | +| FE settings | `frontend/app/src/pages/settings/tabs/workspace-tab.tsx` | `Workspace Policy`를 `Platform Settings` 하위 leaf로 유지 | +| FE entities | `frontend/app/src/entities/platform-settings/*` | `platform-settings` 기준 타입/경로 정리 | +| FE menu | `frontend/app/src/entities/menu/types.ts` | 공용 `ManagementType` 반영 | +| FE sidebar | `frontend/app/src/widgets/sidebar/Sidebar.tsx` | `PLATFORM_MANAGED` 메뉴는 platform admin만 노출 | +| FE deskpie | `frontend/app/src/app/tabs.ts`, `frontend/app/src/app/page-access.ts` | `workspace_id` query param + workspace policy 유지 | +| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt` | workspace policy를 소유하는 platform settings aggregate | +| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt` | platform settings query/update service | +| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt` | platform settings REST controller | +| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderController.kt` | auth provider settings controller | +| BE menus | `backend/iam/src/main/kotlin/io/deck/iam/controller/MenuDtos.kt` | menu managed type를 공용 enum으로 전환 | +| BE menus | `backend/iam/src/main/kotlin/io/deck/iam/controller/MenuController.kt` | platform managed menu visibility contract | +| BE program | `backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt` | `/console/*` 경로로 program registry 갱신 | +| BE workspace | `backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt` | `externalReference`와 공용 `ManagementType` 반영 | +| BE workspace | `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt` | external workspace 수정 잠금과 mutable workspace 규칙 | +| BE sync | `backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt` | external workspace upsert + authoritative membership reconciliation | +| BE auth | `backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2UserInfoExtractor.kt` 등 | AIP claim에서 external workspace key 추출 foundation | +| BE migration | `backend/app/src/main/resources/db/migration/app/V1__init.sql` | unreleased 기준으로 invite provenance/concurrency를 포함한 초기 스키마 최종본을 유지 | + +## Chunk 1: Route And Terminology Reset + +### Task 1: FE route contract를 `/console/*`, `/settings/platform/*`로 고정 + +**Files:** +- Modify: `frontend/app/src/app/page-registry.ts` +- Modify: `frontend/app/src/shared/router/route-descriptor.ts` +- Modify: `frontend/app/src/shared/auth-redirect.ts` +- Test: `frontend/app/src/app/page-registry.test.ts` +- Test: `frontend/app/src/shared/router/route-descriptor.test.ts` +- Test: `frontend/app/src/shared/auth-redirect.test.ts` + +- [ ] Step 1: failing test 추가 + - `/system/users`는 더 이상 canonical route가 아니고 `/console/users`를 써야 한다. + - `/settings/system/general`은 `/settings/platform/general`로 해석돼야 한다. + - post-auth redirect가 `/system/users` 대신 `/console/users`로 복원돼야 한다. +- [ ] Step 2: 테스트 실행해 red 확인 + - Run: `cd frontend/app && pnpm vitest run src/app/page-registry.test.ts src/shared/router/route-descriptor.test.ts src/shared/auth-redirect.test.ts` +- [ ] Step 3: route descriptor / page registry / auth redirect 최소 구현 +- [ ] Step 4: 같은 테스트 재실행해 green 확인 +- [ ] Step 5: 변경 파일만 커밋 + +### Task 2: BE program/menu registry 경로를 `/console/*`로 갱신 + +**Files:** +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt` +- Modify: `backend/app/src/main/kotlin/io/deck/app/init/DevDataSeeder.kt` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/service/ProgramRegistryTest.kt` +- Test: `backend/app/src/test/kotlin/io/deck/app/migration/AppMigrationMenuPermissionsTest.kt` + +- [ ] Step 1: failing test 추가 + - `USER_MANAGEMENT`, `MENU_MANAGEMENT`, `WORKSPACE_MANAGEMENT`, log program path가 `/console/*`를 가리켜야 한다. +- [ ] Step 2: targeted test red 확인 + - Run: `cd backend && ./gradlew :iam:test --tests "io.deck.iam.service.ProgramRegistryTest" :app:test --tests "io.deck.app.migration.AppMigrationMenuPermissionsTest"` +- [ ] Step 3: registry / seeder path 최소 수정 +- [ ] Step 4: 같은 테스트 green 확인 +- [ ] Step 5: 커밋 + +## Chunk 2: System Settings → Platform Settings + +### Task 3: backend settings aggregate/controller/service rename + +**Files:** +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/repository/PlatformSettingRepository.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingAuthProviderController.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingControllerTest.kt` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceTest.kt` + +- [ ] Step 1: rename contract red test 먼저 추가 + - controller path / DTO 이름 / service entry에서 `Platform` 용어 기대를 추가 +- [ ] Step 2: targeted tests red 확인 + - Run: `cd backend && ./gradlew :iam:test --tests "io.deck.iam.controller.PlatformSettingControllerTest" --tests "io.deck.iam.service.PlatformSettingServiceTest"` +- [ ] Step 3: 최소 rename 구현 + - 내부 persistence table/row는 pre-release 기준 필요 시 migration까지 같이 수정 + - workspace policy owner는 계속 platform settings가 유지 +- [ ] Step 4: 테스트 green 확인 +- [ ] Step 5: 커밋 + +### Task 4: frontend system-settings를 platform-settings로 rename + +**Files:** +- Modify: `frontend/app/src/entities/platform-settings/api.ts` +- Modify: `frontend/app/src/entities/platform-settings/types.ts` +- Modify: `frontend/app/src/entities/platform-settings/store.ts` +- Modify: `frontend/app/src/pages/settings/settings-nav.ts` +- Modify: `frontend/app/src/pages/settings/settings.page.tsx` +- Modify: `frontend/app/src/pages/settings/tabs/workspace-tab.tsx` +- Test: `frontend/app/src/entities/platform-settings/api.test.ts` +- Test: `frontend/app/src/pages/settings/settings.page.test.tsx` + +- [ ] Step 1: failing test 추가 + - settings nav가 `System`이 아니라 `Platform` 그룹을 노출해야 한다. + - `/settings/platform/workspace-policy` leaf가 active 되어야 한다. +- [ ] Step 2: 관련 vitest red 확인 + - Run: `cd frontend/app && pnpm vitest run src/entities/platform-settings/api.test.ts src/pages/settings/settings.page.test.tsx` +- [ ] Step 3: FE settings/nav/store rename 최소 구현 +- [ ] Step 4: 같은 tests green 확인 +- [ ] Step 5: 커밋 + +## Chunk 3: Shared ManagementType + +### Task 5: backend 공용 `ManagementType` 도입 + +**Files:** +- Create: `backend/iam/src/main/kotlin/io/deck/iam/ManagementType.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/api/ProgramDefinition.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/MenuDtos.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/MenuController.kt` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceEntityTest.kt` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/controller/MenuControllerTest.kt` + +- [ ] Step 1: failing test 추가 + - `Workspace`는 `USER_MANAGED | PLATFORM_MANAGED` + - `Menu` program workspace policy도 같은 enum 값을 쓴다 +- [ ] Step 2: red 확인 + - Run: `cd backend && ./gradlew :iam:test --tests "io.deck.iam.domain.WorkspaceEntityTest" --tests "io.deck.iam.controller.MenuControllerTest"` +- [ ] Step 3: enum 통합 및 기존 `SYSTEM_MANAGED` 값 migration 구현 +- [ ] Step 4: green 확인 +- [ ] Step 5: 커밋 + +### Task 6: frontend도 공용 `ManagementType`로 통일 + +**Files:** +- Modify: `frontend/app/src/entities/platform-settings/types.ts` +- Modify: `frontend/app/src/entities/workspace/types.ts` +- Modify: `frontend/app/src/entities/menu/types.ts` +- Modify: `frontend/app/src/entities/workspace/visibility.ts` +- Test: `frontend/app/src/entities/workspace/store.test.ts` +- Test: `frontend/app/src/app/page-registry.test.ts` + +- [ ] Step 1: failing test 추가 + - `PLATFORM_MANAGED` workspace/menu를 새 타입으로 해석해야 한다. +- [ ] Step 2: red 확인 + - Run: `cd frontend/app && pnpm vitest run src/entities/workspace/store.test.ts src/app/page-registry.test.ts` +- [ ] Step 3: 타입/visibility 최소 수정 +- [ ] Step 4: green 확인 +- [ ] Step 5: 커밋 + +## Chunk 4: Platform Menu And Settings IA + +### Task 7: menu 관리와 role 관리를 `Platform Settings`로 고정 + +**Files:** +- Modify: `frontend/app/src/pages/settings/settings-nav.ts` +- Modify: `frontend/app/src/widgets/sidebar/Sidebar.tsx` +- Modify: `frontend/app/src/app/page-registry.ts` +- Test: `frontend/app/src/widgets/sidebar/Sidebar.test.tsx` +- Test: `frontend/app/src/pages/settings/settings.page.test.tsx` + +- [ ] Step 1: failing test 추가 + - `Platform` 그룹 하위에 `Roles`, `Menus`가 보여야 한다. + - 일반 사용자는 `PLATFORM_MANAGED` 메뉴를 못 봐야 한다. + - 메뉴 관리 화면에서는 `PLATFORM_MANAGED` row도 읽을 수 있어야 한다. +- [ ] Step 2: red 확인 + - Run: `cd frontend/app && pnpm vitest run src/widgets/sidebar/Sidebar.test.tsx src/pages/settings/settings.page.test.tsx` +- [ ] Step 3: sidebar/settings IA 최소 구현 +- [ ] Step 4: green 확인 +- [ ] Step 5: 커밋 + +### Task 8: console/settings shell smoke를 새 경로로 재작성 + +**Files:** +- Modify: `frontend/tests/system/menu-runtime-smoke.spec.ts` +- Modify: `frontend/tests/system/standalone-menu-smoke.spec.ts` +- Modify: `frontend/tests/helpers/menu-smoke.ts` + +- [ ] Step 1: failing e2e/assertion 추가 + - `/console/users`, `/console/workspaces`, `/settings/platform/menus`가 canonical entry여야 한다. +- [ ] Step 2: smoke spec red 확인 + - Run: `cd frontend/app && BASE_URL=https://localhost:4011 pnpm exec playwright test ../tests/system/menu-runtime-smoke.spec.ts ../tests/system/standalone-menu-smoke.spec.ts --project=chromium --workers=1` +- [ ] Step 3: helper/spec 최소 수정 +- [ ] Step 4: green 확인 +- [ ] Step 5: 커밋 + +## Chunk 5: Workspace External Reference And AIP Sync Foundation + +### Task 9: workspace external foundation 추가 + +**Files:** +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceEntity.kt` +- Create: `backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalReference.kt` 또는 workspace 내부 embeddable +- Modify: `backend/app/src/main/resources/db/migration/app/V1__init.sql` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceEntityTest.kt` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt` + +- [ ] Step 1: failing test 추가 + - external workspace는 `externalId`를 가진다. + - external workspace는 identity/membership mutation이 막힌다. +- [ ] Step 2: red 확인 + - Run: `cd backend && ./gradlew :iam:test --tests "io.deck.iam.domain.WorkspaceEntityTest" --tests "io.deck.iam.service.WorkspaceServiceTest"` +- [ ] Step 3: entity + migration 최소 구현 +- [ ] Step 4: green 확인 +- [ ] Step 5: 커밋 + +### Task 10: AIP 로그인 시 external workspace membership sync foundation 추가 + +**Files:** +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2UserInfoExtractor.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/UserService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/service/UserServiceTest.kt` + +- [ ] Step 1: failing test 추가 + - AIP login payload가 external org ids를 주면 동일 `source + externalId` workspace를 찾거나 생성한다. + - 같은 user는 여러 workspace membership을 가질 수 있다. +- [ ] Step 2: red 확인 + - Run: `cd backend && ./gradlew :iam:test --tests "io.deck.iam.service.UserServiceTest"` +- [ ] Step 3: 최소 sync foundation 구현 + - token 상세 포맷은 adapter/mapper 경계 뒤에 캡슐화 + - 실제 gateway wire format이 확정되지 않았으면 mapper stub + contract test로 고정 +- [ ] Step 4: green 확인 +- [ ] Step 5: 커밋 + +## Chunk 6: Final Verification And PR Finish + +### Task 11: regression matrix 재검증 + +**Files:** +- Modify if needed: `docs/plans/2026-04-03-platform-reset-and-workspace-scope.md` + +- [ ] Step 1: backend targeted suites 실행 + - Run: `cd backend && ./gradlew :iam:test --tests "io.deck.iam.domain.ManagementTypeContractTest" --tests "io.deck.iam.domain.WorkspaceEntityTest" --tests "io.deck.iam.service.ExternalWorkspaceSyncServiceTest" --tests "io.deck.iam.service.UserServiceTest" --tests "io.deck.iam.service.WorkspaceMemberServiceTest" --tests "io.deck.iam.service.WorkspaceInviteServiceTest" --tests "io.deck.iam.service.MenuSeedCommandImplTest" --tests "io.deck.iam.service.MenuServiceTest" --tests "io.deck.iam.service.WorkspaceServiceTest" --tests "io.deck.iam.service.WorkspaceProvisioningCommandImplTest" --tests "io.deck.iam.controller.MenuControllerTest"` +- [ ] Step 2: frontend vitest suites 실행 + - Run: `cd frontend/app && pnpm vitest run src/app/page-registry.test.ts src/shared/router/route-descriptor.test.ts src/shared/auth-redirect.test.ts src/app/app.test.tsx src/pages/settings/settings.page.test.tsx src/widgets/sidebar/Sidebar.test.tsx src/entities/platform-settings/api.test.ts` +- [ ] Step 3: browser smoke 실행 (`scripts/dev -p 11`) + - Run: `cd frontend/app && BASE_URL=https://localhost:4011 pnpm exec playwright test ../tests/system/menu-runtime-smoke.spec.ts ../tests/system/standalone-menu-smoke.spec.ts --project=chromium --workers=1` +- [ ] Step 4: 결과를 plan 문서에 체크 +- [ ] Step 5: 커밋 + +### Task 12: branch / push / draft PR / CI + +- [ ] Step 1: branch 상태 확인 + - Run: `git branch --show-current` + - Expected: `kelly/refactor-platform-reset` +- [ ] Step 2: commit + - Scope 예시: `backend`, `frontend/app`, `docs` +- [ ] Step 3: push + - Run: `git push -u origin kelly/refactor-platform-reset` +- [ ] Step 4: draft PR 생성 + - Base: `develop` + - Title: `refactor: reset platform terminology and workspace scope` +- [ ] Step 5: CI 상태 확인 + - Run: `gh pr checks --watch` + +## Verification Notes + +- fresh verification 없이 완료 주장 금지 +- browser proof는 반드시 `scripts/dev -p 11` 기준 +- `24`, `25` suffix 포트 사용 금지 +- menu lock-out 방지를 위해 `Roles`, `Menus` entry는 `PLATFORM_MANAGED` 고정 메뉴로 유지 +- workspace는 현재 settings 그룹으로 올리지 않는다 + +## Suggested Commit Strategy + +1. `refactor(frontend): reset console and settings platform routes` +2. `refactor(backend): rename system settings to platform settings` +3. `refactor(shared): align workspace and menu management type` +4. `feat(iam): add external workspace reference foundation` +5. `feat(iam): sync aip workspace memberships on login` +6. `docs: add platform reset and workspace scope plan progress` diff --git a/docs/reference/backend/encryption-architecture.md b/docs/reference/backend/encryption-architecture.md index 1d181de44..3d875d192 100644 --- a/docs/reference/backend/encryption-architecture.md +++ b/docs/reference/backend/encryption-architecture.md @@ -228,7 +228,7 @@ export VAULT_KEY_NAME=deck-app | 엔티티 | 필드 | 용도 | |--------|------|------| | `UserEntity` | `totpSecret`, `totpBackupCodes` | TOTP 시크릿/백업 코드 | -| `SystemSettingEntity` | `auth0ClientSecret`, `oktaClientSecret` | OAuth2 Client Secret | +| `PlatformSettingEntity` | `auth0ClientSecret`, `oktaClientSecret` | OAuth2 Client Secret | | `NotificationChannelEntity` | `settings` | 알림 채널 provider 설정 | | `CalendarConnectionEntity` | `settings` | 캘린더 연동 설정 | | `JwkEntity` | `privateKey` | JWK 개인키 | diff --git a/docs/reference/backend/flyway.md b/docs/reference/backend/flyway.md index 3a7c54a8a..88b865f56 100644 --- a/docs/reference/backend/flyway.md +++ b/docs/reference/backend/flyway.md @@ -10,12 +10,16 @@ deskpie/src/main/resources/db/migration/deskpie/ # deskpie 도메인 스키마 ## 버전 규칙 -- **deck(app)**: `V{N}__description.sql` — 순차 번호 (V1, V2, ...) -- **서비스 모듈(meetpie, deskpie 등)**: `V{yyMMddHHmmss}__description.sql` — 타임스탬프 기반 +- **deck(app)**: + - 초기 스키마는 `V1__init.sql` 하나로 유지한다. + - `V1` 이후 increment는 `V{yyyyMMddHHmm}__description.sql` 타임스탬프 규칙을 사용한다. +- **서비스 모듈(meetpie, deskpie 등)**: `V{yyyyMMddHHmm}__description.sql` — 타임스탬프 기반 ``` -V1__init.sql # deck 초기 스키마 (전체 통합) -V260220160530__create_licenses.sql # 서비스 모듈에서 추가 +V1__init.sql # deck 초기 스키마 (전체 통합) +V202603250130__encrypt_notification_channel_settings.sql +V202603311100__object_storage_blob_backing_store.sql +V202602251200__meetpie_init.sql # 서비스 모듈 init ``` ## 설정 @@ -38,7 +42,7 @@ flyway: | 접두사 | 용도 | 예시 | |--------|------|------| -| V | 버전 마이그레이션 | V2__add_sessions.sql | +| V | 버전 마이그레이션 | `V1__init.sql`, `V202603250130__encrypt_notification_channel_settings.sql` | | R | 반복 실행 (뷰, 함수) | R__create_audit_view.sql | ## 테이블/컬럼 코멘트 @@ -66,7 +70,8 @@ COMMENT ON COLUMN holiday_subscriptions.type IS 'PRESET: 국가 공휴일, CUSTO ## 주의사항 -- `V1__init.sql`은 초기 스키마용 (예외) -- 한번 적용된 마이그레이션은 수정 금지 +- `V1__init.sql`은 초기 스키마용 예외다. +- `V1` 이후 timestamped migration은 한번 적용되면 수정하지 않는다. - 단, 아직 어떤 공유 환경에도 적용되지 않은 unreleased 초기 스키마 변경은 같은 브랜치 범위에서 init migration(`V1__init.sql`, 서비스 init migration)으로 흡수할 수 있다. -- init 흡수 시에는 모듈이 공유하는 Flyway history namespace에서 version 충돌이 없는지 함께 확인한다. +- init 흡수 후에는 새 DB에서 실제 `flywayMigrate`로 검증하고, 기존 dev DB는 checksum/history drift가 있으면 fresh volume 또는 `repair` 전략으로 정리한다. +- app/service를 같은 포트 스택에서 번갈아 띄울 때는 모듈별 migration history가 달라질 수 있으므로 필요하면 `scripts/dev down -p -v`로 fresh DB를 다시 만든다. diff --git a/docs/reference/backend/globalization.md b/docs/reference/backend/globalization.md index 7e46a45fa..a13c2501d 100644 --- a/docs/reference/backend/globalization.md +++ b/docs/reference/backend/globalization.md @@ -22,7 +22,7 @@ ## SSOT 원칙 -- 시스템 정책의 SSOT는 `SystemSettingService.getSettings()`다. +- 플랫폼 정책의 SSOT는 `PlatformSettingService.getSettings()`다. - frontend는 국가별 규칙을 직접 정의하지 않고 backend contract를 우선 소비한다. - backend 내부에서도 `if (countryCode == "KR")`를 서비스 곳곳에 흩뿌리지 않고, country-aware resolver/value normalizer로 모은다. @@ -81,6 +81,6 @@ app: - `backend/globalization/src/main/kotlin/io/deck/globalization/contactfield/**` - `backend/globalization/src/main/kotlin/io/deck/globalization/businessregistration/**` -- `backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt` +- `backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt` - `backend/iam/src/main/kotlin/io/deck/iam/controller/UserController.kt` - `frontend/app/src/shared/party/contact-field-config.ts` diff --git a/docs/reference/backend/oauth-setup.md b/docs/reference/backend/oauth-setup.md index 4b503959b..cebd7e4eb 100644 --- a/docs/reference/backend/oauth-setup.md +++ b/docs/reference/backend/oauth-setup.md @@ -107,10 +107,16 @@ AIP(Duplo)는 내부 IdP로, 표준 OIDC 프로토콜을 사용한다. Duplo의 } ``` > `require_proof_key: true`이면 PKCE(S256)가 필수다. Spring Security OAuth2 Client는 기본적으로 PKCE를 지원한다. -3. Deck 관리자 → 설정 → Auth → AIP 섹션에 입력: +3. Deck 관리자 → `Settings > Platform > Authentication > AIP` 섹션에 입력: - **Issuer URI**: `https://api.{duplo-host}` (trailing slash 없이) - **Client ID**, **Client Secret**: 위에서 받은 값 +> 외부 sync 사용자는 별도 전용 로그인 화면을 쓰지 않는다. 일반 AIP OAuth 로그인 성공 뒤 claim의 external organization snapshot이 `AuthService -> OAuthLoginProvisioningService -> UserService -> ExternalWorkspaceSyncService` 경계로 전달되고, 그 결과가 external workspace membership에 반영된다. + +> `useExternalSync`는 reconciliation만 켜는 스위치가 아니라, AIP external organization flow 전체 enablement다. 이 값이 꺼져 있으면 AIP claim이 와도 external workspace auto-approval과 membership sync를 모두 수행하지 않는다. + +> external workspace 매핑 키는 `ExternalReference(source, externalId)`다. 현재 v1 source는 `AIP` 단일 값이지만, 저장 키는 처음부터 `source + externalId` 조합으로 유지한다. + > 참고: 로컬 테스트 시 redirect URI를 `http://localhost:8011/login/oauth2/code/aip`로 등록 ## Okta @@ -202,10 +208,10 @@ export GOOGLE_OAUTH_CLIENT_SECRET="GOCSPX-your-secret" ## Deck 설정 -발급받은 Client ID/Secret을 Deck 시스템 설정에서 등록: +발급받은 Client ID/Secret을 Deck 플랫폼 설정에서 등록: 1. 관리자 로그인 -2. **System Settings** → **Authentication** +2. **Settings** → **Platform** → **Authentication** 3. 각 Provider 활성화 및 Client ID/Secret 입력 (Okta: Domain, Microsoft: Tenant ID 추가 입력) 4. 저장 diff --git a/docs/reference/backend/party.md b/docs/reference/backend/party.md index 1e3e6610e..996ce0daf 100644 --- a/docs/reference/backend/party.md +++ b/docs/reference/backend/party.md @@ -222,7 +222,7 @@ user withdraw/delete - `companies.phone`, `companies.country_code`, `companies.postcode`, `companies.address`, `companies.address_detail`, `contacts.phone`, `contracting_parties.phone`도 deskpie 이행 단계에서 제거한다. - `resolveContactProfile()` 같은 읽기 경로는 `party`만 읽는다. - self profile UI는 이름 수정만 담당한다. -- `system/users`가 first visible consumer다. +- `/console/users`가 first visible consumer다. - `UserDto`, `AccountResponse`, `UpdateProfileRequest`, deskpie CRM owner DTO는 모두 `contactProfile` 배열 계약을 사용한다. ## 현재 API 범위 diff --git a/docs/reference/common-rules.md b/docs/reference/common-rules.md index 0ce2a42e7..c9b837e0a 100644 --- a/docs/reference/common-rules.md +++ b/docs/reference/common-rules.md @@ -85,8 +85,9 @@ KISS: `Keep It Simple, Stupid` - 사용자 본인 소유 리소스(My Resource) 경로는 `my-{resource}` 형식을 사용한다. - BE API: `/api/v1/my-{resource}` (예: `/api/v1/my-workspaces`, `/api/v1/my-namecards`) - - FE 라우트: `/my-{resource}/` (예: `/my-workspaces/`, `/my-namecards/`) - 프로그램 코드: `MY_{RESOURCE}` (예: `MY_WORKSPACE`, `MY_NAMECARD`) +- authenticated FE 라우트는 `/console/*` 또는 `/settings/*` shell 아래 canonical path를 사용한다. + - 예: `/console/my-workspaces/` - 관리자용 리소스 경로는 복수 명사를 사용한다 (예: `/api/v1/workspaces`). ### MUST NOT diff --git a/docs/reference/frontend/features.md b/docs/reference/frontend/features.md index fa0a928fe..c616ec15a 100644 --- a/docs/reference/frontend/features.md +++ b/docs/reference/frontend/features.md @@ -2,7 +2,7 @@ ## 목적 -기능 단위 설계와 구현 패턴을 정의한다. 골든 레퍼런스: `pages/system/users/` + `features/users/`. +기능 단위 설계와 구현 패턴을 정의한다. 골든 레퍼런스는 canonical route `/console/users`와 users management feature 조합이다. 사용자-facing shell은 `/console/*`, shell 컴포넌트의 canonical 이름은 `ConsoleLayout`이다. ## 1. 페이지 구조 패턴 (Page Composition) @@ -20,7 +20,7 @@ pages/{domain}/{page}.page.tsx ← 조합만 (100줄 이하) └── {dialog}-content.tsx ← JSX 다이얼로그 바디 ``` -참조: `pages/system/users/users.page.tsx` + `features/users/manage-users/` +참조: canonical route `/console/users`를 담당하는 users management page + `features/users/manage-users/` ### MUST @@ -111,7 +111,7 @@ features/{domain}/{name}-(form|picker)/ ## 8. Standalone 페이지 패턴 -시스템 관리 테이블 페이지(§1)와 달리, 인증/계정/초대 등 독립 페이지를 정의한다. +console 테이블 페이지(§1)와 달리, 인증/계정/초대 등 독립 페이지를 정의한다. ``` pages/{domain}/{page}.page.tsx ← 조합만 (100줄 이하) @@ -119,7 +119,7 @@ pages/{domain}/use-{page}-page.ts ← 페이지 전용 훅 (co-located) pages/{domain}/{tab}-tab.tsx ← 탭 콘텐츠 (탭 구성 시) ``` -참조: `pages/account/setting/` (탭), `pages/login/` (features 위임) +참조: `pages/settings/` (shell), `pages/account/profile/` + `pages/settings/tabs/` (탭 구현), `pages/login/` (features 위임) ### 유형별 패턴 @@ -127,7 +127,7 @@ pages/{domain}/{tab}-tab.tsx ← 탭 콘텐츠 (탭 구성 시) |------|------|---------| | 복합 인증 플로우 | login | `features/auth/login` (재사용) | | 단일 인증 플로우 | password-change | co-located `use-*-page.ts` | -| 탭 구성 | profile, setting | co-located `use-*-page.ts` + `*-tab.tsx` | +| 탭 구성 | profile, settings | co-located `use-*-page.ts` + `*-tab.tsx` | | 폼 기반 | invite | co-located `use-*-page.ts` | | 단순 상태 | pending | 훅 없이 인라인 허용 | diff --git a/docs/reference/frontend/router.md b/docs/reference/frontend/router.md index f6f24d3d5..bc8247041 100644 --- a/docs/reference/frontend/router.md +++ b/docs/reference/frontend/router.md @@ -2,59 +2,84 @@ ## 목적 -각 탭 페이지 내에서 hash 기반 라우팅으로 뷰를 전환한다. +보호된 SPA 화면은 `pathname`으로 shell을 결정하고, 페이지 내부의 목록/상세 전환은 hash 라우팅으로 처리한다. + +## Shell 계약 + +- 이 문서는 authenticated shell과 shell 하위 canonical path 계약을 다룬다. +- authenticated route + - `/console/*` + - `/settings/*` +- service program/page canonical path는 `/console/{menu_route}`를 사용한다. +- `deskpie`, `meetpie` 같은 서비스 이름을 pathname prefix로 다시 넣지 않는다. +- public route + - `/invite/*` + - `/workspace-invite/*` +- standalone/public route는 서비스가 소유한다. + - 예: `/book/*`, `/namecards/*`, `/widget-previews/*`, `/legal/*` +- `console/settings` 중첩은 만들지 않는다. +- URL 계약은 `Settings` 복수형을 사용한다. +- router canonicalization은 pathname 기준이다. `workspace_id` 같은 service scope query param은 route identity로 해석하지 않고 그대로 보존한다. +- app shell access 판정은 `entities/workspace/scope.ts`의 공용 resolver로 `workspace_id` query를 우선 사용하고, 없으면 현재 선택 workspace를 fallback으로 사용한다. +- `app/page-access.ts`의 `resolveRouteAccess`는 console shell의 page/menu/tab visibility 계약을 공용으로 계산하고, sidebar visibility와 tab restore/open도 같은 계약을 재사용한다. +- `/settings/*` leaf access는 settings 전용 path parser + `useSettingsPage`가 책임진다. +- service page/API는 같은 workspace context 우선순위 개념을 따르되, wire query key와 resolver 구현은 서비스가 소유한다. +- migration 호환을 위해 runtime은 legacy `workspaceId` query도 fallback으로 읽지만, 신규 링크와 문서는 `workspace_id`를 canonical로 사용한다. ## 구성 요소 -| 모듈 | 위치 | 역할 | -| ------------------------- | ----------------- | -------------------------------------------- | -| `useRouter` | `@/shared/router` | hash 경로 매칭, navigate, label 제공 | -| `SystemLayout` breadcrumb | `@/layouts` | `breadcrumb` prop으로 title 뒤에 경로명 표시 | +| 모듈 | 위치 | 역할 | +| --- | --- | --- | +| `useRouter` | `@/shared/router` | hash 경로 매칭, navigate, label 제공 | +| `normalizeLegacyPath` | `@/shared/router/legacy-path` | legacy pathname을 canonical `/console/*`, `/settings/*`로 정규화 | +| `resolveRouteDescriptor` | `@/shared/router/route-descriptor` | canonical path와 detail route 판정 | +| `resolveWorkspaceContextIdFromUrl` | `@/entities/workspace/scope` | `workspace_id` / `workspaceId` query 우선순위 해석 | +| `resolveRouteAccess` | `@/app/page-access` | canonical path 기준 page/menu/tab 공용 접근 판정 | +| `canAccessPage` | `@/app/page-access` | `resolveRouteAccess`의 boolean shortcut | +| `parseSettingsPath` + `useSettingsPage` | `@/pages/settings` | settings leaf parsing, platform-admin deep-link denial, fallback 결정 | +| console page shell | `@/layouts` | canonical 이름은 `ConsoleLayout`이고, 기존 `SystemLayout`은 compatibility alias다 | ## 사용법 ### 1. 라우트 정의 ```tsx -// label이 있는 라우트는 breadcrumb에 표시됨 -const ROUTES = ["/", { path: "/detail/:id", label: "Detail" }]; +const ROUTES = ['/', { path: '/detail/:id', label: 'Detail' }]; ``` ### 2. 라우터 연결 + 뷰 분기 ```tsx -import { useRouter } from "@/shared/router"; +import { useRouter } from '@/shared/router'; export function ExamplePage() { const router = useRouter(ROUTES); const route = router.route.value!; - if (route.path === "/detail/:id") { + if (route.path === '/detail/:id') { return ( - router.navigate("/")} + onBreadcrumbClick={() => router.navigate('/')} > - + ); } return ( - - router.navigate("/detail/" + item.id)} - /> - + + router.navigate('/detail/' + item.id)} /> + ); } ``` ### 3. URL 형태 -``` -/system/example/ → #/ → 목록 뷰 -/system/example/#/detail/abc → #/detail/abc → 상세 뷰 (breadcrumb: "Title › Detail") +```text +/console/example/ → #/ → 목록 뷰 +/console/example/#/detail/abc → #/detail/abc → 상세 뷰 ``` ## Standalone 보호 페이지 @@ -62,27 +87,37 @@ export function ExamplePage() { - `?standalone=true`로 직접 진입한 보호 페이지도 AppShell bootstrap 이후 같은 접근 판정을 사용한다. - `page-registry.ts`는 canonical path와 detail route를 pure하게 해석하고 lazy loader만 반환한다. - 권한/워크스페이스 정책 기반 접근 판단은 `page-access.ts`에서 수행한다. -- detail route 접근 여부는 canonical path 기준으로 평가한다. 예: `/system/workspaces/ws-1` → `/system/workspaces/` +- `page-access.ts`는 workspace scope resolver를 통해 query context를 읽는다. +- detail route 접근 여부는 canonical path 기준으로 평가한다. + - 예: `/console/workspaces/ws-1` → `/console/workspaces/` + +## Settings 계약 + +- settings leaf는 `/settings/account/*`, `/settings/platform/*`만 canonical path로 사용한다. +- legacy `/account/setting`은 호환 redirect일 뿐이고 신규 링크에서는 사용하지 않는다. +- `Workspace`는 공통 settings 그룹이 아니다. service가 필요할 때만 `workspace_id` query param으로 scope를 확장한다. +- settings shell은 pathname을 `account/platform` leaf로 parse하고, security 같은 허용된 하위 detail path만 별도 variant로 인정한다. +- 이 query param은 canonical route를 바꾸지 않는다. settings/router shell은 pathname만 정규화하고, app shell access/sidebar/tabs는 FE resolver를 사용하며 service page/API는 각 레이어가 자기 wire contract 안에서 같은 workspace context 우선순위 개념을 구현한다. ## API ### `useRouter(patterns)` -| 파라미터 | 타입 | 설명 | -| ---------- | ------------------------------- | ---------------- | +| 파라미터 | 타입 | 설명 | +| --- | --- | --- | | `patterns` | `(string \| { path, label })[]` | 라우트 패턴 목록 | 반환: `{ route, navigate, back, destroy }` -- `route.value.path` — 매칭된 패턴 (예: `/detail/:id`) -- `route.value.params` — 추출된 파라미터 (예: `{ id: 'abc' }`) -- `route.value.label` — 라우트 정의의 label (없으면 `undefined`) +- `route.value.path` — 매칭된 패턴 +- `route.value.params` — 추출된 파라미터 +- `route.value.label` — 라우트 정의의 label - `navigate(path)` — hash 변경으로 이동 - `back()` — `history.back()` -### `SystemLayout` breadcrumb +### shell breadcrumb -| Prop | 타입 | 설명 | -| ------------------- | ------------ | ---------------------------------------- | -| `breadcrumb` | `string?` | 있으면 title 뒤에 `›` 구분자와 함께 표시 | -| `onBreadcrumbClick` | `() => void` | title(첫 세그먼트) 클릭 시 호출 | +| Prop | 타입 | 설명 | +| --- | --- | --- | +| `breadcrumb` | `string?` | 있으면 title 뒤에 `›` 구분자와 함께 표시 | +| `onBreadcrumbClick` | `() => void` | title 클릭 시 호출 | diff --git a/docs/reference/frontend/rules.md b/docs/reference/frontend/rules.md index 58baee654..56c4282f9 100644 --- a/docs/reference/frontend/rules.md +++ b/docs/reference/frontend/rules.md @@ -297,10 +297,10 @@ ## 12) Component Mapping -- `Auth & Account`: `pages/login`, `pages/auth/password-change`, `pages/account/*`, `features/auth/*` -- `User & Access Admin`: `pages/system/users`, `pages/system/menus`, `features/users/*`, `features/menus/*`, `entities/user|role|menu` -- `Notification Admin`: `pages/system/notification-channels|notification-rules|email-templates|slack-templates`, `features/notifications/*`, `entities/notification-channel|notification-rule|email-template|slack-template` -- `Audit & Logs`: `pages/system/audit-logs|error-logs|login-history`, `features/logs/*` +- `Auth & Account`: `/settings/account/*`, `pages/login`, `pages/auth/password-change`, `pages/account/*`, `features/auth/*` +- `User & Access Admin`: `/console/users`, `/settings/platform/roles`, `/settings/platform/menus`, users/menu 관리 page module, `features/users/*`, `features/menus/*`, `entities/user|role|menu` +- `Notification Admin`: `/console/notification-*`, notification admin page module, `features/notifications/*`, `entities/notification-channel|notification-rule|email-template|slack-template` +- `Audit & Logs`: `/console/*logs*`, audit/log page module, `features/logs/*` - `Dashboard & Error Surface`: `pages/dashboard`, `pages/errors/*` - `Shell & Navigation`: `layouts/*`, `widgets/*` diff --git a/docs/reference/frontend/tabulator.md b/docs/reference/frontend/tabulator.md index 42dcdff83..59c4b06dd 100644 --- a/docs/reference/frontend/tabulator.md +++ b/docs/reference/frontend/tabulator.md @@ -33,7 +33,7 @@ - `useTabulator`를 페이지/feature에서 직접 호출하지 않는다 — `` 경유. - `shared/grid` public prop에 `ajaxURL`, `ajaxRequestFunc`, `pagination`, `paginationMode`, `paginationCounter`, raw `data`를 다시 노출하지 않는다. -참조: `pages/system/users/users.page.tsx`의 `` 사용 패턴. +참조: canonical route `/console/users`의 `` 사용 패턴. 사용자-facing shell은 `/console/*`다. ## 설계 원칙 @@ -74,7 +74,7 @@ ajaxRequestFunc: async (params) => { ### SHOULD -- iframe(system)과 standalone 동작을 동일 기능 계약으로 유지한다. → [`FE-TAB-005`](./rules.md) +- console iframe과 standalone 동작을 동일 기능 계약으로 유지한다. → [`FE-TAB-005`](./rules.md) - 버튼/더블클릭/모달 열기 동작은 iframe 여부와 무관하게 동일해야 한다. - Observer 콜백은 throttle/debounce로 연산량을 제한한다. → [`FE-TAB-006`](./rules.md) - resize/drag처럼 연속 이벤트: throttle diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md new file mode 100644 index 000000000..8c929938e --- /dev/null +++ b/docs/reference/glossary.md @@ -0,0 +1,110 @@ +# Deck 용어집 + +## 목적 + +platform reset 이후 문서와 코드가 같은 단어를 같은 의미로 사용하도록 공통 용어를 정의한다. + +## 핵심 용어 + +### Platform + +- Deck control-plane 전체를 뜻한다. +- 예전 `System` 용어를 대체한다. +- 전역 설정, 권한, 메뉴, workspace 정책은 모두 platform 범위다. + +### Console + +- 보호된 운영/업무 화면 shell이다. +- canonical route prefix는 `/console/*`이다. +- dashboard, users, workspaces, service pages는 console 아래에 둔다. +- service business page의 canonical path는 `/console/{menu_route}`다. +- `/console/deskpie/*`, `/console/meetpie/*`처럼 서비스명을 pathname prefix로 다시 넣지 않는다. + +### Settings + +- 보호된 설정 화면 shell이다. +- canonical route prefix는 `/settings/*`이다. +- 공통 설정 축은 `account`, `platform` 두 가지다. +- `Workspace`는 공통 settings 그룹이 아니다. + +### Standalone Public Route + +- authenticated shell 바깥의 공개 또는 standalone 화면이다. +- 서비스가 직접 소유하며 `/console/*`, `/settings/*` 계약에 포함되지 않는다. +- 예: `/book/*`, `/namecards/*`, `/widget-previews/*`, `/legal/*` + +### Account + +- 현재 로그인한 사용자 자신의 설정 범위다. +- canonical route는 `/settings/account/*`이다. + +### Workspace + +- app(control-plane)가 소유하는 협업 단위다. +- 공통 tenant shell이 아니며, service가 필요할 때만 `workspace_id` query param으로 scope를 확장한다. +- shared router는 `workspace_id`를 route identity로 해석하지 않고 그대로 보존한다. +- app shell의 page access, sidebar visibility, tab restore/open은 FE 공용 workspace context resolver로 `workspace_id`를 읽는다. +- service page/API는 같은 workspace context 우선순위 개념을 따르되, 실제 wire query key는 서비스가 소유한다. +- 한 사용자는 여러 workspace에 동시에 속할 수 있다. +- internal workspace만 local owner invariant를 가진다. + +### External Workspace + +- `externalReference != null`인 workspace다. +- 현재는 AIP 외부 조직과 1:1로 매핑한다. +- `externalReference != null`이면 `PLATFORM_MANAGED`여야 한다. +- Deck UI와 service 경계 모두에서 identity, membership, invite mutation을 직접 허용하지 않는다. +- local owner 없이 read-only membership만 가질 수 있다. + +### External Reference + +- 외부 시스템과의 매핑 식별자다. +- 현재 최소 shape는 `source + externalId`다. +- v1의 `source`는 `AIP` 하나만 사용한다. +- `externalId`는 source 시스템의 실제 조직 ID다. 현재는 AIP 조직 ID다. + +### ManagementType + +- 공용 관리 타입 enum이다. +- 값은 `USER_MANAGED`, `PLATFORM_MANAGED` 두 가지다. +- workspace와 menu가 같은 타입을 공유한다. + +### USER_MANAGED + +- Deck 사용자가 직접 관리하는 리소스를 뜻한다. +- internal workspace와 일반 메뉴가 여기에 해당한다. + +### PLATFORM_MANAGED + +- Deck platform이 관리하는 고정 리소스를 뜻한다. +- external workspace와 platform 전용 메뉴가 여기에 해당한다. +- menu에서는 일반 사용자에게 노출하지 않고 platform admin에게만 보여준다. +- workspace와 menu가 같은 enum 값을 공유하지만, 상세 동작은 각 도메인이 해석한다. + +### Platform Admin + +- platform 전역 운영 권한을 가진 사용자다. +- `PLATFORM_MANAGED` menu의 runtime visibility 판단 주체다. +- workspace owner와는 별개 개념이며, menu/runtime authorization에서만 사용한다. +- 현재 session/account payload에는 legacy field name인 `isOwner`로 내려오지만, 의미는 platform admin bit다. + +### Workspace Policy + +- platform settings가 관리하는 workspace 기능 계약이다. +- `PlatformSettingEntity.workspacePolicy`가 backend SSOT다. +- service별 workspace 요구 여부와 selector 노출 규칙을 제어한다. +- `usePlatformManaged`는 `PLATFORM_MANAGED` workspace 노출을 제어한다. +- `useExternalSync`는 AIP claim 기반 external organization flow 전체 enablement를 제어한다. +- `usePlatformManaged = false`이면 `useExternalSync = false`로 normalize하는 것이 현재 v1 규칙이다. + +### AIP Sync + +- OAuth 로그인 이후 JWT의 외부 조직 정보를 기준으로 workspace와 membership을 동기화하는 흐름이다. +- external sync 사용자는 별도 전용 화면이 아니라 일반 AIP OAuth 로그인으로 진입한다. +- sync 호출 흐름은 `AuthService -> OAuthLoginProvisioningService -> UserService -> ExternalWorkspaceSyncService`다. +- 같은 `source + externalId`는 같은 external workspace에 매핑한다. +- 한 사용자가 여러 외부 조직에 속하면 여러 workspace membership을 동시에 가진다. +- 현재 v1은 AIP를 유일한 external source로 가정한다. +- OAuth extractor와 auth 경계는 `ExternalOrganizationSync.NoSync`, `Unavailable`, `AuthoritativeSnapshot`을 구분한다. +- `AuthoritativeSnapshot`일 때만 `ExternalWorkspaceSyncService`가 reconciliation을 수행한다. +- 따라서 이번 로그인 claim이 authoritative snapshot으로 전달된 경우에만, 같은 source 범위에서 claim에 없는 external organization membership이 제거 대상이다. diff --git a/docs/reference/legal-pages.md b/docs/reference/legal-pages.md index 0225c7d79..84dc61a2a 100644 --- a/docs/reference/legal-pages.md +++ b/docs/reference/legal-pages.md @@ -61,10 +61,10 @@ frontend/app/src/pages/legal/content/{service}/{document}.{locale}.md ### MUST - `brandName`은 공개 `/auth/config` 응답을 사용한다. -- `contactEmail`은 `system_settings.contact_email` 값을 사용한다. +- `contactEmail`은 `platform_settings.contact_email` 값을 사용한다. - 값이 비어 있으면 안전한 기본값으로 fallback 한다. -## System Settings 연동 +## Platform Settings 연동 공개 legal 문서에 사용하는 연락처는 `Settings > General`의 `Contact Email` 필드로 관리한다. diff --git a/docs/reference/meetpie.md b/docs/reference/meetpie.md index 0a3fe5281..4942b8e7b 100644 --- a/docs/reference/meetpie.md +++ b/docs/reference/meetpie.md @@ -8,7 +8,7 @@ FE 공통 규칙은 [`frontend.md`](./frontend.md), BE 공통 규칙은 [`backen ## Scope - **도메인**: 명함, 일정, 관계 인텔리전스, 예약(Booking) -- **경계**: workspace_id 스코프의 비즈니스 전용 로직만 배치. 인증·레이아웃·사용자 관리는 app(control-plane)에 둔다. +- **경계**: meetpie 고유 비즈니스 로직만 배치한다. workspace가 필요한 기능은 `workspace_id`를 선택적으로 사용하고, workspace가 필요 없는 기능도 같은 모듈 안에 공존할 수 있다. 인증·레이아웃·사용자 관리는 app(control-plane)에 둔다. - **FE**: `frontend/meetpie/src/` — FSD(`pages/`, `features/`, `entities/`) - **BE**: `backend/meetpie/` — DDD 레이어(`controller/`, `service/`, `domain/`, `repository/`) - **실행**: FE `cd frontend/meetpie && pnpm dev`, BE `./gradlew :dist:meetpie:bootRun` @@ -63,7 +63,7 @@ toastSuccess('스케줄이 생성됐습니다.'); ### MUST -- 페이지는 `SystemLayout`으로 감싼다 (`title`, `icon`, `loading`, `actions` props). +- console shell 안의 페이지는 `ConsoleLayout`으로 감싼다 (`title`, `icon`, `loading`, `actions` props). 기존 `SystemLayout`은 compatibility alias로만 본다. - 비즈니스 로직은 co-located 훅(`use-{page}.ts`)으로 분리한다. - 모달 폼은 `useModal`의 `content` prop에 별도 컴포넌트로 전달한다. @@ -77,7 +77,8 @@ toastSuccess('스케줄이 생성됐습니다.'); ### MUST - 메뉴 등록은 `ProgramRegistrar`에 `ProgramDefinition(code, path, permissions, workspace = ...)`를 추가한다. -- workspace가 전제인 page는 `ProgramDefinition.WorkspacePolicy(required = true)`를 선언한다. +- meetpie service page/program canonical path는 `/console/{menu_route}`를 사용한다. +- workspace가 전제인 page만 `ProgramDefinition.WorkspacePolicy(required = true)`를 선언한다. - 새 도메인 엔티티는 Delete 전략에 따라 `SoftDeleteEntity` 또는 `HardDeleteEntity`를 상속한다. Soft Delete 시 `@SQLRestriction`을 적용한다. - 모든 `@*Mapping` 핸들러에 `@PreAuthorize`를 명시한다. @@ -89,7 +90,7 @@ toastSuccess('스케줄이 생성됐습니다.'); - [ ] UI 메시지가 영어로 작성되었는가 - [ ] `toastError`에 하드코딩 문자열이 없는가 (FE-ERR-001) -- [ ] 페이지가 `SystemLayout`으로 감싸져 있는가 +- [ ] console shell 페이지가 `ConsoleLayout`으로 감싸져 있는가 - [ ] 변환 로직에 단위 테스트가 있는가 - [ ] `ProgramRegistrar`에 메뉴가 등록되었는가 - [ ] `check-patterns.sh` 위반이 0건인가 diff --git a/docs/reference/workspace.md b/docs/reference/workspace.md index 3bfb4e618..f69345926 100644 --- a/docs/reference/workspace.md +++ b/docs/reference/workspace.md @@ -2,56 +2,84 @@ ## 목적 -workspace 모듈(app control-plane) 개발 시 FE/BE 공통 기준을 정의한다. -FE 공통 규칙은 [`frontend.md`](./frontend.md), BE 공통 규칙은 [`backend.md`](./backend.md)를 추가로 따른다. +workspace 모듈(app control-plane) 개발 시 FE/BE 공통 기준을 정의한다. FE 공통 규칙은 [`frontend.md`](./frontend.md), BE 공통 규칙은 [`backend.md`](./backend.md)를 추가로 따른다. +공통 용어는 [`glossary.md`](./glossary.md)를 우선 참고한다. ## Scope -- **도메인**: workspace CRUD, 멤버 관리, 초대, ownership, workspace 정책 -- **경계**: workspace 자체는 app(control-plane)가 소유한다. deskpie는 활성 workspace를 전제로 동작하고, meetpie는 조직 맥락으로 workspace를 사용할 수 있지만 program 진입 자체를 막지는 않는다. -- **FE**: `frontend/app/src/entities/workspace/` + `features/workspaces/` -- **BE**: `backend/iam/` + `backend/app/` +- 도메인: workspace CRUD, 멤버 관리, 초대, ownership, workspace 정책, AIP sync foundation +- 경계: workspace는 app(control-plane)가 소유한다. 공통 tenant shell이 아니며, service가 필요할 때만 `workspace_id` query param으로 scope를 확장한다. +- service 해석 + - `deskpie`: active workspace가 필요할 수 있다 + - `meetpie`: 기본적으로 workspace 없이도 동작할 수 있다 +- FE: `frontend/app/src/entities/workspace/` + `features/workspaces/` +- BE: `backend/iam/` + `backend/app/` ## 핵심 모델 - 사용자는 `0..N`개의 workspace에 속할 수 있다. -- 가입 시 기본 `USER_MANAGED` workspace를 생성할 수 있지만, 유지 의무는 없다. -- workspace는 항상 owner를 최소 1명 유지해야 한다. -- workspace 타입은 `USER_MANAGED`, `SYSTEM_MANAGED` 두 가지다. -- owner setting의 `WorkspacePolicy?`가 `null`이면 workspace 기능 전체를 끈 것으로 해석한다. +- internal workspace는 owner를 최소 1명 유지해야 한다. +- external workspace는 local owner 없이 read-only membership만 가질 수 있다. +- workspace 관리 타입은 `USER_MANAGED`, `PLATFORM_MANAGED` 두 가지다. +- `Workspace.externalReference`의 저장 키는 `source + externalId` 조합이다. +- backend API와 frontend DTO도 같은 `source + externalId` shape를 그대로 노출한다. +- 현재 v1의 `Workspace.externalReference.source`는 `AIP` 하나만 사용한다. +- `Workspace.externalReference.externalId`는 AIP의 실제 조직 ID다. +- `externalReference != null`인 workspace는 external workspace이며 반드시 `PLATFORM_MANAGED`여야 한다. +- external workspace의 identity와 membership은 Deck UI와 service 경계에서 직접 수정하지 않는다. +- `PlatformSettingEntity.workspacePolicy == null`이면 workspace 기능 전체를 끈 것으로 해석한다. ## 아키텍처 개요 -``` -[Owner Setting] [관리자 페이지] [사용자 페이지] -SystemSettingController WorkspaceController MyWorkspaceController - └─ WorkspacePolicy └─ WORKSPACE_MANAGEMENT_* └─ MY_WORKSPACE_* - - ↓ ↓ ↓ - WorkspaceService ← WorkspaceMemberService ← WorkspaceInviteService - ↓ - WorkspaceEntity ← WorkspaceMemberEntity +```text +[Platform Settings] [관리자 페이지] [사용자 페이지] +PlatformSettingController WorkspaceController MyWorkspaceController + └─ WorkspacePolicy └─ WORKSPACE_MANAGEMENT_* └─ MY_WORKSPACE_* + + ↓ ↓ ↓ + WorkspaceService ← WorkspaceMemberService ← WorkspaceInviteService + ↓ + WorkspaceEntity ← WorkspaceMemberEntity + +[OAuth Login] +OAuth2AuthenticationSuccessHandler → AuthService → OAuthLoginProvisioningService → UserService → ExternalWorkspaceSyncService + +[App Shell Workspace Context] +shared/router/legacy-path.ts → pathname canonicalization only +entities/workspace/scope.ts → workspace_id / workspaceId resolver +app/page-access.ts → `resolveRouteAccess` 공용 page/menu guard +app/tabs.ts + widgets/sidebar → 같은 access contract를 재사용하는 tab/sidebar restore and visibility +pages/settings/settings-path.ts → settings leaf path parser + detail route contract ``` ## Workspace Policy Contract ### MUST -- backend는 `SystemSettingEntity.workspacePolicy`를 SSOT로 사용한다. +- backend는 `PlatformSettingEntity.workspacePolicy`를 SSOT로 사용한다. - `workspacePolicy == null`이면 workspace 기능 전체를 비활성화한다. -- program metadata는 `ProgramDefinition.WorkspacePolicy(required, managedType)`로 선언한다. +- program metadata는 `ProgramDefinition.WorkspacePolicy(required, requiredManagedType, selectionRequired)`로 선언한다. +- `requiredManagedType`은 어떤 workspace capability가 필요한지 표현하고, `selectionRequired`는 현재 활성 workspace context가 필요한지를 표현한다. - workspace가 필요한 page/program은 정책상 비활성화되면 메뉴에서 숨기고 직접 URL 접근도 `404`로 처리한다. +- service는 필요할 때만 active workspace scope를 요구한다. +- shared router는 `workspace_id`를 route identity로 해석하지 않고 pathname canonicalization만 담당한다. +- app shell access layer는 공용 workspace context resolver로 `workspace_id` query를 우선 사용하고, 없으면 현재 선택된 workspace를 fallback으로 사용한다. +- route guard, sidebar visibility, tab restore/open은 `resolveRouteAccess` 공용 계약을 재사용한다. +- `/settings/*` leaf denial/fallback은 `parseSettingsPath` + `useSettingsPage`가 page-local contract로 처리한다. +- service page/API는 같은 workspace context 우선순위 개념을 따르되, wire query key는 서비스가 소유한다. app shell 문서상의 canonical key는 `workspace_id`지만 현재 service API는 legacy `workspaceId`를 유지할 수 있다. +- migration 호환을 위해 runtime helper는 legacy `workspaceId` query도 fallback으로 읽지만, 신규 링크와 문서는 `workspace_id`를 canonical로 사용한다. ### MUST NOT - frontend가 workspace 정책을 하드코딩으로 추론하지 않는다. - workspace가 필요한 page를 menu hide만으로 차단하지 않는다. +- `Workspace`를 공통 `/settings/workspace/*` 그룹으로 가정하지 않는다. ## 권한 모델 Contract ### MUST -- 관리 액션 가능 여부는 시스템 권한으로 판단한다. +- 관리 액션 가능 여부는 platform permission으로 판단한다. - workspace owner 여부는 접근 권한이 아니라 도메인 무결성 검증에만 사용한다. - 관리자 컨트롤러는 `WORKSPACE_MANAGEMENT_READ/WRITE` 권한을 사용한다. - 사용자 컨트롤러는 `MY_WORKSPACE_READ/WRITE` 권한을 사용한다. @@ -64,48 +92,44 @@ SystemSettingController WorkspaceController MyWorkspaceC ### MUST -- workspace는 owner를 최소 1명 유지해야 한다. +- internal workspace는 owner를 최소 1명 유지해야 한다. - owner 승격 대상은 `UserStatus.isNormal()`을 만족하는 현재 멤버여야 한다. -- 마지막 owner는 `leave`, 멤버 제거, 사용자 삭제, 탈퇴로 제거할 수 없다. +- internal workspace의 마지막 owner는 `leave`, 멤버 제거, 사용자 삭제, 탈퇴로 제거할 수 없다. - 마지막 owner를 제거하려면 먼저 다른 멤버에게 owner를 넘기거나 workspace를 삭제해야 한다. - 소유자 변경 시 새 owner가 멤버가 아니면 자동으로 멤버에 추가한다. +- external workspace는 local owner invariant를 두지 않는다. ### MUST NOT -- owner가 0명인 workspace를 저장하지 않는다. +- owner가 0명인 internal workspace를 저장하지 않는다. - `INACTIVE`, `LOCKED`, `DORMANT`, `PENDING`, `INVITED` 사용자에게 owner를 부여하지 않는다. - owner 상태를 `workspace_members.is_owner` 밖의 별도 컬럼으로 이중 관리하지 않는다. -### 설명 - -- `normal`은 사용자 상태 enum의 의미이며, 현재 기준으로 `ACTIVE`만 정상 상태다. -- `passwordMustChange`는 로그인 후 후속 절차 플래그이며 owner 승격 기준에는 포함하지 않는다. - -## Membership / Withdraw Contract +## Membership / External Contract ### MUST -- 사용자는 `USER_MANAGED`, `SYSTEM_MANAGED` 어느 타입이든 workspace에서 나갈 수 있다. -- 단, 마지막 owner의 `leave`는 차단하고 대체 액션을 안내해야 한다. -- 사용자 삭제/탈퇴 시 owner가 아닌 membership은 제거한다. -- 사용자 삭제/탈퇴 시 마지막 owner인 workspace가 하나라도 있으면 작업을 차단한다. +- internal workspace(`externalReference == null`)만 Deck에서 멤버 추가/제거, 초대, self-withdraw를 허용한다. +- external workspace는 membership mutation을 AIP sync 결과로만 반영한다. +- controller/service mutation entrypoint는 UI를 신뢰하지 말고 service 경계에서 external workspace를 다시 차단한다. +- 사용자 삭제/탈퇴 시 owner가 아닌 internal membership은 제거할 수 있다. +- 사용자 삭제/탈퇴 시 마지막 owner인 internal workspace가 하나라도 있으면 작업을 차단한다. -### SHOULD +### MUST NOT -- 마지막 owner 차단 메시지는 다음 액션을 함께 안내한다. - - 다른 멤버에게 owner 넘기기 - - workspace 삭제 +- external workspace에서 수동 invite, accept, cancel, resend를 허용하지 않는다. +- external workspace에서 수동 멤버 제거나 owner 이전을 허용하지 않는다. ## My Workspace Contract ### MUST - `My Workspace`는 내가 속한 모든 workspace를 보여준다. -- 초대받은 `SYSTEM_MANAGED` workspace도 목록에 보여준다. -- member는 목록에 보이더라도 상세 화면으로 진입하지 못하게 막는다. +- `PLATFORM_MANAGED` 또는 external workspace도 목록에 보여준다. - owner와 member의 액션은 다르게 보여준다. - - owner: 타입과 권한에 맞는 관리 액션 - - member: 나가기 + - internal owner: 타입과 권한에 맞는 관리 액션 + - internal member: 나가기 + - external membership: 읽기 전용 안내 ### MUST NOT @@ -120,38 +144,49 @@ SystemSettingController WorkspaceController MyWorkspaceC - invite 수락 시 기존 사용자는 membership만 추가하고, 비회원은 계정 생성 후 membership을 추가한다. - 비회원 invite로 생성되는 사용자는 명시 role이 없으면 현재 default role을 부여받는다. - role은 정확히 1개의 default를 유지해야 한다. -- 관리자 workspace와 `My Workspace`의 invite 정책은 동일해야 한다. +- invite validation은 missing workspace, external workspace, 이미 멤버인 상태를 accept 전에 invalid로 확정해야 한다. ### MUST NOT +- external workspace invite 경로를 열어두지 않는다. - invite event를 발행만 하고 notification channel과 분리된 상태로 남겨두지 않는다. -- batch invite에서 관리자/사용자 경로마다 이메일 정규화 정책을 다르게 두지 않는다. - workspace invite 신규 가입 경로에 특정 role 이름을 하드코딩하지 않는다. -## 삭제 Contract +## AIP Sync Contract ### MUST -- 모든 삭제는 확인 절차를 거친다. -- 관리자 권한이 있는 사용자는 정책과 권한에 맞는 workspace 삭제를 수행할 수 있다. -- 삭제 전 마지막 owner 규칙을 먼저 검증한다. +- OAuth 로그인 성공 후 JWT의 외부 조직 목록을 읽어 workspace sync를 수행한다. +- external sync 사용자는 별도 전용 로그인 경로 없이 일반 AIP OAuth 로그인 경로를 사용한다. +- sync 호출 흐름은 `AuthService -> OAuthLoginProvisioningService -> UserService -> ExternalWorkspaceSyncService`다. +- `UserService`는 `workspacePolicy.useExternalSync`를 AIP external organization flow 전체 enablement로 해석하고, approval/policy 판단을 조합한다. +- 실제 workspace upsert와 membership reconciliation은 `ExternalWorkspaceSyncService`가 담당한다. +- auth 경계는 `ExternalOrganizationSync.NoSync`, `Unavailable`, `AuthoritativeSnapshot`을 구분한다. +- 같은 `source + externalId` 조합은 기존 external workspace를 재사용한다. +- 매칭되는 workspace가 없으면 생성한다. +- 같은 사용자가 여러 외부 조직에 속할 수 있으므로 여러 workspace membership을 동시에 가질 수 있어야 한다. +- `AuthoritativeSnapshot`일 때만 AIP claim 목록을 authoritative full snapshot으로 해석한다. +- 따라서 이번 로그인 claim이 authoritative snapshot으로 전달된 경우에만, 같은 `source` 범위에서 claim에 없는 external workspace membership이 제거 대상이다. +- external workspace identity와 membership은 sync service만 authoritative하게 바꾼다. -### SHOULD +### MUST NOT -- 삭제 확인 UI에서 영향 범위를 함께 보여준다. - - 멤버 수 - - owner 수 - - 복구 불가 여부 +- external workspace의 이름/설명/멤버십을 Deck 수동 편집 결과와 이중 source of truth로 두지 않는다. ## FE Contract ### MUST -- `store.ts`의 visible workspace 목록은 `workspacePolicy`와 `managedType`으로 필터링한다. +- `store.ts`의 visible workspace 목록은 `workspacePolicy`와 각 workspace의 `managedType`으로 필터링한다. - `currentWorkspaceId`는 filtered workspace 기준으로만 계산한다. - `useSelector=false`거나 filtered workspace가 `0`개면 sidebar는 horizontal logo를 표시한다. - workspace가 필요한 program은 활성 workspace가 없으면 menu에서 숨긴다. - direct URL 진입도 route guard에서 다시 검증한다. +- route guard는 `workspace_id` query가 있으면 이를 우선 사용하고, 없으면 `currentWorkspaceId`를 fallback으로 사용한다. +- service는 필요할 때만 workspace scope query를 읽는다. 현재 deskpie HTTP contract는 `workspaceId`를 유지하고, app shell canonical link는 `workspace_id`를 사용한다. +- router canonicalization은 pathname만 담당하고 `workspace_id` query 자체는 opaque하게 보존한다. +- shell 내부의 sidebar visibility와 tab restore도 동일한 workspace context resolver를 재사용한다. +- tab dedupe는 `workspace_id` canonical key와 legacy `workspaceId` fallback을 같은 scope로 취급해야 한다. ### SHOULD @@ -163,38 +198,42 @@ SystemSettingController WorkspaceController MyWorkspaceC - deskpie처럼 workspace가 전제인 program은 `workspace.required = true`를 선언한다. - meetpie program은 기본적으로 `workspace.required = false`로 유지한다. -- `managedType`이 필요한 page만 `USER_MANAGED` 또는 `SYSTEM_MANAGED`를 명시한다. -- 일반 business page는 `required = true`, `managedType = null`로 두고 현재 활성 workspace만 요구한다. +- service business program path는 canonical `/console/{menu_route}`를 사용한다. +- 서비스 이름을 pathname prefix로 다시 넣지 않는다. +- `requiredManagedType`이 필요한 page만 `USER_MANAGED` 또는 `PLATFORM_MANAGED`를 명시한다. +- 일반 business page는 `required = true`, `requiredManagedType = null`, `selectionRequired = true`로 두고 현재 활성 workspace만 요구한다. +- cross-workspace page는 `required = true`, `requiredManagedType = null`, `selectionRequired = false`로 둔다. ### 예시 -- `/system/workspaces`: `required = true`, `managedType = SYSTEM_MANAGED` -- `/my-workspaces`: `required = true`, `managedType = null` -- deskpie business pages: `required = true`, `managedType = null` +- `/console/workspaces`: `required = true`, `requiredManagedType = PLATFORM_MANAGED`, `selectionRequired = false` +- `/console/my-workspaces`: `required = true`, `requiredManagedType = null`, `selectionRequired = false` +- deskpie business pages: `required = true`, `requiredManagedType = null`, `selectionRequired = true` - meetpie business pages: 기본적으로 `required = false` ## API 클라이언트 Contract | 클라이언트 | 엔드포인트 | 용도 | -|-----------|-----------|------| +| --- | --- | --- | | `workspaceApi` | `/api/v1/workspaces` | 관리자 페이지 | | `myWorkspaceApi` | `/api/v1/my-workspaces` | 사용자 페이지 | +| `platformSettingsApi` | `/api/v1/platform-settings/*` | workspace policy, auth, branding | ### MUST - 관리자/사용자 API 클라이언트를 분리한다. -- system setting은 섹션별 엔드포인트로 관리한다. - - `/api/v1/system-settings/general` - - `/api/v1/system-settings/workspace-policy` - - `/api/v1/system-settings/auth` - - `/api/v1/system-settings/auth/providers` +- workspace policy는 platform settings 섹션 엔드포인트로 관리한다. + - `/api/v1/platform-settings/general` + - `/api/v1/platform-settings/workspace-policy` + - `/api/v1/platform-settings/auth` + - `/api/v1/platform-settings/auth/providers` ## Done Checklist - [ ] `workspacePolicy == null`일 때 menu hide + direct URL `404`가 동작하는가 - [ ] 마지막 owner의 leave/remove/delete/withdraw가 차단되는가 +- [ ] external workspace의 invite/member mutation이 차단되는가 - [ ] `My Workspace`가 전체 membership을 보여주는가 -- [ ] member 상세 진입이 차단되는가 - [ ] owner 상태가 `workspace_members.is_owner`만으로 일관되게 관리되는가 - [ ] default role이 정확히 1개 유지되고, 비회원 workspace invite가 이 값을 사용하는가 - [ ] deskpie만 `workspace.required = true`이고 meetpie는 열려 있는가 diff --git a/frontend/app/package.json b/frontend/app/package.json index f570f6ed0..426c3462d 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -27,7 +27,7 @@ "./components/ui/tooltip": "./src/components/ui/tooltip.tsx", "./config/tabulator": "./src/config/tabulator/index.ts", "./entities/business-registration": "./src/entities/business-registration/index.ts", - "./entities/system-settings": "./src/entities/system-settings/index.ts", + "./entities/platform-settings": "./src/entities/platform-settings/index.ts", "./entities/workspace": "./src/entities/workspace/index.ts", "./features/auth": "./src/features/auth/index.ts", "./layouts": "./src/layouts/index.ts", @@ -74,6 +74,8 @@ "./shared/party": "./src/shared/party/index.ts", "./shared/party/contact-field-config": "./src/shared/party/contact-field-config.ts", "./shared/runtime": "./src/shared/runtime/index.ts", + "./shared/router": "./src/shared/router/index.ts", + "./shared/router/console-path": "./src/shared/router/console-path.ts", "./shared/search-input": "./src/shared/search-input/index.ts", "./shared/spinner": "./src/shared/spinner/index.ts", "./shared/splitter": "./src/shared/splitter/index.ts", @@ -110,7 +112,7 @@ "test": "vitest", "test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml", "test:run": "vitest run", - "test:branding": "vitest run src/shared/lib/branding.test.tsx src/pages/account/setting/branding-tab.test.tsx src/pages/account/setting/setting.page.test.tsx && (cd ../../backend && ./gradlew :iam:test --tests \"io.deck.iam.service.SystemSettingServiceTest\")", + "test:branding": "vitest run src/shared/lib/branding.test.tsx src/pages/settings/tabs/branding-tab.test.tsx && (cd ../../backend && ./gradlew :iam:test --tests \"io.deck.iam.service.PlatformSettingServiceTest\")", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/frontend/app/playwright.account.config.ts b/frontend/app/playwright.account.config.ts new file mode 100644 index 000000000..bbc91cf05 --- /dev/null +++ b/frontend/app/playwright.account.config.ts @@ -0,0 +1,9 @@ +import baseConfig from './playwright.config'; +import { defineConfig } from '@playwright/test'; + +const base = baseConfig; + +export default defineConfig({ + ...base, + testIgnore: (base.testIgnore ?? []).filter((pattern) => pattern !== '**/account/**'), +}); diff --git a/frontend/app/playwright.config.ts b/frontend/app/playwright.config.ts index 7131e2813..7eeebe05c 100644 --- a/frontend/app/playwright.config.ts +++ b/frontend/app/playwright.config.ts @@ -12,12 +12,8 @@ export default defineConfig({ '**/manual/**', '**/account/**', '**/password-change.spec.ts', - '**/system/logs.spec.ts', '**/system/menus.spec.ts', - '**/system/notification-management.spec.ts', '**/system/sidebar.spec.ts', - '**/system/templates.spec.ts', - '**/system/users.spec.ts', ], fullyParallel: true, forbidOnly: isCI, diff --git a/frontend/app/src/app/App.tsx b/frontend/app/src/app/App.tsx index d8fdc9c2d..c4942a62b 100644 --- a/frontend/app/src/app/App.tsx +++ b/frontend/app/src/app/App.tsx @@ -33,6 +33,7 @@ import { CommandPaletteProvider } from './command-palette/CommandPaletteProvider import { AuthorizationProvider } from '#app/shared/authorization'; import { auth } from '#app/features/auth'; import { OverlayProvider } from '#app/shared/overlay'; +import { normalizeLegacyPath } from '#app/shared/router/legacy-path'; const LoginPage = lazy(() => import('#app/pages/login/login.page')); const InvitePage = lazy(() => import('#app/pages/invite/invite.page')); @@ -48,7 +49,6 @@ const PrivacyPage = lazy(() => import('#app/pages/legal/privacy.page')); const TermsPage = lazy(() => import('#app/pages/legal/terms.page')); const ServiceLegalPage = lazy(() => import('#app/pages/legal/service-legal.page')); const SettingsPage = lazy(() => import('#app/pages/settings/settings.page')); -const SettingPage = lazy(() => import('#app/pages/account/setting/setting.page')); const ForbiddenPage = lazy(() => import('#app/pages/errors/403/forbidden.page')); const NotFoundPage = lazy(() => import('#app/pages/errors/404/not-found.page')); const ServerErrorPage = lazy(() => import('#app/pages/errors/500/server-error.page')); @@ -114,12 +114,23 @@ function AppShellRoute() { const isStandaloneMode = searchParams.get('standalone') === 'true'; if (isStandaloneMode) { - return ; + return ; } return ; } +function LegacyPathRedirect() { + const location = useLocation(); + + return ( + + ); +} + function useAppShellBootstrap(enabled: boolean) { useEffect(() => { if (!enabled) return; @@ -128,7 +139,7 @@ function useAppShellBootstrap(enabled: boolean) { }, [enabled]); } -function StandalonePageRoute({ pathname }: { pathname: string }) { +function StandalonePageRoute({ url }: { url: string }) { const initialized = isInitialized.useStore(); const loading = isLoading.useStore(); @@ -139,7 +150,7 @@ function StandalonePageRoute({ pathname }: { pathname: string }) { return ; } - const Page = getAccessiblePage(pathname); + const Page = getAccessiblePage(url); return ( }> @@ -160,9 +171,20 @@ const standaloneRoutes: RouteObject[] = [ { path: '/legal/privacy/*', element: withStandaloneFallback() }, { path: '/legal/terms/*', element: withStandaloneFallback() }, { path: '/legal/:serviceId/:documentId', element: withStandaloneFallback() }, + { path: '/settings', element: }, + { path: '/settings/account', element: }, + { path: '/settings/platform', element: }, + { path: '/settings/system/*', element: }, { path: '/settings/*', element: withStandaloneFallback() }, - { path: '/account/profile/*', element: }, - { path: '/account/setting/*', element: withStandaloneFallback() }, + { path: '/system/*', element: }, + { path: '/dashboard', element: }, + { path: '/dashboard/*', element: }, + { path: '/account/profile/*', element: }, + { path: '/account/setting/*', element: }, + { path: '/my-workspaces', element: }, + { path: '/my-workspaces/*', element: }, + { path: '/console/menus', element: }, + { path: '/console/menus/*', element: }, { path: '/error/403', element: withStandaloneFallback() }, { path: '/error/404', element: withStandaloneFallback() }, { path: '/error/500', element: withStandaloneFallback() }, diff --git a/frontend/app/src/app/app.test.tsx b/frontend/app/src/app/app.test.tsx index bda89d9fb..85fe6e76a 100644 --- a/frontend/app/src/app/app.test.tsx +++ b/frontend/app/src/app/app.test.tsx @@ -4,7 +4,7 @@ import { App, contextMenu, user, theme, isInitialized, isLoading } from './App'; import { registerRoutes, clearRoutes, getExtraRoutes } from './route-registry'; import { tabs, activeTabId } from '#app/widgets/tabbar'; import { sidebarCollapsed, setPrograms } from '#app/widgets/sidebar'; -import { setSystemSettings } from '#app/entities/system-settings'; +import { setPlatformSettings } from '#app/entities/platform-settings'; import { currentWorkspaceId } from '#app/entities/workspace'; import { clearSession, setSession } from '#app/features/auth'; import type { Meta } from '#app/shared/meta'; @@ -145,7 +145,7 @@ describe('App overlay dismiss 통합', () => { activeTabId.set(null); sidebarCollapsed.set(false); setPrograms([]); - setSystemSettings(null); + setPlatformSettings(null); currentWorkspaceId.set(''); clearSession(); isInitialized.set(false); @@ -322,6 +322,140 @@ describe('App overlay dismiss 통합', () => { expect(container.querySelector('[data-settings-nav]')).not.toBeNull(); }); + it('/account/setting legacy 경로는 settings platform general로 리다이렉트되어야 함', async () => { + window.history.replaceState({}, '', '/account/setting'); + + const { container } = render(); + + await waitFor( + () => { + expect(window.location.pathname).toBe('/settings/platform/general'); + expect(container.textContent).toContain('General'); + expect(container.textContent).toContain('Platform'); + }, + { timeout: 5000 } + ); + expect(container.querySelector('[data-settings-nav]')).not.toBeNull(); + }); + + it('/settings root 경로는 account profile canonical leaf로 리다이렉트되어야 함', async () => { + window.history.replaceState({}, '', '/settings'); + + const { container } = render(); + + await waitFor( + () => { + expect(window.location.pathname).toBe('/settings/account/profile'); + expect(container.textContent).toContain('Profile'); + }, + { timeout: 5000 } + ); + }); + + it('/settings/platform 경로는 platform general canonical leaf로 리다이렉트되어야 함', async () => { + window.history.replaceState({}, '', '/settings/platform'); + + const { container } = render(); + + await waitFor( + () => { + expect(window.location.pathname).toBe('/settings/platform/general'); + expect(container.textContent).toContain('General'); + expect(container.textContent).toContain('Platform'); + }, + { timeout: 5000 } + ); + }); + + it('/settings/system/general legacy 경로는 platform general canonical leaf로 리다이렉트되어야 함', async () => { + window.history.replaceState({}, '', '/settings/system/general'); + + const { container } = render(); + + await waitFor( + () => { + expect(window.location.pathname).toBe('/settings/platform/general'); + expect(container.textContent).toContain('General'); + expect(container.textContent).toContain('Platform'); + }, + { timeout: 5000 } + ); + }); + + it('/account/setting/auth legacy 경로는 settings platform authentication으로 리다이렉트되어야 함', async () => { + window.history.replaceState({}, '', '/account/setting/auth'); + + const { container } = render(); + + await waitFor( + () => { + expect(window.location.pathname).toBe('/settings/platform/authentication'); + expect(container.textContent).toContain('Authentication'); + expect(container.textContent).toContain('Platform'); + }, + { timeout: 5000 } + ); + }); + + it('/console/menus legacy 경로는 settings platform menus로 리다이렉트되어야 함', async () => { + window.history.replaceState({}, '', '/console/menus'); + + const { container } = render(); + + await waitFor( + () => { + expect(window.location.pathname).toBe('/settings/platform/menus'); + expect(container.textContent).toContain('Menus'); + expect(container.textContent).toContain('Platform'); + expect(container.textContent).toContain('Back to App'); + }, + { timeout: 5000 } + ); + }); + + it('/console/menus legacy 경로는 query/hash를 유지한 채 settings platform menus로 리다이렉트되어야 함', async () => { + window.history.replaceState({}, '', '/console/menus?from=legacy#section'); + + render(); + + await waitFor( + () => { + expect(window.location.pathname).toBe('/settings/platform/menus'); + expect(window.location.search).toBe('?from=legacy'); + expect(window.location.hash).toBe('#section'); + }, + { timeout: 5000 } + ); + }); + + it('/my-workspaces legacy 경로는 query/hash를 유지한 채 console my-workspaces로 리다이렉트되어야 함', async () => { + window.history.replaceState({}, '', '/my-workspaces?tab=members#invite'); + + render(); + + await waitFor( + () => { + expect(window.location.pathname).toBe('/console/my-workspaces'); + expect(window.location.search).toBe('?tab=members'); + expect(window.location.hash).toBe('#invite'); + }, + { timeout: 5000 } + ); + }); + + it('/system/users legacy 경로는 console users로 리다이렉트되어야 함', async () => { + window.history.replaceState({}, '', '/system/users'); + + render(); + + await waitFor( + () => { + expect(window.location.pathname).toBe('/console/users'); + }, + { timeout: 5000 } + ); + }); + describe('Icon 컴포넌트 전환 회귀', () => { it('App JSX에 data-lucide 속성이 존재하지 않아야 함', () => { const { container } = render(); @@ -477,17 +611,18 @@ describe('App overlay dismiss 통합', () => { permissions: ['PROTECTED_READ'], workspace: { required: true, - managedType: null, + requiredManagedType: null, }, }, ]); await Promise.resolve(); - setSystemSettings({ + setPlatformSettings({ brandName: 'Deck', baseUrl: 'https://localhost:4022', workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, }); @@ -498,7 +633,7 @@ describe('App overlay dismiss 통합', () => { user.set(null); setPrograms([]); - setSystemSettings(null); + setPlatformSettings(null); currentWorkspaceId.set(''); clearSession(); isInitialized.set(false); diff --git a/frontend/app/src/app/auth.test.ts b/frontend/app/src/app/auth.test.ts index 35e803553..08838cd15 100644 --- a/frontend/app/src/app/auth.test.ts +++ b/frontend/app/src/app/auth.test.ts @@ -6,9 +6,14 @@ const { mockNavigate, mockHttpGet, mockHttpPost, mockClearMeta } = vi.hoisted(() mockClearMeta: vi.fn(), })); -vi.mock('#app/shared/runtime', () => ({ - navigate: mockNavigate, -})); +vi.mock('#app/shared/runtime', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + navigate: mockNavigate, + getCurrentUrl: vi.fn(() => '/console/dashboard/'), + }; +}); vi.mock('#app/shared/http-client', () => ({ http: { @@ -31,25 +36,27 @@ describe('auth', () => { describe('checkAuth', () => { it('비밀번호 변경 필요 시 navigate로 password-change 페이지로 이동해야 함', async () => { - window.history.pushState({}, '', '/system/users?page=2#roles'); + window.history.pushState({}, '', '/console/users?page=2#roles'); mockHttpGet.mockResolvedValue({ passwordMustChange: true }); const result = await checkAuth(); expect(mockNavigate).toHaveBeenCalledWith( - '/auth/password-change?next=%2Fsystem%2Fusers%3Fpage%3D2%23roles', + '/auth/password-change?next=%2Fconsole%2Fusers%3Fpage%3D2%23roles', true ); expect(result).toBeNull(); }); it('인증 실패 시 login next URL로 이동해야 함', async () => { - window.history.pushState({}, '', '/system/users?page=2#roles'); + window.history.pushState({}, '', '/console/users?page=2#roles'); mockHttpGet.mockRejectedValue(new Error('Unauthorized')); const result = await checkAuth(); - expect(mockNavigate).toHaveBeenCalledWith('/login?next=%2Fsystem%2Fusers%3Fpage%3D2%23roles'); + expect(mockNavigate).toHaveBeenCalledWith( + '/login?next=%2Fconsole%2Fusers%3Fpage%3D2%23roles' + ); expect(result).toBeNull(); }); }); diff --git a/frontend/app/src/app/bootstrap.test.ts b/frontend/app/src/app/bootstrap.test.ts index 484398078..509d371f1 100644 --- a/frontend/app/src/app/bootstrap.test.ts +++ b/frontend/app/src/app/bootstrap.test.ts @@ -102,20 +102,12 @@ const { mockStartTour, mockGetPendingTour, mockClearPendingTour } = vi.hoisted(( mockClearPendingTour: vi.fn(), })); -const { mockStartSessionRefresh } = vi.hoisted(() => ({ - mockStartSessionRefresh: vi.fn(), -})); - vi.mock('#app/features/tour', () => ({ startTour: mockStartTour, getPendingTour: mockGetPendingTour, clearPendingTour: mockClearPendingTour, })); -vi.mock('#app/features/auth/session-refresh', () => ({ - startSessionRefresh: mockStartSessionRefresh, -})); - // ============================================ // Tests // ============================================ @@ -233,7 +225,7 @@ describe('initializeAppShell', () => { mockHttpGet.mockResolvedValueOnce([]); // /menus/programs mockHttpGet.mockResolvedValueOnce([]); // /menus/roles/ADMIN/tree mockHttpGet.mockResolvedValueOnce({ - // /system-settings + // /platform-settings brandName: 'My Brand', logoHorizontalUrl: 'https://cdn.example.com/logo.svg', logoHorizontalDarkUrl: 'https://cdn.example.com/logo-dark.svg', @@ -257,7 +249,7 @@ describe('initializeAppShell', () => { mockCheckAuth.mockResolvedValue(makeUser()); mockHttpGet.mockResolvedValueOnce([]); // /menus/programs mockHttpGet.mockResolvedValueOnce([]); // /menus/roles/ADMIN/tree - mockHttpGet.mockResolvedValueOnce({ brandName: 'My Brand' }); // /system-settings + mockHttpGet.mockResolvedValueOnce({ brandName: 'My Brand' }); // /platform-settings const { initializeAppShell } = await import('./bootstrap'); await initializeAppShell(); @@ -275,7 +267,7 @@ describe('initializeAppShell', () => { mockCheckAuth.mockResolvedValue(makeUser()); mockHttpGet.mockResolvedValueOnce([]); // /menus/programs mockHttpGet.mockResolvedValueOnce([]); // /menus/roles/ADMIN/tree - mockHttpGet.mockRejectedValueOnce(new Error('network error')); // /system-settings + mockHttpGet.mockRejectedValueOnce(new Error('network error')); // /platform-settings const { initializeAppShell } = await import('./bootstrap'); await initializeAppShell(); @@ -286,13 +278,13 @@ describe('initializeAppShell', () => { it('직접 URL 진입 시 현재 URL을 restoreTabs에 전달해야 함', async () => { mockCheckAuth.mockResolvedValue(makeUser()); - mockGetCurrentUrl.mockReturnValue('/my-workspaces/?tab=recent'); + mockGetCurrentUrl.mockReturnValue('/console/my-workspaces/?tab=recent'); const { restoreTabs } = await import('./tabs'); const { initializeAppShell } = await import('./bootstrap'); await initializeAppShell(); - expect(restoreTabs).toHaveBeenCalledWith('/my-workspaces/?tab=recent'); + expect(restoreTabs).toHaveBeenCalledWith('/console/my-workspaces/?tab=recent'); }); it('bootstrapAppShell 호출 시 workspace runtime handler를 등록해야 함', async () => { @@ -304,7 +296,7 @@ describe('initializeAppShell', () => { it('workspace runtime refresh는 선택을 저장하고 preserve 옵션으로 재초기화해야 함', async () => { mockCheckAuth.mockResolvedValue(makeUser()); - mockGetCurrentUrl.mockReturnValue('/my-workspaces/'); + mockGetCurrentUrl.mockReturnValue('/console/my-workspaces/'); const { restoreTabs } = await import('./tabs'); const { refreshWorkspaceRuntime } = await import('./bootstrap'); @@ -312,7 +304,7 @@ describe('initializeAppShell', () => { expect(mockPersistWorkspaceSelection).toHaveBeenCalledWith('ws-2'); expect(mockResetAppState).toHaveBeenCalledWith({ preserveWorkspaceSelection: true }); - expect(restoreTabs).toHaveBeenCalledWith('/my-workspaces/'); + expect(restoreTabs).toHaveBeenCalledWith('/console/my-workspaces/'); }); it('동일한 초기화가 겹치면 resetAppState를 한 번만 호출해야 함', async () => { diff --git a/frontend/app/src/app/bootstrap.ts b/frontend/app/src/app/bootstrap.ts index 7d1c9bb75..ef4ca4a87 100644 --- a/frontend/app/src/app/bootstrap.ts +++ b/frontend/app/src/app/bootstrap.ts @@ -1,6 +1,6 @@ import { http } from '#app/shared/http-client'; import { progress } from '#app/shared/progress'; -import { setSystemSettings, type SystemSettings } from '#app/entities/system-settings'; +import { setPlatformSettings, type PlatformSettings } from '#app/entities/platform-settings'; import type { ApiMenu, Program } from '#app/widgets/sidebar'; import { setPrograms, @@ -62,12 +62,12 @@ async function loadMenus(primaryRoleId?: string) { async function loadBrandSettings() { try { - const settings = await http.get('/system-settings'); + const settings = await http.get('/platform-settings'); if (settings.brandName?.trim()) { brandName.set(settings.brandName.trim()); document.title = settings.brandName.trim(); } - setSystemSettings(settings); + setPlatformSettings(settings); setBrandingUrls({ horizontalUrl: settings.logoHorizontalUrl ?? null, horizontalDarkUrl: settings.logoHorizontalDarkUrl ?? null, diff --git a/frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx b/frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx index 6e5c4cc41..8bfe0b47b 100644 --- a/frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx +++ b/frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx @@ -20,9 +20,14 @@ const { registeredCommands, flattenMenuToCommandsMock } = vi.hoisted(() => ({ >(() => []), })); -vi.mock('#app/shared/runtime', () => ({ - navigate: vi.fn(), -})); +vi.mock('#app/shared/runtime', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + navigate: vi.fn(), + getCurrentUrl: vi.fn(() => '/console/dashboard/'), + }; +}); vi.mock('#app/features/command-palette', () => ({ CommandPalette: () =>
, @@ -129,7 +134,7 @@ describe('CommandPaletteProvider', () => { (command) => command.id === 'page-settings-account-profile' ); const generalCommand = commandCalls.find( - (command) => command.id === 'page-settings-system-general' + (command) => command.id === 'page-settings-platform-general' ); expect(profileCommand).toBeDefined(); @@ -138,15 +143,15 @@ describe('CommandPaletteProvider', () => { expect(profileCommand?.group).toBeTruthy(); expect(generalCommand).toBeDefined(); expect(generalCommand?.title).toBe('General'); - expect(generalCommand?.group).toBe('System'); + expect(generalCommand?.group).toBe('Platform'); expect(generalCommand?.group).toBeTruthy(); generalCommand?.action(); - expect(navigate).toHaveBeenCalledWith('/settings/system/general'); + expect(navigate).toHaveBeenCalledWith('/settings/platform/general'); }); }); - it('owner가 아니면 ownerOnly settings command는 등록되지 않아야 함', async () => { + it('platform admin이 아니면 platformAdminOnly settings command는 등록되지 않아야 함', async () => { user.set(memberUser); render( @@ -162,14 +167,14 @@ describe('CommandPaletteProvider', () => { commandCalls.find((command) => command.id === 'page-settings-account-profile') ).toBeDefined(); expect( - commandCalls.find((command) => command.id === 'page-settings-system-general') + commandCalls.find((command) => command.id === 'page-settings-platform-general') ).toBeUndefined(); }); }); it('standalone 문맥에서도 메뉴 로드 후 page command를 다시 등록해야 함', async () => { user.set(null); - window.history.replaceState({}, '', '/system/notification-channels?standalone=true'); + window.history.replaceState({}, '', '/console/notification-channels?standalone=true'); vi.mocked(http.get) .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ id: 'menu-logs', name: 'Logs', icon: 'FileText', children: [] }]); @@ -195,7 +200,7 @@ describe('CommandPaletteProvider', () => { }); it('standalone query에서는 user가 있어도 action command를 등록하지 않아야 함', async () => { - window.history.replaceState({}, '', '/system/notification-channels?standalone=true'); + window.history.replaceState({}, '', '/console/notification-channels?standalone=true'); render( diff --git a/frontend/app/src/app/command-palette/CommandPaletteProvider.tsx b/frontend/app/src/app/command-palette/CommandPaletteProvider.tsx index e303a5428..22ab418a9 100644 --- a/frontend/app/src/app/command-palette/CommandPaletteProvider.tsx +++ b/frontend/app/src/app/command-palette/CommandPaletteProvider.tsx @@ -41,8 +41,12 @@ function normalizeSettingsUrl(url: string): string { case '/settings': case '/settings/account': case '/account/profile': - case '/account/setting': return '/settings/account/profile'; + case '/account/setting': + return '/settings/platform/general'; + case '/console/menus': + case '/console/menus/': + return '/settings/platform/menus'; default: return url; } @@ -95,13 +99,14 @@ async function registerSettingsCommands() { const commands: Parameters[0] = []; const currentUser = user.get(); + const isPlatformAdmin = currentUser?.isOwner === true; const tAccount = i18n.getFixedT(i18n.language, 'account'); for (const group of settingsNav) { const groupTitle = tAccount(group.labelKey as never) as string; for (const leaf of group.leaves) { - if (leaf.ownerOnly && !currentUser?.isOwner) continue; + if (leaf.platformAdminOnly && !isPlatformAdmin) continue; const title = tAccount(leaf.titleKey as never) as string; const subtitle = tAccount(leaf.subtitleKey as never) as string; diff --git a/frontend/app/src/app/header/NotificationBell.test.tsx b/frontend/app/src/app/header/NotificationBell.test.tsx index b5c8b462e..8cae8f67b 100644 --- a/frontend/app/src/app/header/NotificationBell.test.tsx +++ b/frontend/app/src/app/header/NotificationBell.test.tsx @@ -84,7 +84,7 @@ describe('NotificationBell', () => { 'notification-channels', 'Channels', 'Radio', - '/system/notification-channels/?channel=EMAIL', + '/console/notification-channels/?channel=EMAIL', ); }); diff --git a/frontend/app/src/app/header/NotificationBell.tsx b/frontend/app/src/app/header/NotificationBell.tsx index eeec08167..f9fd02e8e 100644 --- a/frontend/app/src/app/header/NotificationBell.tsx +++ b/frontend/app/src/app/header/NotificationBell.tsx @@ -54,7 +54,7 @@ export function NotificationBell() { 'notification-channels', t('notifications.channelsTabTitle'), 'Radio', - '/system/notification-channels/?channel=EMAIL', + '/console/notification-channels/?channel=EMAIL', ) } > @@ -73,7 +73,7 @@ export function NotificationBell() { 'notification-channels', t('notifications.channelsTabTitle'), 'Radio', - '/system/notification-channels/?channel=SLACK', + '/console/notification-channels/?channel=SLACK', ) } > @@ -93,7 +93,7 @@ export function NotificationBell() { key={u.id} className="w-full flex items-start gap-3 px-2 py-1.5 rounded-md hover:bg-accent hover:text-accent-foreground cursor-pointer text-left" onClick={() => - navigateTo('users', t('notifications.usersTabTitle'), 'Users', `/system/users/?userId=${u.id}`) + navigateTo('users', t('notifications.usersTabTitle'), 'Users', `/console/users/?userId=${u.id}`) } > @@ -113,7 +113,7 @@ export function NotificationBell() { key={inv.id} className="w-full flex items-start gap-3 px-2 py-1.5 rounded-md hover:bg-accent hover:text-accent-foreground cursor-pointer text-left" onClick={() => - navigateTo('users', t('notifications.usersTabTitle'), 'Users', `/system/users/?userId=${inv.acceptedUserId}`) + navigateTo('users', t('notifications.usersTabTitle'), 'Users', `/console/users/?userId=${inv.acceptedUserId}`) } > diff --git a/frontend/app/src/app/navigation/menu-access.test.ts b/frontend/app/src/app/navigation/menu-access.test.ts new file mode 100644 index 000000000..6b4da71c1 --- /dev/null +++ b/frontend/app/src/app/navigation/menu-access.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkspacePolicy } from '#app/entities/platform-settings'; +import { findApiMenuDefinitionByPath, findVisibleApiMenuDefinitionByPath } from './menu-access'; +import type { ApiMenu, Program } from './menu-types'; + +const workspacePolicy: WorkspacePolicy = { + useUserManaged: true, + usePlatformManaged: true, + useExternalSync: true, + useSelector: true, +}; + +const programs: Program[] = [ + { + code: 'CONTACTS', + path: '/console/contacts', + permissions: [], + }, +]; + +const platformManagedMenus: ApiMenu[] = [ + { + id: 'platform-root', + name: 'Platform', + managementType: 'PLATFORM_MANAGED', + permissions: [], + children: [ + { + id: 'contacts', + name: 'Contacts', + program: 'CONTACTS', + permissions: [], + children: [], + }, + ], + }, +]; + +describe('menu-access', () => { + it('상위 PLATFORM_MANAGED 그룹이 숨겨지면 하위 leaf도 visible lookup에서 제외해야 한다', () => { + expect( + findVisibleApiMenuDefinitionByPath(platformManagedMenus, '/console/contacts/', { + programs, + workspacePolicy, + activeWorkspaceId: '', + isPlatformAdmin: false, + }) + ).toBeNull(); + }); + + it('raw definition lookup은 visibility와 무관하게 canonical path를 찾을 수 있어야 한다', () => { + expect( + findApiMenuDefinitionByPath(platformManagedMenus, programs, '/console/contacts/') + ).toEqual(platformManagedMenus[0].children[0]); + }); +}); diff --git a/frontend/app/src/app/navigation/menu-access.ts b/frontend/app/src/app/navigation/menu-access.ts new file mode 100644 index 000000000..ae2dc148a --- /dev/null +++ b/frontend/app/src/app/navigation/menu-access.ts @@ -0,0 +1,98 @@ +import type { WorkspacePolicy } from '#app/entities/platform-settings'; +import { isProgramAccessible } from '#app/entities/platform-settings/workspace-access'; +import { resolveMenuLookupPath } from '#app/shared/router'; +import type { ApiMenu, Program } from './menu-types'; + +export interface MenuAccessContext { + programs: Program[]; + workspacePolicy: WorkspacePolicy | null; + activeWorkspaceId: string; + isPlatformAdmin: boolean; +} + +function normalizeMenuPath(path: string): string { + return resolveMenuLookupPath(path); +} + +function findProgram(programs: Program[], programCode?: string): Program | undefined { + if (!programCode) { + return undefined; + } + + return programs.find((program) => program.code === programCode); +} + +function hasMatchingProgramPath( + apiMenu: ApiMenu, + programs: Program[], + normalizedPath: string +): boolean { + const program = findProgram(programs, apiMenu.program); + return typeof program?.path === 'string' && normalizeMenuPath(program.path) === normalizedPath; +} + +export function isApiMenuVisible(apiMenu: ApiMenu, accessContext: MenuAccessContext): boolean { + if (apiMenu.managementType === 'PLATFORM_MANAGED' && !accessContext.isPlatformAdmin) { + return false; + } + + if (apiMenu.children.length > 0) { + return apiMenu.children.some((child) => isApiMenuVisible(child, accessContext)); + } + + return isProgramAccessible( + findProgram(accessContext.programs, apiMenu.program), + accessContext.workspacePolicy, + accessContext.activeWorkspaceId + ); +} + +export function findApiMenuDefinitionByPath( + apiMenus: ApiMenu[], + programs: Program[], + path: string +): ApiMenu | null { + const normalizedPath = normalizeMenuPath(path); + + for (const apiMenu of apiMenus) { + if (hasMatchingProgramPath(apiMenu, programs, normalizedPath)) { + return apiMenu; + } + + const child = findApiMenuDefinitionByPath(apiMenu.children, programs, normalizedPath); + if (child) { + return child; + } + } + + return null; +} + +export function findVisibleApiMenuDefinitionByPath( + apiMenus: ApiMenu[], + path: string, + accessContext: MenuAccessContext +): ApiMenu | null { + const normalizedPath = normalizeMenuPath(path); + + for (const apiMenu of apiMenus) { + if (!isApiMenuVisible(apiMenu, accessContext)) { + continue; + } + + if (hasMatchingProgramPath(apiMenu, accessContext.programs, normalizedPath)) { + return apiMenu; + } + + const child = findVisibleApiMenuDefinitionByPath( + apiMenu.children, + normalizedPath, + accessContext + ); + if (child) { + return child; + } + } + + return null; +} diff --git a/frontend/app/src/app/navigation/menu-runtime.ts b/frontend/app/src/app/navigation/menu-runtime.ts new file mode 100644 index 000000000..31430b52e --- /dev/null +++ b/frontend/app/src/app/navigation/menu-runtime.ts @@ -0,0 +1,215 @@ +import { createStore } from '#app/shared/store/create-store'; +import { user } from '#app/app/state'; +import { currentWorkspaceId, getCurrentWorkspaceContextId } from '#app/entities/workspace'; +import { platformSettings } from '#app/entities/platform-settings'; +import { resolveMenuLookupPath } from '#app/shared/router'; +import { getCurrentUrl } from '#app/shared/runtime'; +import { + findApiMenuDefinitionByPath, + findVisibleApiMenuDefinitionByPath, + isApiMenuVisible, + type MenuAccessContext, +} from './menu-access'; +import type { ApiMenu, MenuItem, Program } from './menu-types'; +export type { ApiMenu, MenuItem, Program } from './menu-types'; + +export const menuItems = createStore([]); +export const activeMenuId = createStore(null); +export const programs = createStore([]); +const rawApiMenus = createStore([]); + +function normalizeItems(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} + +function resolveMenuPath(path: string): string { + return resolveMenuLookupPath(path); +} + +function hasProgramPath(program?: Program): program is Program & { path: string } { + return typeof program?.path === 'string' && program.path.length > 0; +} + +function findProgram(programCode?: string): Program | undefined { + if (!programCode) return undefined; + return programs.get().find((program) => program.code === programCode); +} + +function createMenuAccessContext(): MenuAccessContext { + return { + programs: programs.get(), + workspacePolicy: platformSettings.get()?.workspacePolicy ?? null, + activeWorkspaceId: getCurrentWorkspaceContextId(), + isPlatformAdmin: isPlatformAdminUser(), + }; +} + +function isPlatformAdminUser(): boolean { + return user.get()?.isOwner === true; +} + +function convertApiMenuToMenuItem( + apiMenu: ApiMenu, + accessContext: MenuAccessContext +): MenuItem | null { + if (!isApiMenuVisible(apiMenu, accessContext)) { + return null; + } + + const program = findProgram(apiMenu.program); + const url = program?.path || undefined; + const children = apiMenu.children + .map((child) => convertApiMenuToMenuItem(child, accessContext)) + .filter((item): item is MenuItem => item != null); + + if (apiMenu.children.length > 0) { + if (children.length === 0) { + return null; + } + + return { + id: apiMenu.id, + label: apiMenu.name, + icon: apiMenu.icon, + children, + }; + } + + return { + id: apiMenu.id, + label: apiMenu.name, + icon: apiMenu.icon, + url, + }; +} + +function syncMenuData() { + const apiMenus = normalizeItems(rawApiMenus.get()); + const accessContext = createMenuAccessContext(); + menuItems.set( + apiMenus + .map((apiMenu) => convertApiMenuToMenuItem(apiMenu, accessContext)) + .filter((item): item is MenuItem => item != null) + ); +} + +export function refreshMenuData() { + syncMenuData(); +} + +function findVisibleMenuInTree(items: MenuItem[], path: string): MenuItem | null { + const resolvedPath = resolveMenuPath(path); + + for (const item of items) { + if (item.url && resolveMenuPath(item.url) === resolvedPath) { + return item; + } + + if (item.children?.length) { + const match = findVisibleMenuInTree(item.children, path); + if (match) { + return match; + } + } + } + + return null; +} + +export function findVisibleMenuByPath(path: string): MenuItem | null { + return findVisibleMenuInTree(menuItems.get(), path); +} + +export function resolveActiveMenuIdByPath(path: string): string | null { + return findVisibleMenuByPath(path)?.id ?? null; +} + +export function setActiveMenu(menuId: string) { + activeMenuId.set(menuId); +} + +export function clearActiveState() { + activeMenuId.set(null); +} + +export function syncSidebarState(path = getCurrentUrl()) { + refreshMenuData(); + const nextActiveMenuId = resolveActiveMenuIdByPath(path); + if (nextActiveMenuId) { + setActiveMenu(nextActiveMenuId); + return; + } + + clearActiveState(); +} + +function toMenuLookupItem(apiMenu: ApiMenu, path: string): MenuItem { + return { + id: apiMenu.id, + label: apiMenu.name, + icon: apiMenu.icon, + url: resolveMenuPath(path), + }; +} + +export function setPrograms(nextPrograms: Program[] | null | undefined) { + programs.set(normalizeItems(nextPrograms)); + syncSidebarState(); +} + +export function getProgramByPath(path: string): Program | undefined { + const normalizedPath = resolveMenuPath(path); + return programs + .get() + .find((program) => hasProgramPath(program) && resolveMenuPath(program.path) === normalizedPath); +} + +export function setMenuData(apiMenus: ApiMenu[] | null | undefined) { + rawApiMenus.set(normalizeItems(apiMenus)); + syncSidebarState(); +} + +export function useMenuRuntimeDependencies() { + programs.useStore(); + rawApiMenus.useStore(); + user.useStore(); +} + +export function findRawMenuByPath(path: string): MenuItem | null { + const apiMenu = findVisibleApiMenuDefinitionByPath( + rawApiMenus.get(), + path, + createMenuAccessContext() + ); + if (!apiMenu) { + return null; + } + + return toMenuLookupItem(apiMenu, path); +} + +export function hasMenuDefinitionByPath(path: string): boolean { + return findApiMenuDefinitionByPath(rawApiMenus.get(), programs.get(), path) != null; +} + +export function resetMenuRuntime() { + menuItems.set([]); + activeMenuId.set(null); + programs.set([]); + rawApiMenus.set([]); +} + +platformSettings.subscribe(syncSidebarState); +currentWorkspaceId.subscribe(syncSidebarState); +user.subscribe(syncSidebarState); + +const globalWindow = window as Window & { + __deckSidebarPopstateBound?: boolean; +}; + +if (!globalWindow.__deckSidebarPopstateBound) { + window.addEventListener('popstate', () => { + syncSidebarState(); + }); + globalWindow.__deckSidebarPopstateBound = true; +} diff --git a/frontend/app/src/app/navigation/menu-types.ts b/frontend/app/src/app/navigation/menu-types.ts new file mode 100644 index 000000000..a01d88ac0 --- /dev/null +++ b/frontend/app/src/app/navigation/menu-types.ts @@ -0,0 +1,23 @@ +import type { ManagementType } from '#app/entities/platform-settings'; +import type { Program as MenuProgram } from '#app/entities/menu'; + +export interface MenuItem { + id: string; + label: string; + icon?: string; + url?: string; + badge?: string; + children?: MenuItem[]; +} + +export interface ApiMenu { + id: string; + name: string; + icon?: string; + program?: string; + managementType?: ManagementType; + permissions: string[]; + children: ApiMenu[]; +} + +export type Program = MenuProgram; diff --git a/frontend/app/src/app/page-access.ts b/frontend/app/src/app/page-access.ts index 74a8e5e55..b4172ec48 100644 --- a/frontend/app/src/app/page-access.ts +++ b/frontend/app/src/app/page-access.ts @@ -1,10 +1,17 @@ -import { currentWorkspaceId } from '#app/entities/workspace'; -import type { WorkspacePolicy } from '#app/entities/system-settings'; -import { systemSettings } from '#app/entities/system-settings'; -import { isProgramAccessible } from '#app/entities/system-settings/workspace-access'; -import { getProgramByPath, programs, type Program } from '#app/widgets/sidebar'; +import { currentWorkspaceId, resolveWorkspaceContextIdFromUrl } from '#app/entities/workspace'; +import type { WorkspacePolicy } from '#app/entities/platform-settings'; +import { platformSettings } from '#app/entities/platform-settings'; +import { isProgramAccessible } from '#app/entities/platform-settings/workspace-access'; +import { + findVisibleMenuByPath, + getProgramByPath, + hasMenuDefinitionByPath, + useMenuRuntimeDependencies, + type Program, +} from './navigation/menu-runtime'; import { resolveRouteDescriptor } from '#app/shared/router'; import { getNotFoundPage, getPage, hasPage } from './page-registry'; +import type { MenuItem } from './navigation/menu-runtime'; function normalizePath(url: string): string { const basePath = url.split('?')[0].split('#')[0] || '/'; @@ -29,13 +36,34 @@ export function isPageAccessible( return isProgramAccessible(program, workspacePolicy, activeWorkspaceId); } -export function canAccessPage(url: string): boolean { +export interface RouteAccessResolution { + path: string; + program: Program | undefined; + menuItem: MenuItem | null; + hasMenuDefinition: boolean; + accessible: boolean; +} + +export function resolveRouteAccess(url: string): RouteAccessResolution { const path = resolveCanonicalPath(url); const program = getProgramByPath(path); - const workspacePolicy = systemSettings.get()?.workspacePolicy ?? null; - const activeWorkspaceId = currentWorkspaceId.get(); + const workspacePolicy = platformSettings.get()?.workspacePolicy ?? null; + const activeWorkspaceId = resolveWorkspaceContextIdFromUrl(url, currentWorkspaceId.get()); + const menuItem = findVisibleMenuByPath(path); + const hasMenuDefinition = hasMenuDefinitionByPath(path); + const programAccessible = isPageAccessible(path, program, workspacePolicy, activeWorkspaceId); - return isPageAccessible(path, program, workspacePolicy, activeWorkspaceId); + return { + path, + program, + menuItem, + hasMenuDefinition, + accessible: hasMenuDefinition ? menuItem != null && programAccessible : programAccessible, + }; +} + +export function canAccessPage(url: string): boolean { + return resolveRouteAccess(url).accessible; } export function getAccessiblePage(url: string) { @@ -47,7 +75,7 @@ export function getAccessiblePage(url: string) { } export function usePageAccessDependencies() { - programs.useStore(); - systemSettings.useStore(); + useMenuRuntimeDependencies(); + platformSettings.useStore(); currentWorkspaceId.useStore(); } diff --git a/frontend/app/src/app/page-registry-routes.test.ts b/frontend/app/src/app/page-registry-routes.test.ts index 1a28aa5f0..f837186e3 100644 --- a/frontend/app/src/app/page-registry-routes.test.ts +++ b/frontend/app/src/app/page-registry-routes.test.ts @@ -5,8 +5,8 @@ import { resolve } from 'node:path'; const pageRegistrySource = readFileSync(resolve(__dirname, './page-registry.ts'), 'utf-8'); describe('page-registry routes', () => { - it('system logs 라우트에 activity log와 API audit log 페이지가 등록되어 있어야 한다', () => { - expect(pageRegistrySource).toContain("'/system/activity-logs/':"); - expect(pageRegistrySource).toContain("'/system/api-audit-logs/':"); + it('console logs 라우트에 activity log와 API audit log 페이지가 등록되어 있어야 한다', () => { + expect(pageRegistrySource).toContain("'/console/activity-logs/':"); + expect(pageRegistrySource).toContain("'/console/api-audit-logs/':"); }); }); diff --git a/frontend/app/src/app/page-registry.test.ts b/frontend/app/src/app/page-registry.test.ts index 0555bf916..5e7806a33 100644 --- a/frontend/app/src/app/page-registry.test.ts +++ b/frontend/app/src/app/page-registry.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, it, expect } from 'vitest'; import { getPage, getNotFoundPage, hasPage } from './page-registry'; import { getAccessiblePage, isPageAccessible } from './page-access'; import type { Program } from '#app/widgets/sidebar'; -import type { WorkspacePolicy } from '#app/entities/system-settings'; -import { setPrograms } from '#app/widgets/sidebar'; -import { setSystemSettings } from '#app/entities/system-settings'; +import type { WorkspacePolicy } from '#app/entities/platform-settings'; +import { setMenuData, setPrograms } from '#app/widgets/sidebar'; +import { setPlatformSettings } from '#app/entities/platform-settings'; import { currentWorkspaceId } from '#app/entities/workspace'; import { clearSession, setSession, type UserSession } from '#app/features/auth'; @@ -31,7 +31,8 @@ const session: UserSession = { describe('page-registry workspace access', () => { afterEach(() => { setPrograms([]); - setSystemSettings(null); + setMenuData([]); + setPlatformSettings(null); currentWorkspaceId.set(''); clearSession(); }); @@ -40,31 +41,32 @@ describe('page-registry workspace access', () => { setPrograms([ createProgram({ code: 'WORKSPACE_MANAGEMENT', - path: '/system/workspaces', + path: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ'], workspace: { required: true, - managedType: 'SYSTEM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }), ]); - const pageBeforeAccess = getPage('/system/workspaces/'); + const pageBeforeAccess = getPage('/console/workspaces/'); setSession({ ...session, permissions: ['WORKSPACE_MANAGEMENT_READ'], }); - setSystemSettings({ + setPlatformSettings({ workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, } as never); currentWorkspaceId.set('ws-1'); - const pageAfterAccess = getPage('/system/workspaces/'); + const pageAfterAccess = getPage('/console/workspaces/'); expect(pageAfterAccess).toBe(pageBeforeAccess); }); @@ -73,162 +75,204 @@ describe('page-registry workspace access', () => { setPrograms([ createProgram({ code: 'WORKSPACE_MANAGEMENT', - path: '/system/workspaces', + path: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ'], workspace: { required: true, - managedType: 'SYSTEM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }), ]); - const actualPage = getPage('/system/workspaces/'); - expect(getAccessiblePage('/system/workspaces/')).toBe(getNotFoundPage()); + const actualPage = getPage('/console/workspaces/'); + expect(getAccessiblePage('/console/workspaces/')).toBe(getNotFoundPage()); setSession({ ...session, permissions: ['WORKSPACE_MANAGEMENT_READ'], }); - setSystemSettings({ + setPlatformSettings({ workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, } as never); currentWorkspaceId.set('ws-1'); - expect(getAccessiblePage('/system/workspaces/')).toBe(actualPage); + expect(getAccessiblePage('/console/workspaces/')).toBe(actualPage); + }); + + it('platform-managed 메뉴는 owner가 아니면 deep link로도 접근할 수 없어야 한다', () => { + setPrograms([ + createProgram({ + code: 'ROLE_MANAGEMENT', + path: '/settings/platform/roles', + permissions: [], + }), + ]); + setMenuData([ + { + id: 'platform-roles', + name: 'Roles', + icon: 'Shield', + program: 'ROLE_MANAGEMENT', + managementType: 'PLATFORM_MANAGED', + permissions: [], + children: [], + }, + ]); + setSession(session); + + expect(getAccessiblePage('/settings/platform/roles')).toBe(getNotFoundPage()); }); it('workspacePolicy가 null이면 workspace required 페이지를 막아야 한다', () => { const program = createProgram({ workspace: { required: true, - managedType: 'USER_MANAGED', + selectionRequired: false, + requiredManagedType: null, }, }); - expect(isPageAccessible('/my-workspaces/', program, null, '')).toBe(false); + expect(isPageAccessible('/console/my-workspaces/', program, null, '')).toBe(false); }); - it('managedType이 꺼져 있으면 해당 페이지를 막아야 한다', () => { + it('requiredManagedType이 꺼져 있으면 해당 페이지를 막아야 한다', () => { const program = createProgram({ - path: '/my-workspaces', + path: '/console/my-workspaces', workspace: { required: true, - managedType: 'USER_MANAGED', + selectionRequired: false, + requiredManagedType: null, }, }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: false, + useExternalSync: true, useSelector: true, }; - expect(isPageAccessible('/my-workspaces/', program, policy, 'ws-1')).toBe(false); + expect(isPageAccessible('/console/my-workspaces/', program, policy, 'ws-1')).toBe(false); }); it('workspace context가 필요한 일반 페이지는 currentWorkspaceId가 없으면 막아야 한다', () => { const program = createProgram({ - path: '/booking', + path: '/console/booking', workspace: { required: true, - managedType: null, + selectionRequired: true, + requiredManagedType: null, }, }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; - expect(isPageAccessible('/booking/', program, policy, '')).toBe(false); + expect(isPageAccessible('/console/booking/', program, policy, '')).toBe(false); }); it('workspace context가 있으면 일반 workspace 페이지 접근을 허용한다', () => { const program = createProgram({ - path: '/booking', + path: '/console/booking', workspace: { required: true, - managedType: null, + selectionRequired: true, + requiredManagedType: null, }, }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; - expect(isPageAccessible('/booking/', program, policy, 'ws-1')).toBe(true); + expect(isPageAccessible('/console/booking/', program, policy, 'ws-1')).toBe(true); }); - it('my-workspaces는 system-managed only 정책에서도 visible workspace가 있으면 접근을 허용한다', () => { + it('my-workspaces는 platform-managed만 켜져 있어도 active workspace 없이 접근을 허용한다', () => { const program = createProgram({ - path: '/my-workspaces', + path: '/console/my-workspaces', workspace: { required: true, - managedType: null, + selectionRequired: false, + requiredManagedType: null, }, }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; - expect(isPageAccessible('/my-workspaces/', program, policy, 'ws-1')).toBe(true); + expect(isPageAccessible('/console/my-workspaces/', program, policy, '')).toBe(true); }); it('프로그램 권한이 없으면 workspace 정책이 맞아도 페이지 접근을 막아야 한다', () => { setSession(session); const program = createProgram({ - path: '/system/workspaces', + path: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ', 'WORKSPACE_MANAGEMENT_WRITE'], workspace: { required: true, - managedType: 'SYSTEM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; - expect(isPageAccessible('/system/workspaces/', program, policy, 'ws-1')).toBe(false); + expect(isPageAccessible('/console/workspaces/', program, policy, 'ws-1')).toBe(false); }); it('program이 없는 등록 경로는 404로 처리해야 한다', () => { const policy: WorkspacePolicy = { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; - expect(isPageAccessible('/system/workspaces/', undefined, policy, 'ws-1')).toBe(false); + expect(isPageAccessible('/console/workspaces/', undefined, policy, 'ws-1')).toBe(false); }); it('program이 없는 settings shell 경로는 app shell 페이지로 취급하지 않아야 한다', () => { const policy: WorkspacePolicy = { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; - expect(isPageAccessible('/settings/', undefined, policy, 'ws-1')).toBe(false); + expect(isPageAccessible('/settings/account/profile', undefined, policy, 'ws-1')).toBe(false); expect(hasPage('/settings/account/preferences')).toBe(false); expect(getPage('/settings/account/preferences')).not.toBeNull(); }); + it('legacy /console/menus 경로는 더 이상 app shell page registry에 남지 않아야 한다', () => { + expect(hasPage('/console/menus')).toBe(false); + expect(hasPage('/console/menus/')).toBe(false); + }); + it('미등록 경로도 404 페이지를 반환해야 한다', () => { expect(hasPage('/this-path-does-not-exist/')).toBe(false); expect(getPage('/this-path-does-not-exist/')).not.toBeNull(); }); it('workspace detail path는 목록 path와 다른 page loader를 써야 한다', () => { - const listPage = getPage('/system/workspaces/'); - const detailPage = getPage('/system/workspaces/ws-1'); + const listPage = getPage('/console/workspaces/'); + const detailPage = getPage('/console/workspaces/ws-1'); expect(listPage).not.toBeNull(); expect(detailPage).not.toBeNull(); diff --git a/frontend/app/src/app/page-registry.ts b/frontend/app/src/app/page-registry.ts index c694e1a74..388f5d993 100644 --- a/frontend/app/src/app/page-registry.ts +++ b/frontend/app/src/app/page-registry.ts @@ -1,38 +1,45 @@ import { lazy, type ComponentType, type LazyExoticComponent } from 'react'; import { resolveRouteDescriptor } from '#app/shared/router'; +import { toConsolePath } from '#app/shared/router/console-path'; type LazyPage = LazyExoticComponent; const loaders: Record Promise<{ default: ComponentType }>> = { - '/dashboard/': () => import('#app/pages/dashboard/dashboard.page'), - '/system/users/': () => import('#app/pages/system/users/users.page'), - '/system/menus/': () => import('#app/pages/system/menus/menus.page'), - '/system/activity-logs/': () => import('#app/pages/system/activity-logs/activity-logs.page'), - '/system/api-audit-logs/': () => import('#app/pages/system/api-audit-logs/api-audit-logs.page'), - '/system/audit-logs/': () => import('#app/pages/system/audit-logs/audit-logs.page'), - '/system/email-templates/': () => + '/console/dashboard/': () => import('#app/pages/dashboard/dashboard.page'), + '/console/users/': () => import('#app/pages/system/users/users.page'), + '/console/activity-logs/': () => import('#app/pages/system/activity-logs/activity-logs.page'), + '/console/api-audit-logs/': () => import('#app/pages/system/api-audit-logs/api-audit-logs.page'), + '/console/email-templates/': () => import('#app/pages/system/email-templates/email-templates.page'), - '/system/slack-templates/': () => + '/console/slack-templates/': () => import('#app/pages/system/slack-templates/slack-templates.page'), - '/system/error-logs/': () => import('#app/pages/system/error-logs/error-logs.page'), - '/system/login-history/': () => import('#app/pages/system/login-history/login-history.page'), - '/system/notification-channels/': () => + '/console/error-logs/': () => import('#app/pages/system/error-logs/error-logs.page'), + '/console/login-history/': () => import('#app/pages/system/login-history/login-history.page'), + '/console/notification-channels/': () => import('#app/pages/system/notification-channels/notification-channels.page'), - '/system/notification-rules/': () => + '/console/notification-rules/': () => import('#app/pages/system/notification-rules/notification-rules.page'), - '/system/workspaces/': () => import('#app/pages/system/workspaces/workspaces.page'), - '/my-workspaces/': () => import('#app/pages/my-workspaces/my-workspaces.page'), + '/console/workspaces/': () => import('#app/pages/system/workspaces/workspaces.page'), + '/console/my-workspaces/': () => import('#app/pages/my-workspaces/my-workspaces.page'), }; const detailLoaders: Partial Promise<{ default: ComponentType }>>> = { - '/system/workspaces/': () => import('#app/pages/system/workspaces/workspace-detail.page'), - '/my-workspaces/': () => import('#app/pages/my-workspaces/my-workspace-detail.page'), + '/console/workspaces/': () => import('#app/pages/system/workspaces/workspace-detail.page'), + '/console/my-workspaces/': () => import('#app/pages/my-workspaces/my-workspace-detail.page'), }; export function registerPages(extra: Record Promise<{ default: ComponentType }>>) { Object.assign(loaders, extra); } +export function registerConsolePages( + extra: Record Promise<{ default: ComponentType }>> +) { + registerPages( + Object.fromEntries(Object.entries(extra).map(([path, loader]) => [toConsolePath(path), loader])) + ); +} + const cache = new Map(); const notFoundPage = lazy(() => import('#app/pages/errors/404/not-found.page')); diff --git a/frontend/app/src/app/reset.ts b/frontend/app/src/app/reset.ts index 031878277..a1aae590f 100644 --- a/frontend/app/src/app/reset.ts +++ b/frontend/app/src/app/reset.ts @@ -3,7 +3,7 @@ import { clearCommands } from '#app/features/command-palette'; import { clearSession } from '#app/features/auth'; import { stopSessionRefresh } from '#app/features/auth/session-refresh'; import { clearTours } from '#app/features/tour'; -import { setSystemSettings } from '#app/entities/system-settings'; +import { setPlatformSettings } from '#app/entities/platform-settings'; import { resetWorkspaceState } from '#app/entities/workspace'; import { resetSidebar } from '#app/widgets/sidebar'; import { activeTabId, tabs } from '#app/widgets/tabbar'; @@ -32,7 +32,7 @@ export function resetAppState(options?: ResetAppStateOptions) { isLoading.set(true); ownerAlerts.set(null); contextMenu.set({ visible: false, tabId: null, x: 0, y: 0 }); - setSystemSettings(null); + setPlatformSettings(null); resetWorkspaceState({ preserveSelection: options?.preserveWorkspaceSelection }); sessionStorage.clear(); diff --git a/frontend/app/src/app/sidebar/SidebarWrapper.test.tsx b/frontend/app/src/app/sidebar/SidebarWrapper.test.tsx index 6d447ffe7..16f6c98f8 100644 --- a/frontend/app/src/app/sidebar/SidebarWrapper.test.tsx +++ b/frontend/app/src/app/sidebar/SidebarWrapper.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { act, render, cleanup, fireEvent, screen } from '@testing-library/react'; import { SidebarProvider } from '#app/shared/sidebar'; -import { setSystemSettings } from '#app/entities/system-settings'; +import { setPlatformSettings } from '#app/entities/platform-settings'; import { workspaceList } from '#app/entities/workspace'; import { SidebarWrapper } from './SidebarWrapper'; import { user, theme } from '../state'; @@ -12,9 +12,14 @@ vi.mock('#app/shared/hooks', async () => { return { ...actual, useIsMobile: vi.fn(() => false) }; }); -vi.mock('#app/shared/runtime', () => ({ - navigate: vi.fn(), -})); +vi.mock('#app/shared/runtime', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + navigate: vi.fn(), + getCurrentUrl: vi.fn(() => '/console/dashboard/'), + }; +}); vi.mock('#app/widgets/sidebar', () => ({ SidebarNav: () =>
, @@ -37,7 +42,7 @@ describe('SidebarWrapper', () => { act(() => { user.set(null); theme.set('light'); - setSystemSettings(null); + setPlatformSettings(null); workspaceList.set([]); }); cleanup(); @@ -125,12 +130,13 @@ describe('SidebarWrapper', () => { it('selector가 비활성화되면 workspace가 있어도 horizontal logo를 렌더링해야 함', () => { act(() => { - setSystemSettings({ + setPlatformSettings({ brandName: 'Deck', baseUrl: 'https://deck.test', workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: false, }, }); @@ -191,7 +197,7 @@ describe('SidebarWrapper', () => { user.set({ id: 'user-1', username: 'admin', - name: '시스템 관리자', + name: '플랫폼 관리자', email: 'admin@deck.io', roleIds: ['role-1'], roles: [{ id: 'role-1', label: 'Administrator' }], @@ -311,7 +317,7 @@ describe('SidebarWrapper', () => { user.set({ id: 'user-1', username: 'admin', - name: '시스템 관리자', + name: '플랫폼 관리자', email: 'admin@deck.io', roleIds: ['role-1'], roles: [{ id: 'role-1', label: 'Administrator' }], @@ -381,7 +387,7 @@ describe('SidebarWrapper', () => { user.set({ id: 'user-1', username: 'admin', - name: '시스템 관리자', + name: '플랫폼 관리자', email: 'admin@deck.io', roleIds: ['role-1'], roles: [{ id: 'role-1', label: 'Administrator' }], diff --git a/frontend/app/src/app/sidebar/SidebarWrapper.tsx b/frontend/app/src/app/sidebar/SidebarWrapper.tsx index dc30838bb..ab115d279 100644 --- a/frontend/app/src/app/sidebar/SidebarWrapper.tsx +++ b/frontend/app/src/app/sidebar/SidebarWrapper.tsx @@ -27,7 +27,7 @@ import { } from '#app/shared/sidebar'; import { SidebarNav } from '#app/widgets/sidebar'; import { workspaceList } from '#app/entities/workspace'; -import { systemSettings } from '#app/entities/system-settings'; +import { platformSettings } from '#app/entities/platform-settings'; import { settingsAccountProfilePath } from '#app/pages/settings/settings-nav'; import { WorkspaceSelector } from './WorkspaceSelector'; import { user, theme, brandName } from '../state'; @@ -75,7 +75,7 @@ export function SidebarShellHeader() { const { open, toggleSidebar } = useSidebar(); const currentBrandName = brandName.useStore(); const visibleWorkspaces = workspaceList.useStore(); - const workspacePolicy = systemSettings.useStore()?.workspacePolicy ?? null; + const workspacePolicy = platformSettings.useStore()?.workspacePolicy ?? null; const showWorkspaceSelector = Boolean(workspacePolicy?.useSelector) && visibleWorkspaces.length > 0; diff --git a/frontend/app/src/app/tabbar/TabBarWrapper.test.tsx b/frontend/app/src/app/tabbar/TabBarWrapper.test.tsx index 9d83009ee..e56f5356a 100644 --- a/frontend/app/src/app/tabbar/TabBarWrapper.test.tsx +++ b/frontend/app/src/app/tabbar/TabBarWrapper.test.tsx @@ -9,7 +9,7 @@ vi.mock('#app/shared/scroll-hint', () => ({ let mockTabs = [ { id: 'tab-1', title: '대시보드', icon: 'Home', url: '/dashboard/' }, - { id: 'tab-2', title: '설정', icon: 'Settings', url: '/settings/' }, + { id: 'tab-2', title: '설정', icon: 'Settings', url: '/settings/account/profile' }, ]; let mockActiveTabId: string | null = 'tab-1'; let mockMaximized = false; @@ -50,7 +50,7 @@ describe('TabBarWrapper', () => { vi.clearAllMocks(); mockTabs = [ { id: 'tab-1', title: '대시보드', icon: 'Home', url: '/dashboard/' }, - { id: 'tab-2', title: '설정', icon: 'Settings', url: '/settings/' }, + { id: 'tab-2', title: '설정', icon: 'Settings', url: '/settings/account/profile' }, ]; mockActiveTabId = 'tab-1'; mockMaximized = false; diff --git a/frontend/app/src/app/tabbar/TabBarWrapper.tsx b/frontend/app/src/app/tabbar/TabBarWrapper.tsx index 409c22e3f..e136e16f7 100644 --- a/frontend/app/src/app/tabbar/TabBarWrapper.tsx +++ b/frontend/app/src/app/tabbar/TabBarWrapper.tsx @@ -7,10 +7,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#app/s import { Kbd } from '#app/shared/kbd'; import { SidebarTrigger } from '#app/shared/sidebar'; import { tabs, activeTabId } from '#app/widgets/tabbar'; -import { setActiveMenu } from '#app/widgets/sidebar'; import { openCommandPalette } from '#app/features/command-palette'; import { maximized } from '../state'; -import { saveTabs, closeTab, openInNewWindow } from '../tabs'; +import { closeTab, openInNewWindow } from '../tabs'; import { showContextMenu } from '../context-menu'; const MOD_KEY = navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'; @@ -35,7 +34,6 @@ export function TabBarWrapper() { const handleTabClick = (e: React.MouseEvent, tabId: string) => { if (e.button === 1) { closeTab(tabId); - saveTabs(); return; } if (e.shiftKey) { @@ -43,8 +41,6 @@ export function TabBarWrapper() { return; } activeTabId.set(tabId); - setActiveMenu(tabId); - saveTabs(); }; const handleContextMenu = (e: React.MouseEvent, tabId: string) => { @@ -146,7 +142,6 @@ export function TabBarWrapper() { onClick={(e) => { e.stopPropagation(); closeTab(tab.id); - saveTabs(); }} > diff --git a/frontend/app/src/app/tabs.test.ts b/frontend/app/src/app/tabs.test.ts index 54e61be71..9c7677379 100644 --- a/frontend/app/src/app/tabs.test.ts +++ b/frontend/app/src/app/tabs.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { clearSession } from '#app/features/auth'; -import * as sidebar from '#app/widgets/sidebar'; +import type { UserSession } from '#app/features/auth'; +import * as menuRuntime from './navigation/menu-runtime'; const mockTabs = { _value: [] as { id: string; title: string; icon: string; url: string }[], @@ -41,10 +41,12 @@ vi.mock('#app/widgets/tabbar', () => ({ openInNewWindow: vi.fn(), })); -vi.mock('#app/widgets/sidebar', () => ({ - setActiveMenu: vi.fn(), +vi.mock('./navigation/menu-runtime', () => ({ getProgramByPath: vi.fn(), - findRawMenuByPath: vi.fn(), + findVisibleMenuByPath: vi.fn(), + hasMenuDefinitionByPath: vi.fn(), + useMenuRuntimeDependencies: vi.fn(), + programs: { useStore: vi.fn(), get: vi.fn() }, })); vi.mock('#app/shared/runtime', async () => { @@ -57,11 +59,58 @@ vi.mock('#app/shared/runtime', async () => { }); const TABS_KEY = 'deck-tabs'; +const session: UserSession = { + id: 'user-1', + name: 'User', + email: 'user@deck.io', + roleIds: [], + roles: [{ id: 'role-user', label: 'User' }], + permissions: [], + isOwner: false, + hasPermissions: true, +}; + +async function clearRuntimeState() { + const [{ clearSession }, { setPlatformSettings }, { currentWorkspaceId }] = await Promise.all([ + import('#app/features/auth'), + import('#app/entities/platform-settings'), + import('#app/entities/workspace'), + ]); + + clearSession(); + setPlatformSettings(null); + currentWorkspaceId.set(''); +} + +async function seedSession(overrides: Partial) { + const { setSession } = await import('#app/features/auth'); + setSession({ + ...session, + ...overrides, + }); +} + +async function seedPlatformWorkspacePolicy() { + const { setPlatformSettings } = await import('#app/entities/platform-settings'); + setPlatformSettings({ + workspacePolicy: { + useUserManaged: true, + usePlatformManaged: true, + useExternalSync: true, + useSelector: true, + }, + } as never); +} + +async function setWorkspaceContext(workspaceId: string) { + const { currentWorkspaceId } = await import('#app/entities/workspace'); + currentWorkspaceId.set(workspaceId); +} describe('tabs', () => { let replaceStateMock: ReturnType; - beforeEach(() => { + beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); sessionStorage.clear(); @@ -74,13 +123,14 @@ describe('tabs', () => { configurable: true, }); mockGetSearchParams.mockReturnValue(new URLSearchParams('')); - clearSession(); - vi.mocked(sidebar.getProgramByPath).mockReturnValue(undefined); - vi.mocked(sidebar.findRawMenuByPath).mockReturnValue(null); + await clearRuntimeState(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue(undefined); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue(null); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(false); }); - afterEach(() => { - clearSession(); + afterEach(async () => { + await clearRuntimeState(); }); async function getSidebarModule() { @@ -92,25 +142,74 @@ describe('tabs', () => { mockGetSearchParams.mockReturnValue(new URLSearchParams('standalone=true')); const { openTab } = await import('./tabs'); - openTab('calendar-settings-manage', 'Integrations', 'Calendar', '/calendar-settings/manage/'); + openTab( + 'calendar-settings-manage', + 'Integrations', + 'Calendar', + '/console/calendar-integrations/manage/' + ); expect(mockNavigate).toHaveBeenCalledWith( - '/calendar-settings/manage/?standalone=true&title=Integrations&icon=Calendar' + '/console/calendar-integrations/manage/?standalone=true&title=Integrations&icon=Calendar' ); expect(mockOpenTab).not.toHaveBeenCalled(); }); it('같은 URL 탭이 이미 있으면 기존 탭을 활성화해야 함', async () => { mockTabs._value = [ - { id: 'menu-calendar', title: 'Calendar', icon: 'Calendar', url: '/calendar-settings' }, + { + id: 'menu-calendar', + title: 'Calendar', + icon: 'Calendar', + url: '/console/calendar-integrations', + }, ]; const { openTab } = await import('./tabs'); - openTab('calendar-settings', 'Integrations', 'Calendar', '/calendar-settings/'); + openTab('calendar-settings', 'Integrations', 'Calendar', '/console/calendar-integrations/'); expect(mockOpenTab).not.toHaveBeenCalled(); expect(mockActiveTabId._value).toBe('menu-calendar'); }); + + it('workspace_id와 legacy workspaceId는 같은 탭으로 dedupe해야 함', async () => { + mockTabs._value = [ + { + id: 'deskpie-contacts', + title: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/?workspaceId=ws-1', + }, + ]; + + const { openTab } = await import('./tabs'); + openTab( + 'deskpie-contacts', + 'Contacts', + 'ContactRound', + '/console/contacts/?workspace_id=ws-1' + ); + + expect(mockOpenTab).not.toHaveBeenCalled(); + expect(mockActiveTabId._value).toBe('deskpie-contacts'); + }); + + it('legacy path와 canonical path는 같은 탭으로 dedupe해야 함', async () => { + mockTabs._value = [ + { + id: 'dashboard', + title: 'Dashboard', + icon: 'Home', + url: '/dashboard/', + }, + ]; + + const { openTab } = await import('./tabs'); + openTab('dashboard', 'Dashboard', 'Home', '/console/dashboard/'); + + expect(mockOpenTab).not.toHaveBeenCalled(); + expect(mockActiveTabId._value).toBe('dashboard'); + }); }); describe('saveTabs', () => { @@ -129,7 +228,7 @@ describe('tabs', () => { describe('scheduleSaveTabs', () => { it('탭과 active id가 함께 바뀌어도 sessionStorage 저장은 한 번만 해야 함', async () => { - mockTabs._value = [{ id: 'tab-1', title: 'Users', icon: 'Users', url: '/system/users/' }]; + mockTabs._value = [{ id: 'tab-1', title: 'Users', icon: 'Users', url: '/console/users/' }]; mockActiveTabId._value = 'tab-1'; const tabsModule = await import('./tabs'); @@ -146,10 +245,15 @@ describe('tabs', () => { describe('restoreTabs', () => { it('sessionStorage에서 탭을 복원해야 함', async () => { const tabData = { - tabs: [{ id: 'tab-1', title: 'Users', icon: 'Users', url: '/users' }], + tabs: [{ id: 'tab-1', title: 'Users', icon: 'Users', url: '/console/users/' }], activeTabId: 'tab-1', }; sessionStorage.setItem(TABS_KEY, JSON.stringify(tabData)); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ + code: 'USER_MANAGEMENT', + path: '/console/users', + permissions: [], + }); const { restoreTabs } = await import('./tabs'); restoreTabs('/'); @@ -175,14 +279,16 @@ describe('tabs', () => { describe('URL 동기화', () => { it('탭 활성화 시 URL이 탭의 url로 변경되어야 함', async () => { - mockTabs._value = [{ id: 'tab-1', title: 'Users', icon: 'Users', url: '/system/users/' }]; + mockTabs._value = [{ id: 'tab-1', title: 'Users', icon: 'Users', url: '/console/users/' }]; + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); await import('./tabs'); mockActiveTabId.set('tab-1'); await vi.waitFor(() => { - expect(replaceStateMock).toHaveBeenCalledWith({}, '', '/system/users/'); + expect(replaceStateMock).toHaveBeenCalledWith({}, '', '/console/users/'); }); + expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(PopStateEvent)); }); it('탭을 모두 닫으면 URL이 /로 복원되어야 함', async () => { @@ -196,7 +302,7 @@ describe('tabs', () => { }); it('동기화 일시 중지 중에는 activeTabId가 null이어도 현재 URL을 덮어쓰지 않아야 함', async () => { - window.history.replaceState({}, '', '/system/users/?page=2'); + window.history.replaceState({}, '', '/console/users/?page=2'); replaceStateMock.mockClear(); const { suspendTabUrlSync } = await import('./tabs'); @@ -210,18 +316,23 @@ describe('tabs', () => { it('현재 URL과 일치하는 탭이 있으면 복원 시 해당 탭을 활성화해야 함', async () => { Object.defineProperty(window, 'location', { - value: { ...window.location, pathname: '/system/users/', search: '', hash: '' }, + value: { ...window.location, pathname: '/console/users/', search: '', hash: '' }, writable: true, }); const tabData = { tabs: [ { id: 'tab-1', title: 'Dashboard', icon: 'Home', url: '/dashboard/' }, - { id: 'tab-2', title: 'Users', icon: 'Users', url: '/system/users/' }, + { id: 'tab-2', title: 'Users', icon: 'Users', url: '/console/users/' }, ], activeTabId: 'tab-1', }; sessionStorage.setItem(TABS_KEY, JSON.stringify(tabData)); + vi.mocked(menuRuntime.getProgramByPath).mockImplementation((path) => + path === '/console/users/' + ? { code: 'USER_MANAGEMENT', path: '/console/users', permissions: [] } + : undefined + ); const { restoreTabs } = await import('./tabs'); restoreTabs(); @@ -255,21 +366,22 @@ describe('tabs', () => { it('현재 URL이 program path와 일치하면 새 탭으로 복원해야 함', async () => { Object.defineProperty(window, 'location', { - value: { ...window.location, pathname: '/my-workspaces/', search: '', hash: '' }, + value: { ...window.location, pathname: '/console/my-workspaces/', search: '', hash: '' }, writable: true, }); - const sidebar = await getSidebarModule(); - vi.mocked(sidebar.getProgramByPath).mockReturnValue({ + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ code: 'MY_WORKSPACE', - path: '/my-workspaces', + path: '/console/my-workspaces', permissions: [], }); - vi.mocked(sidebar.findRawMenuByPath).mockReturnValue({ + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue({ id: 'my-workspace', label: 'My Workspace', icon: 'Building2', - url: '/my-workspaces/', + url: '/console/my-workspaces/', }); const { restoreTabs } = await import('./tabs'); @@ -279,80 +391,249 @@ describe('tabs', () => { id: 'my-workspace', title: 'My Workspace', icon: 'Building2', - url: '/my-workspaces/', + url: '/console/my-workspaces/', }); expect(mockActiveTabId._value).toBe('my-workspace'); }); it('현재 URL query가 있으면 새 탭 복원 시 query를 보존해야 함', async () => { + await seedSession({ + permissions: ['CRM_PIPELINE_MANAGEMENT_READ'], + }); + Object.defineProperty(window, 'location', { - value: { ...window.location, pathname: '/pipelines/', search: '?objectType=DEAL' }, + value: { ...window.location, pathname: '/console/pipelines/', search: '?objectType=DEAL' }, writable: true, }); - const sidebar = await getSidebarModule(); - vi.mocked(sidebar.getProgramByPath).mockReturnValue({ + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ code: 'CRM_PIPELINE_MANAGEMENT', - path: '/pipelines', + path: '/console/pipelines', permissions: ['CRM_PIPELINE_MANAGEMENT_READ'], }); - vi.mocked(sidebar.findRawMenuByPath).mockReturnValue({ + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue({ id: 'deskpie-pipelines', label: 'Pipelines', icon: 'Workflow', - url: '/pipelines/', + url: '/console/pipelines/', }); const { restoreTabs } = await import('./tabs'); - restoreTabs('/pipelines/?objectType=DEAL'); + restoreTabs('/console/pipelines/?objectType=DEAL'); expect(mockOpenTab).toHaveBeenCalledWith({ id: 'deskpie-pipelines', title: 'Pipelines', icon: 'Workflow', - url: '/pipelines/?objectType=DEAL', + url: '/console/pipelines/?objectType=DEAL', }); expect(mockActiveTabId._value).toBe('deskpie-pipelines'); }); + it('settings leaf URL로 직접 진입하면 menu metadata로 탭을 복원해야 함', async () => { + Object.defineProperty(window, 'location', { + value: { ...window.location, pathname: '/settings/platform/general', search: '' }, + writable: true, + }); + + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ + code: 'PLATFORM_GENERAL_SETTINGS', + path: '/settings/platform/general', + permissions: [], + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue({ + id: 'platform-general', + label: 'General', + icon: 'Settings', + url: '/settings/platform/general', + }); + + const { restoreTabs } = await import('./tabs'); + restoreTabs('/settings/platform/general'); + + expect(mockOpenTab).toHaveBeenCalledWith({ + id: 'platform-general', + title: 'General', + icon: 'Settings', + url: '/settings/platform/general/', + }); + expect(mockActiveTabId._value).toBe('platform-general'); + }); + it('workspace detail URL로 직접 진입하면 parent menu metadata로 탭을 복원해야 함', async () => { + await seedSession({ + permissions: ['WORKSPACE_MANAGEMENT_READ'], + }); + await seedPlatformWorkspacePolicy(); + Object.defineProperty(window, 'location', { - value: { ...window.location, pathname: '/system/workspaces/ws-1', search: '' }, + value: { ...window.location, pathname: '/console/workspaces/ws-1', search: '' }, writable: true, }); - const sidebar = await getSidebarModule(); - vi.mocked(sidebar.getProgramByPath).mockReturnValue({ + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ code: 'WORKSPACE_MANAGEMENT', - path: '/system/workspaces', + path: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ'], workspace: { required: true, - managedType: 'SYSTEM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }); - vi.mocked(sidebar.findRawMenuByPath).mockReturnValue({ + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue({ id: 'workspace-management', label: 'Workspaces', icon: 'Building2', - url: '/system/workspaces/', + url: '/console/workspaces/', }); const { restoreTabs } = await import('./tabs'); - restoreTabs('/system/workspaces/ws-1'); + restoreTabs('/console/workspaces/ws-1'); expect(mockOpenTab).toHaveBeenCalledWith({ id: 'workspace-management', title: 'Workspaces', icon: 'Building2', - url: '/system/workspaces/ws-1', + url: '/console/workspaces/ws-1', }); expect(mockActiveTabId._value).toBe('workspace-management'); }); + it('workspace_id query가 있으면 현재 선택이 비어 있어도 workspace-required program 탭을 복원해야 함', async () => { + await seedPlatformWorkspacePolicy(); + + Object.defineProperty(window, 'location', { + value: { + ...window.location, + pathname: '/console/contacts', + search: '?workspace_id=ws-query', + hash: '', + }, + writable: true, + }); + await setWorkspaceContext(''); + + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ + code: 'DESKPIE_CONTACTS', + path: '/console/contacts', + permissions: [], + workspace: { + required: true, + selectionRequired: true, + requiredManagedType: null, + }, + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue({ + id: 'deskpie-contacts', + label: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/', + }); + + const { restoreTabs } = await import('./tabs'); + restoreTabs('/console/contacts?workspace_id=ws-query'); + + expect(mockOpenTab).toHaveBeenCalledWith({ + id: 'deskpie-contacts', + title: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/?workspace_id=ws-query', + }); + expect(mockActiveTabId._value).toBe('deskpie-contacts'); + }); + + it('menu가 있어도 workspace-required program이 접근 불가이면 현재 URL 탭을 복원하지 않아야 함', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + pathname: '/console/contacts', + search: '', + hash: '', + }, + writable: true, + }); + await setWorkspaceContext(''); + + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ + code: 'DESKPIE_CONTACTS', + path: '/console/contacts', + permissions: [], + workspace: { + required: true, + selectionRequired: true, + requiredManagedType: null, + }, + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue({ + id: 'deskpie-contacts', + label: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/', + }); + + const { restoreTabs } = await import('./tabs'); + restoreTabs('/console/contacts'); + + expect(mockOpenTab).not.toHaveBeenCalled(); + expect(mockActiveTabId._value).toBeNull(); + }); + + it('saved tab의 legacy workspaceId와 현재 URL의 workspace_id를 같은 탭으로 복원해야 함', async () => { + sessionStorage.setItem( + TABS_KEY, + JSON.stringify({ + tabs: [ + { + id: 'deskpie-contacts', + title: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/?workspaceId=ws-query', + }, + ], + activeTabId: 'deskpie-contacts', + }) + ); + + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ + code: 'DESKPIE_CONTACTS', + path: '/console/contacts', + permissions: [], + workspace: { + required: true, + selectionRequired: true, + requiredManagedType: null, + }, + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue({ + id: 'deskpie-contacts', + label: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/', + }); + await seedPlatformWorkspacePolicy(); + + const { restoreTabs } = await import('./tabs'); + restoreTabs('/console/contacts?workspace_id=ws-query'); + + expect(mockOpenTab).toHaveBeenCalledTimes(1); + expect(mockActiveTabId._value).toBe('deskpie-contacts'); + }); + it('현재 URL이 권한 없는 program path와 일치하면 탭을 복원하지 않아야 함', async () => { Object.defineProperty(window, 'location', { - value: { ...window.location, pathname: '/system/workspaces/' }, + value: { ...window.location, pathname: '/console/workspaces/' }, writable: true, }); sessionStorage.setItem( @@ -369,17 +650,18 @@ describe('tabs', () => { }) ); - const sidebar = await getSidebarModule(); - vi.mocked(sidebar.getProgramByPath).mockReturnValue({ + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ code: 'WORKSPACE_MANAGEMENT', - path: '/system/workspaces', + path: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ', 'WORKSPACE_MANAGEMENT_WRITE'], workspace: { required: true, - managedType: 'SYSTEM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }); - vi.mocked(sidebar.findRawMenuByPath).mockReturnValue(null); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue(null); const { restoreTabs } = await import('./tabs'); restoreTabs(); @@ -387,6 +669,122 @@ describe('tabs', () => { expect(mockOpenTab).not.toHaveBeenCalled(); expect(mockActiveTabId._value).toBeNull(); }); + + it('platform-managed 메뉴가 숨겨진 경로는 saved tab이나 deep link로도 복원하지 않아야 함', async () => { + Object.defineProperty(window, 'location', { + value: { ...window.location, pathname: '/settings/platform/roles', search: '' }, + writable: true, + }); + await seedSession({ + isOwner: false, + }); + + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ + code: 'ROLE_MANAGEMENT', + path: '/settings/platform/roles', + permissions: [], + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockReturnValue(null); + + const { restoreTabs } = await import('./tabs'); + restoreTabs('/settings/platform/roles'); + + expect(mockOpenTab).not.toHaveBeenCalled(); + expect(mockActiveTabId._value).toBeNull(); + }); + + it('저장된 hidden platform-managed 탭은 복원 단계에서 제거해야 함', async () => { + sessionStorage.setItem( + TABS_KEY, + JSON.stringify({ + tabs: [ + { + id: 'platform-roles', + title: 'Roles', + icon: 'Shield', + url: '/settings/platform/roles', + }, + { + id: 'deskpie-contacts', + title: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/?workspace_id=ws-1', + }, + ], + activeTabId: 'platform-roles', + }) + ); + + vi.mocked(menuRuntime.getProgramByPath).mockImplementation((path) => { + if (path === '/settings/platform/roles/') { + return { + code: 'ROLE_MANAGEMENT', + path: '/settings/platform/roles', + permissions: [], + }; + } + + if (path === '/console/contacts/') { + return { + code: 'DESKPIE_CONTACTS', + path: '/console/contacts', + permissions: [], + workspace: { + required: true, + selectionRequired: true, + requiredManagedType: null, + }, + }; + } + + return undefined; + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockImplementation( + (path) => path === '/settings/platform/roles/' || path === '/console/contacts/' + ); + vi.mocked(menuRuntime.findVisibleMenuByPath).mockImplementation((path) => { + if (path === '/console/contacts/') { + return { + id: 'deskpie-contacts', + label: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/', + }; + } + + return null; + }); + await seedPlatformWorkspacePolicy(); + await setWorkspaceContext('ws-1'); + mockOpenTab.mockImplementation((tab) => { + mockTabs._value = [...mockTabs._value, tab]; + }); + + const { restoreTabs } = await import('./tabs'); + restoreTabs('/'); + + expect(mockOpenTab).toHaveBeenCalledTimes(1); + expect(mockOpenTab).toHaveBeenCalledWith({ + id: 'deskpie-contacts', + title: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/?workspace_id=ws-1', + }); + expect(mockActiveTabId._value).toBe('deskpie-contacts'); + + await Promise.resolve(); + const stored = JSON.parse(sessionStorage.getItem(TABS_KEY)!); + expect(stored.tabs).toEqual([ + { + id: 'deskpie-contacts', + title: 'Contacts', + icon: 'ContactRound', + url: '/console/contacts/?workspace_id=ws-1', + }, + ]); + expect(stored.activeTabId).toBe('deskpie-contacts'); + }); }); describe('refreshTab', () => { @@ -409,7 +807,7 @@ describe('tabs', () => { describe('duplicateTab', () => { it('같은 URL이어도 새 ID로 복제 탭을 열어야 함', async () => { - mockTabs._value = [{ id: 'tab-1', title: 'Users', icon: 'Users', url: '/system/users/' }]; + mockTabs._value = [{ id: 'tab-1', title: 'Users', icon: 'Users', url: '/console/users/' }]; vi.spyOn(Date, 'now').mockReturnValue(1700000000001); const { duplicateTab } = await import('./tabs'); @@ -419,7 +817,7 @@ describe('tabs', () => { id: 'tab-1-1700000000001', title: 'Users', icon: 'Users', - url: '/system/users/', + url: '/console/users/', }); }); }); diff --git a/frontend/app/src/app/tabs.ts b/frontend/app/src/app/tabs.ts index 41c67d73d..1445f577c 100644 --- a/frontend/app/src/app/tabs.ts +++ b/frontend/app/src/app/tabs.ts @@ -1,6 +1,4 @@ -import { currentWorkspaceId } from '#app/entities/workspace'; -import { systemSettings } from '#app/entities/system-settings'; -import { isProgramAccessible } from '#app/entities/system-settings/workspace-access'; +import { normalizeWorkspaceScopeSearch } from '#app/entities/workspace'; import { tabs, activeTabId, @@ -8,30 +6,33 @@ import { closeTab, openInNewWindow, } from '#app/widgets/tabbar'; -import { findRawMenuByPath, getProgramByPath, setActiveMenu } from '#app/widgets/sidebar'; import { getCurrentUrl, getSearchParams, navigate } from '#app/shared/runtime'; -import { resolveRouteDescriptor } from '#app/shared/router'; +import { resolveRouteDescriptor, resolveTabMatchPath } from '#app/shared/router'; import type { Tab } from '#app/widgets/tabbar'; +import { resolveRouteAccess } from './page-access'; const TABS_KEY = 'deck-tabs'; const URL_PARSE_BASE = 'http://localhost'; let saveScheduled = false; let tabUrlSyncSuspendCount = 0; -function normalizePathname(pathname: string): string { - if (pathname === '/') return pathname; - return pathname.endsWith('/') ? pathname : `${pathname}/`; +function notifyLocationChanged() { + window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state })); } function isSameTabUrl(left: string, right: string): boolean { const leftUrl = new URL(left, URL_PARSE_BASE); const rightUrl = new URL(right, URL_PARSE_BASE); return ( - normalizePathname(leftUrl.pathname) === normalizePathname(rightUrl.pathname) && - leftUrl.search === rightUrl.search + resolveTabMatchPath(left) === resolveTabMatchPath(right) && + normalizeWorkspaceScopeSearch(leftUrl.search) === normalizeWorkspaceScopeSearch(rightUrl.search) ); } +function toCanonicalTabUrl(url: string): string { + return resolveRouteDescriptor(url).actualPath; +} + // 탭 활성화 시 URL을 탭의 url로 동기화 activeTabId.subscribe(() => { if (tabUrlSyncSuspendCount > 0) { @@ -41,12 +42,14 @@ activeTabId.subscribe(() => { const id = activeTabId.get(); if (!id) { window.history.replaceState({}, '', '/'); + notifyLocationChanged(); scheduleSaveTabs(); return; } const tab = tabs.get().find((t) => t.id === id); if (tab) { window.history.replaceState({}, '', tab.url); + notifyLocationChanged(); } scheduleSaveTabs(); }); @@ -115,18 +118,15 @@ export function openTab(id: string, title: string, icon: string, url: string, ne const existingByUrl = tabs.get().find((tab) => isSameTabUrl(tab.url, url)); if (existingByUrl) { activeTabId.set(existingByUrl.id); - setActiveMenu(id); return; } openTabWidget({ id, title, icon, url }); - setActiveMenu(id); } export function closeOtherTabs(id: string) { tabs.set(tabs.get().filter((t) => t.id === id)); activeTabId.set(id); - setActiveMenu(id); } export function closeRightTabs(id: string) { @@ -135,7 +135,6 @@ export function closeRightTabs(id: string) { tabs.set(tabs.get().slice(0, index + 1)); if (activeTabId.get() && !tabs.get().find((t) => t.id === activeTabId.get())) { activeTabId.set(id); - setActiveMenu(id); } } @@ -149,7 +148,6 @@ export function duplicateTab(id: string) { if (!tab) return; const newId = `${id}-${Date.now()}`; openTabWidget({ id: newId, title: tab.title, icon: tab.icon || '', url: tab.url }); - setActiveMenu(newId); } export function refreshTab(id: string) { @@ -165,15 +163,18 @@ export function refreshTab(id: string) { nextTabs[index] = { ...tab, id: refreshedId }; tabs.set(nextTabs); activeTabId.set(refreshedId); - setActiveMenu(baseId); } export function restoreTabs(initialUrl = getCurrentUrl()) { try { const saved = JSON.parse(sessionStorage.getItem(TABS_KEY) || 'null'); - if (saved?.tabs) { - saved.tabs.forEach((tab: Tab) => openTabWidget(tab)); - } + const savedTabs = Array.isArray(saved?.tabs) ? (saved.tabs as Tab[]) : []; + const accessibleSavedTabs = savedTabs + .filter((tab) => resolveRouteAccess(tab.url).accessible) + .map((tab) => ({ ...tab, url: toCanonicalTabUrl(tab.url) })); + const removedSavedTabs = accessibleSavedTabs.length !== savedTabs.length; + + accessibleSavedTabs.forEach((tab) => openTabWidget(tab)); // 현재 URL 경로와 일치하는 탭이 있으면 그 탭을 활성화 const currentUrl = initialUrl; @@ -182,38 +183,30 @@ export function restoreTabs(initialUrl = getCurrentUrl()) { const normalizedUrl = descriptor.actualPath; if (normalizedPath && normalizedPath !== '/') { - const matchingTab = saved?.tabs?.find((tab: Tab) => isSameTabUrl(tab.url, normalizedUrl)); + const matchingTab = accessibleSavedTabs.find((tab) => isSameTabUrl(tab.url, normalizedUrl)); if (matchingTab) { activeTabId.set(matchingTab.id); - setActiveMenu(matchingTab.id); + if (removedSavedTabs) { + scheduleSaveTabs(); + } return; } - const currentPathMenu = findRawMenuByPath(normalizedPath); - const currentPathProgram = getProgramByPath(normalizedPath); - const canOpenCurrentPath = - !!currentPathMenu || - (!!currentPathProgram && - isProgramAccessible( - currentPathProgram, - systemSettings.get()?.workspacePolicy ?? null, - currentWorkspaceId.get() - )); - - if (canOpenCurrentPath) { - const fallbackId = currentPathMenu?.id ?? `path:${normalizedPath}`; + const routeAccess = resolveRouteAccess(currentUrl); + + if (routeAccess.accessible) { + const fallbackId = routeAccess.menuItem?.id ?? `path:${normalizedPath}`; openTabWidget({ id: fallbackId, - title: currentPathMenu?.label ?? normalizedPath, - icon: currentPathMenu?.icon || '', + title: routeAccess.menuItem?.label ?? normalizedPath, + icon: routeAccess.menuItem?.icon || '', url: normalizedUrl, }); activeTabId.set(fallbackId); - setActiveMenu(fallbackId); return; } - if (currentPathProgram) { + if (routeAccess.program || routeAccess.hasMenuDefinition) { return; } @@ -224,13 +217,17 @@ export function restoreTabs(initialUrl = getCurrentUrl()) { url: normalizedUrl, }); activeTabId.set(`path:${normalizedPath}`); - setActiveMenu(`path:${normalizedPath}`); return; } - if (saved?.activeTabId) { + if (saved?.activeTabId && accessibleSavedTabs.some((tab) => tab.id === saved.activeTabId)) { activeTabId.set(saved.activeTabId); - setActiveMenu(saved.activeTabId); + } else if (accessibleSavedTabs[0]) { + activeTabId.set(accessibleSavedTabs[0].id); + } + + if (removedSavedTabs) { + scheduleSaveTabs(); } } catch { // 파싱 실패 시 무시 diff --git a/frontend/app/src/canonical-dialog-imports.test.ts b/frontend/app/src/canonical-dialog-imports.test.ts index 00e50602d..5530b624f 100644 --- a/frontend/app/src/canonical-dialog-imports.test.ts +++ b/frontend/app/src/canonical-dialog-imports.test.ts @@ -7,7 +7,7 @@ const APP_DIALOG_FILES = [ 'src/layouts/system-layout.tsx', 'src/pages/account/profile/security-connections-section.tsx', 'src/pages/account/profile/sessions-tab.tsx', - 'src/pages/account/setting/roles-tab.tsx', + 'src/pages/settings/tabs/roles-tab.tsx', 'src/features/menus/manage-menus/model/use-menus-page.ts', 'src/features/notifications/manage-channels/model/use-channels-actions.ts', 'src/features/notifications/manage-rules/model/use-rules-actions.ts', diff --git a/frontend/app/src/canonical-field-imports.test.ts b/frontend/app/src/canonical-field-imports.test.ts index 78d4c9286..2e6ce1689 100644 --- a/frontend/app/src/canonical-field-imports.test.ts +++ b/frontend/app/src/canonical-field-imports.test.ts @@ -6,8 +6,8 @@ const APP_FIELD_FILES = [ 'src/pages/account/profile/info-tab.tsx', 'src/pages/account/profile/security-password-section.tsx', 'src/pages/account/profile/preferences-tab.tsx', - 'src/pages/account/setting/general-tab.tsx', - 'src/pages/account/setting/roles-tab.tsx', + 'src/pages/settings/tabs/general-tab.tsx', + 'src/pages/settings/tabs/roles-tab.tsx', 'src/pages/auth/password-change/password-change.page.tsx', 'src/features/auth/login/ui/backup-code-form.tsx', 'src/features/auth/login/ui/totp-form.tsx', diff --git a/frontend/app/src/canonical-switch-imports.test.ts b/frontend/app/src/canonical-switch-imports.test.ts index f46d202b0..e98d919ef 100644 --- a/frontend/app/src/canonical-switch-imports.test.ts +++ b/frontend/app/src/canonical-switch-imports.test.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const APP_SWITCH_FILES = [ - 'src/pages/account/setting/auth-tab.tsx', + 'src/pages/settings/tabs/auth-tab.tsx', 'src/features/notifications/notification-channel-form/ui/notification-channel-form-view.tsx', 'src/features/notifications/notification-rule-form/ui/notification-rule-form-view.tsx', ] as const; diff --git a/frontend/app/src/entities/account/types.ts b/frontend/app/src/entities/account/types.ts index 47aeabba6..025d7f99a 100644 --- a/frontend/app/src/entities/account/types.ts +++ b/frontend/app/src/entities/account/types.ts @@ -6,6 +6,7 @@ export interface AccountMeResponse { email: string; contactProfile: ContactProfile; hasInternalIdentity: boolean; + // Legacy transport field name. Semantically this is the platform admin flag. isOwner?: boolean; locale?: string; timezone?: string; diff --git a/frontend/app/src/entities/dashboard/index.ts b/frontend/app/src/entities/dashboard/index.ts index 2152c8c22..a2a73ea8e 100644 --- a/frontend/app/src/entities/dashboard/index.ts +++ b/frontend/app/src/entities/dashboard/index.ts @@ -3,7 +3,7 @@ export type { SecurityStatus, DailyLoginStat, DailyErrorStat, - SystemStatus, + PlatformStatus, RoleDistribution, AuditLogSummary, DashboardResponse, diff --git a/frontend/app/src/entities/dashboard/types.ts b/frontend/app/src/entities/dashboard/types.ts index 19093fc6c..b5879e812 100644 --- a/frontend/app/src/entities/dashboard/types.ts +++ b/frontend/app/src/entities/dashboard/types.ts @@ -23,7 +23,7 @@ export interface DailyErrorStat { count: number; } -export interface SystemStatus { +export interface PlatformStatus { emailEnabled: boolean; slackEnabled: boolean; activeNotificationChannels: number; @@ -53,7 +53,7 @@ export interface DashboardResponse { activeUsersCount: number | null; errorStats: DailyErrorStat[] | null; pendingInvitesCount: number | null; - systemStatus: SystemStatus | null; + platformStatus: PlatformStatus | null; roleDistribution: RoleDistribution[] | null; recentAuditLogs: AuditLogSummary[] | null; } diff --git a/frontend/app/src/entities/menu/types.ts b/frontend/app/src/entities/menu/types.ts index b5a77130a..19b4c1f43 100644 --- a/frontend/app/src/entities/menu/types.ts +++ b/frontend/app/src/entities/menu/types.ts @@ -4,11 +4,12 @@ * 메뉴 관련 타입 정의 */ -import type { WorkspaceManagedType } from '#app/entities/system-settings'; +import type { ManagementType } from '#app/entities/platform-settings'; export interface ProgramWorkspacePolicy { required: boolean; - managedType: WorkspaceManagedType | null; + selectionRequired?: boolean; + requiredManagedType: ManagementType | null; } export interface Menu { @@ -17,6 +18,7 @@ export interface Menu { namesI18n?: Record; icon?: string; program?: string; + managementType?: ManagementType; permissions: string[]; url?: string; sortOrder?: number; @@ -37,6 +39,7 @@ export interface CreateMenuData { name: string; namesI18n?: Record; icon?: string; + managementType?: ManagementType; url?: string; sortOrder?: number; parentId?: string | null; @@ -47,6 +50,7 @@ export interface UpdateMenuData { namesI18n?: Record; icon?: string; program?: string; + managementType?: ManagementType; permissions: string[]; } diff --git a/frontend/app/src/entities/platform-settings/api.test.ts b/frontend/app/src/entities/platform-settings/api.test.ts new file mode 100644 index 000000000..24a3eb2f8 --- /dev/null +++ b/frontend/app/src/entities/platform-settings/api.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('#app/shared/http-client', () => ({ + http: { + get: vi.fn(), + put: vi.fn(), + post: vi.fn(), + }, +})); + +import { http } from '#app/shared/http-client'; +import { platformSettingsApi } from './api'; + +describe('platformSettingsApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('일반 설정은 /platform-settings/general 경로를 사용해야 한다', async () => { + vi.mocked(http.put).mockResolvedValue(undefined); + + await platformSettingsApi.updateGeneral({ brandName: 'Deck', contactEmail: 'privacy@deck.io' }); + + expect(http.put).toHaveBeenCalledWith('/platform-settings/general', { + brandName: 'Deck', + contactEmail: 'privacy@deck.io', + }); + }); + + it('public branding 조회는 /branding 경로를 사용해야 한다', async () => { + vi.mocked(http.get).mockResolvedValue({ + brandName: 'DeskPie', + logoHorizontalUrl: '/logo-light.svg?v=1', + logoHorizontalDarkUrl: '/logo-dark.svg?v=2', + logoPublicUrl: '/logo-public.png?v=3', + faviconUrl: '/favicon-light.svg?v=4', + faviconDarkUrl: '/favicon-dark.svg?v=5', + }); + + await platformSettingsApi.getPublicBranding(); + + expect(http.get).toHaveBeenCalledWith('/branding', { showToast: false }); + }); + + it('workspace policy는 /platform-settings/workspace-policy 경로를 사용해야 한다', async () => { + vi.mocked(http.put).mockResolvedValue(undefined); + + await platformSettingsApi.updateWorkspacePolicy({ + workspacePolicy: { + useUserManaged: true, + usePlatformManaged: false, + useExternalSync: false, + useSelector: true, + }, + }); + + expect(http.put).toHaveBeenCalledWith('/platform-settings/workspace-policy', { + workspacePolicy: { + useUserManaged: true, + usePlatformManaged: false, + useExternalSync: false, + useSelector: true, + }, + }); + }); + + it('auth provider 조회는 /platform-settings/auth/providers 경로를 사용해야 한다', async () => { + vi.mocked(http.get).mockResolvedValue([]); + + await platformSettingsApi.getOAuthProviders(); + + expect(http.get).toHaveBeenCalledWith('/platform-settings/auth/providers', { + showToast: false, + }); + }); + + it('auth provider 수정은 /platform-settings/auth/providers 경로를 사용해야 한다', async () => { + vi.mocked(http.put).mockResolvedValue(undefined); + + await platformSettingsApi.updateOAuthProviders({ + providers: { + GOOGLE: { enabled: false }, + }, + }); + + expect(http.put).toHaveBeenCalledWith('/platform-settings/auth/providers', { + providers: { + GOOGLE: { enabled: false }, + }, + }); + }); +}); diff --git a/frontend/app/src/entities/system-settings/api.ts b/frontend/app/src/entities/platform-settings/api.ts similarity index 68% rename from frontend/app/src/entities/system-settings/api.ts rename to frontend/app/src/entities/platform-settings/api.ts index 93f28de32..793b0fe72 100644 --- a/frontend/app/src/entities/system-settings/api.ts +++ b/frontend/app/src/entities/platform-settings/api.ts @@ -1,6 +1,6 @@ import { http } from '#app/shared/http-client'; import type { - SystemSettings, + PlatformSettings, PublicBranding, AuthSettings, AuthResponse, @@ -27,32 +27,32 @@ const DARK_VARIANTS = new Set(['HORIZONTAL_DARK', 'FAVICON_DARK']); function buildLogoEndpoint(type: LogoType, mode: 'url' | 'upload'): string { const apiType = API_TYPE_MAP[type]; const darkQuery = DARK_VARIANTS.has(type) ? '?dark=true' : ''; - return `/system-settings/logo/${apiType}/${mode}${darkQuery}`; + return `/platform-settings/logo/${apiType}/${mode}${darkQuery}`; } -export const systemSettingsApi = { - get: () => http.get('/system-settings', { showToast: false }), +export const platformSettingsApi = { + get: () => http.get('/platform-settings', { showToast: false }), getPublicBranding: () => http.get('/branding', { showToast: false }), updateGeneral: (data: UpdateGeneralSettingsRequest) => - http.put('/system-settings/general', data), + http.put('/platform-settings/general', data), updateWorkspacePolicy: (data: UpdateWorkspacePolicyRequest) => - http.put('/system-settings/workspace-policy', data), + http.put('/platform-settings/workspace-policy', data), updateCountryPolicy: (data: UpdateCountryPolicyRequest) => - http.put('/system-settings/country-policy', data), + http.put('/platform-settings/country-policy', data), updateCurrencyPolicy: (data: UpdateCurrencyPolicyRequest) => - http.put('/system-settings/currency-policy', data), + http.put('/platform-settings/currency-policy', data), updateGlobalizationPolicy: (data: UpdateGlobalizationPolicyRequest) => - http.put('/system-settings/globalization-policy', data), + http.put('/platform-settings/globalization-policy', data), - getAuth: () => http.get('/system-settings/auth', { showToast: false }), + getAuth: () => http.get('/platform-settings/auth', { showToast: false }), - updateAuth: (data: UpdateAuthSettingsRequest) => http.put('/system-settings/auth', data), + updateAuth: (data: UpdateAuthSettingsRequest) => http.put('/platform-settings/auth', data), setLogoUrl: (type: LogoType, url: string) => http.put>(buildLogoEndpoint(type, 'url'), { url }), @@ -64,8 +64,8 @@ export const systemSettingsApi = { http.put>(buildLogoEndpoint(type, 'url'), { url: null }), getOAuthProviders: () => - http.get('/system-settings/auth/providers', { showToast: false }), + http.get('/platform-settings/auth/providers', { showToast: false }), updateOAuthProviders: (data: UpdateOAuthProvidersRequest) => - http.put('/system-settings/auth/providers', data), + http.put('/platform-settings/auth/providers', data), }; diff --git a/frontend/app/src/entities/system-settings/index.ts b/frontend/app/src/entities/platform-settings/index.ts similarity index 72% rename from frontend/app/src/entities/system-settings/index.ts rename to frontend/app/src/entities/platform-settings/index.ts index 27bfd7447..0090f06de 100644 --- a/frontend/app/src/entities/system-settings/index.ts +++ b/frontend/app/src/entities/platform-settings/index.ts @@ -1,14 +1,14 @@ -export { systemSettingsApi } from './api'; -export { systemSettings, setSystemSettings, updateSystemWorkspacePolicy } from './store'; +export { platformSettingsApi } from './api'; +export { platformSettings, setPlatformSettings, updatePlatformWorkspacePolicy } from './store'; export { isProgramAccessible, isWorkspaceAccessible } from './workspace-access'; export type { LogoType, + ManagementType, OAuthProvider, PublicBranding, - SystemSettings, + PlatformSettings, AuthSettings, AuthResponse, - WorkspaceManagedType, WorkspacePolicy, CountryPolicy, CurrencyPolicy, diff --git a/frontend/app/src/entities/platform-settings/store.ts b/frontend/app/src/entities/platform-settings/store.ts new file mode 100644 index 000000000..0e42a63a8 --- /dev/null +++ b/frontend/app/src/entities/platform-settings/store.ts @@ -0,0 +1,18 @@ +import { createStore } from '#app/shared/store/create-store'; +import type { PlatformSettings, WorkspacePolicy } from './types'; + +export const platformSettings = createStore(null); + +export function setPlatformSettings(settings: PlatformSettings | null) { + platformSettings.set(settings); +} + +export function updatePlatformWorkspacePolicy(workspacePolicy: WorkspacePolicy | null) { + platformSettings.set((current) => { + if (!current) return current; + return { + ...current, + workspacePolicy, + }; + }); +} diff --git a/frontend/app/src/entities/system-settings/types.ts b/frontend/app/src/entities/platform-settings/types.ts similarity index 92% rename from frontend/app/src/entities/system-settings/types.ts rename to frontend/app/src/entities/platform-settings/types.ts index 45db73b45..b91598f76 100644 --- a/frontend/app/src/entities/system-settings/types.ts +++ b/frontend/app/src/entities/platform-settings/types.ts @@ -7,11 +7,12 @@ export type LogoType = export type OAuthProvider = 'GOOGLE' | 'NAVER' | 'KAKAO' | 'OKTA' | 'MICROSOFT' | 'AIP'; -export type WorkspaceManagedType = 'USER_MANAGED' | 'SYSTEM_MANAGED'; +export type ManagementType = 'USER_MANAGED' | 'PLATFORM_MANAGED'; export interface WorkspacePolicy { useUserManaged: boolean; - useSystemManaged: boolean; + usePlatformManaged: boolean; + useExternalSync: boolean; useSelector: boolean; } @@ -25,7 +26,7 @@ export interface CurrencyPolicy { preferredCurrencyCodes: string[]; } -export interface SystemSettings { +export interface PlatformSettings { brandName: string; contactEmail?: string | null; baseUrl: string; diff --git a/frontend/app/src/entities/platform-settings/workspace-access.test.ts b/frontend/app/src/entities/platform-settings/workspace-access.test.ts new file mode 100644 index 000000000..20ba05254 --- /dev/null +++ b/frontend/app/src/entities/platform-settings/workspace-access.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; +import { isWorkspaceAccessible } from './workspace-access'; + +vi.mock('#app/features/auth', () => ({ + hasAnyPermission: vi.fn(() => true), +})); + +describe('isWorkspaceAccessible', () => { + it('platform-managed workspace는 usePlatformManaged 정책을 따라야 한다', () => { + expect( + isWorkspaceAccessible( + { required: true, requiredManagedType: 'PLATFORM_MANAGED' }, + { + useUserManaged: true, + usePlatformManaged: false, + useExternalSync: false, + useSelector: true, + }, + 'workspace-1' + ) + ).toBe(false); + }); + + it('my workspace 페이지는 platform-managed만 켜져 있어도 active workspace 없이 접근할 수 있어야 한다', () => { + expect( + isWorkspaceAccessible( + { required: true, requiredManagedType: null, selectionRequired: false }, + { + useUserManaged: false, + usePlatformManaged: true, + useExternalSync: true, + useSelector: true, + }, + '' + ) + ).toBe(true); + }); + + it('workspace context 전용 페이지는 active workspace가 없으면 접근할 수 없어야 한다', () => { + expect( + isWorkspaceAccessible( + { required: true, requiredManagedType: null, selectionRequired: true }, + { + useUserManaged: true, + usePlatformManaged: true, + useExternalSync: true, + useSelector: true, + }, + '' + ) + ).toBe(false); + }); +}); diff --git a/frontend/app/src/entities/system-settings/workspace-access.ts b/frontend/app/src/entities/platform-settings/workspace-access.ts similarity index 57% rename from frontend/app/src/entities/system-settings/workspace-access.ts rename to frontend/app/src/entities/platform-settings/workspace-access.ts index 5c42f7e48..6be90883d 100644 --- a/frontend/app/src/entities/system-settings/workspace-access.ts +++ b/frontend/app/src/entities/platform-settings/workspace-access.ts @@ -1,9 +1,10 @@ import { hasAnyPermission } from '#app/features/auth'; -import type { WorkspaceManagedType, WorkspacePolicy } from './types'; +import type { ManagementType, WorkspacePolicy } from './types'; export interface WorkspaceCapability { required: boolean; - managedType: WorkspaceManagedType | null; + selectionRequired?: boolean; + requiredManagedType: ManagementType | null; } export interface ProgramAccess { @@ -11,6 +12,21 @@ export interface ProgramAccess { workspace?: WorkspaceCapability | null; } +function hasEnabledWorkspaceCapability( + requiredManagedType: ManagementType | null, + workspacePolicy: WorkspacePolicy +): boolean { + if (requiredManagedType === 'USER_MANAGED') { + return workspacePolicy.useUserManaged; + } + + if (requiredManagedType === 'PLATFORM_MANAGED') { + return workspacePolicy.usePlatformManaged; + } + + return workspacePolicy.useUserManaged || workspacePolicy.usePlatformManaged; +} + export function isWorkspaceAccessible( workspace: WorkspaceCapability | null | undefined, workspacePolicy: WorkspacePolicy | null | undefined, @@ -19,15 +35,11 @@ export function isWorkspaceAccessible( if (!workspace?.required) return true; if (!workspacePolicy) return false; - if (workspace.managedType === 'USER_MANAGED' && !workspacePolicy.useUserManaged) { - return false; - } - - if (workspace.managedType === 'SYSTEM_MANAGED' && !workspacePolicy.useSystemManaged) { + if (!hasEnabledWorkspaceCapability(workspace.requiredManagedType, workspacePolicy)) { return false; } - if (workspace.managedType == null && !activeWorkspaceId) { + if (workspace.selectionRequired && !activeWorkspaceId) { return false; } diff --git a/frontend/app/src/entities/system-settings/api.test.ts b/frontend/app/src/entities/system-settings/api.test.ts deleted file mode 100644 index 4785c9140..000000000 --- a/frontend/app/src/entities/system-settings/api.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; - -vi.mock('#app/shared/http-client', () => ({ - http: { - get: vi.fn(), - put: vi.fn(), - post: vi.fn(), - }, -})); - -import { http } from '#app/shared/http-client'; -import { systemSettingsApi } from './api'; - -describe('systemSettingsApi', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('일반 설정은 /system-settings/general 경로를 사용해야 한다', async () => { - vi.mocked(http.put).mockResolvedValue(undefined); - - await systemSettingsApi.updateGeneral({ brandName: 'Deck', contactEmail: 'privacy@deck.io' }); - - expect(http.put).toHaveBeenCalledWith('/system-settings/general', { - brandName: 'Deck', - contactEmail: 'privacy@deck.io', - }); - }); - - it('public branding 조회는 /branding 경로를 사용해야 한다', async () => { - vi.mocked(http.get).mockResolvedValue({ - brandName: 'DeskPie', - logoHorizontalUrl: '/logo-light.svg?v=1', - logoHorizontalDarkUrl: '/logo-dark.svg?v=2', - logoPublicUrl: '/logo-public.png?v=3', - faviconUrl: '/favicon-light.svg?v=4', - faviconDarkUrl: '/favicon-dark.svg?v=5', - }); - - await systemSettingsApi.getPublicBranding(); - - expect(http.get).toHaveBeenCalledWith('/branding', { showToast: false }); - }); - - it('workspace policy는 /system-settings/workspace-policy 경로를 사용해야 한다', async () => { - vi.mocked(http.put).mockResolvedValue(undefined); - - await systemSettingsApi.updateWorkspacePolicy({ - workspacePolicy: { useUserManaged: true, useSystemManaged: false, useSelector: true }, - }); - - expect(http.put).toHaveBeenCalledWith('/system-settings/workspace-policy', { - workspacePolicy: { useUserManaged: true, useSystemManaged: false, useSelector: true }, - }); - }); - - it('auth provider 조회는 /system-settings/auth/providers 경로를 사용해야 한다', async () => { - vi.mocked(http.get).mockResolvedValue([]); - - await systemSettingsApi.getOAuthProviders(); - - expect(http.get).toHaveBeenCalledWith('/system-settings/auth/providers', { showToast: false }); - }); - - it('auth provider 수정은 /system-settings/auth/providers 경로를 사용해야 한다', async () => { - vi.mocked(http.put).mockResolvedValue(undefined); - - await systemSettingsApi.updateOAuthProviders({ - providers: { - GOOGLE: { enabled: false }, - }, - }); - - expect(http.put).toHaveBeenCalledWith('/system-settings/auth/providers', { - providers: { - GOOGLE: { enabled: false }, - }, - }); - }); -}); diff --git a/frontend/app/src/entities/system-settings/store.ts b/frontend/app/src/entities/system-settings/store.ts deleted file mode 100644 index c071b2869..000000000 --- a/frontend/app/src/entities/system-settings/store.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createStore } from '#app/shared/store/create-store'; -import type { SystemSettings, WorkspacePolicy } from './types'; - -export const systemSettings = createStore(null); - -export function setSystemSettings(settings: SystemSettings | null) { - systemSettings.set(settings); -} - -export function updateSystemWorkspacePolicy(workspacePolicy: WorkspacePolicy | null) { - systemSettings.set((current) => { - if (!current) return current; - return { - ...current, - workspacePolicy, - }; - }); -} diff --git a/frontend/app/src/entities/workspace/index.ts b/frontend/app/src/entities/workspace/index.ts index 7b9af2e1e..b36dafe74 100644 --- a/frontend/app/src/entities/workspace/index.ts +++ b/frontend/app/src/entities/workspace/index.ts @@ -29,4 +29,12 @@ export { resetWorkspaceState, switchWorkspace, } from './store'; +export { + WORKSPACE_SCOPE_QUERY_PARAM, + normalizeWorkspaceScopeSearch, + resolveWorkspaceContextId, + resolveWorkspaceContextIdFromUrl, + getCurrentWorkspaceContextId, + useWorkspaceContextId, +} from './scope'; export { filterWorkspacesByPolicy, resolveCurrentWorkspaceId } from './visibility'; diff --git a/frontend/app/src/entities/workspace/scope.test.ts b/frontend/app/src/entities/workspace/scope.test.ts new file mode 100644 index 000000000..e0f912797 --- /dev/null +++ b/frontend/app/src/entities/workspace/scope.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeWorkspaceScopeSearch, + resolveWorkspaceContextId, + resolveWorkspaceContextIdFromUrl, + WORKSPACE_SCOPE_QUERY_PARAM, +} from './scope'; + +describe('resolveWorkspaceContextId', () => { + it('workspace_id query param이 있으면 현재 선택보다 우선한다', () => { + expect( + resolveWorkspaceContextId(`?${WORKSPACE_SCOPE_QUERY_PARAM}=ws-query`, 'ws-current') + ).toBe('ws-query'); + }); + + it('workspace_id query param이 없으면 현재 선택을 사용한다', () => { + expect(resolveWorkspaceContextId('?page=2', 'ws-current')).toBe('ws-current'); + }); + + it('workspace_id query param이 비어 있으면 현재 선택을 사용한다', () => { + expect(resolveWorkspaceContextId(`?${WORKSPACE_SCOPE_QUERY_PARAM}=`, 'ws-current')).toBe( + 'ws-current' + ); + }); + + it('전체 URL에서 workspace_id query param을 추출한다', () => { + expect( + resolveWorkspaceContextIdFromUrl( + `/console/contacts?${WORKSPACE_SCOPE_QUERY_PARAM}=ws-query&page=2`, + 'ws-current' + ) + ).toBe('ws-query'); + }); + + it('legacy workspaceId query param도 fallback으로 허용한다', () => { + expect(resolveWorkspaceContextId('?workspaceId=ws-legacy', 'ws-current')).toBe('ws-legacy'); + expect( + resolveWorkspaceContextIdFromUrl( + '/console/contacts?workspaceId=ws-legacy&page=2', + 'ws-current' + ) + ).toBe('ws-legacy'); + }); + + it('workspace scope search는 workspace_id canonical key로 정규화한다', () => { + expect(normalizeWorkspaceScopeSearch('?workspaceId=ws-legacy&page=2')).toBe( + '?page=2&workspace_id=ws-legacy' + ); + expect(normalizeWorkspaceScopeSearch('?workspace_id=ws-current&page=2')).toBe( + '?page=2&workspace_id=ws-current' + ); + }); +}); diff --git a/frontend/app/src/entities/workspace/scope.ts b/frontend/app/src/entities/workspace/scope.ts new file mode 100644 index 000000000..129cfe32a --- /dev/null +++ b/frontend/app/src/entities/workspace/scope.ts @@ -0,0 +1,49 @@ +import { currentWorkspaceId } from './store'; + +const URL_PARSE_BASE = 'http://localhost'; +export const WORKSPACE_SCOPE_QUERY_PARAM = 'workspace_id'; +const LEGACY_WORKSPACE_SCOPE_QUERY_PARAM = 'workspaceId'; + +function readWorkspaceScopeQuery(search: string): string { + const params = new URLSearchParams(search); + return ( + params.get(WORKSPACE_SCOPE_QUERY_PARAM)?.trim() ?? + params.get(LEGACY_WORKSPACE_SCOPE_QUERY_PARAM)?.trim() ?? + '' + ); +} + +export function normalizeWorkspaceScopeSearch(search: string): string { + const params = new URLSearchParams(search); + const workspaceId = readWorkspaceScopeQuery(search); + + params.delete(WORKSPACE_SCOPE_QUERY_PARAM); + params.delete(LEGACY_WORKSPACE_SCOPE_QUERY_PARAM); + + if (workspaceId) { + params.set(WORKSPACE_SCOPE_QUERY_PARAM, workspaceId); + } + + const normalized = params.toString(); + return normalized ? `?${normalized}` : ''; +} + +export function resolveWorkspaceContextId(search: string, fallbackWorkspaceId: string): string { + const workspaceId = readWorkspaceScopeQuery(search); + return workspaceId || fallbackWorkspaceId; +} + +export function resolveWorkspaceContextIdFromUrl(url: string, fallbackWorkspaceId: string): string { + const search = new URL(url, URL_PARSE_BASE).search; + return resolveWorkspaceContextId(search, fallbackWorkspaceId); +} + +export function getCurrentWorkspaceContextId(): string { + const search = typeof window === 'undefined' ? '' : window.location.search; + return resolveWorkspaceContextId(search, currentWorkspaceId.get()); +} + +export function useWorkspaceContextId(): string { + currentWorkspaceId.useStore(); + return getCurrentWorkspaceContextId(); +} diff --git a/frontend/app/src/entities/workspace/store.test.ts b/frontend/app/src/entities/workspace/store.test.ts index 2516533a3..37be7c67d 100644 --- a/frontend/app/src/entities/workspace/store.test.ts +++ b/frontend/app/src/entities/workspace/store.test.ts @@ -8,7 +8,7 @@ import { } from './store'; import { CURRENT_WORKSPACE_STORAGE_KEY } from './storage'; import { filterWorkspacesByPolicy, resolveCurrentWorkspaceId } from './visibility'; -import type { WorkspacePolicy } from '#app/entities/system-settings'; +import type { WorkspacePolicy } from '#app/entities/platform-settings'; const workspaces: MyWorkspace[] = [ { @@ -28,7 +28,7 @@ const workspaces: MyWorkspace[] = [ description: null, owners: [{ id: 'owner-2', name: 'Owner', email: 'owner2@test.com' }], memberCount: 3, - managedType: 'SYSTEM_MANAGED', + managedType: 'PLATFORM_MANAGED', role: 'MEMBER', createdAt: null, updatedAt: null, @@ -51,7 +51,8 @@ describe('workspace store helpers', () => { it('useUserManaged만 켜져 있으면 USER_MANAGED만 남긴다', () => { const policy: WorkspacePolicy = { useUserManaged: true, - useSystemManaged: false, + usePlatformManaged: false, + useExternalSync: false, useSelector: true, }; diff --git a/frontend/app/src/entities/workspace/store.ts b/frontend/app/src/entities/workspace/store.ts index 244ab87a5..b6444fc45 100644 --- a/frontend/app/src/entities/workspace/store.ts +++ b/frontend/app/src/entities/workspace/store.ts @@ -1,7 +1,7 @@ import { createStore } from '#app/shared/store/create-store'; import type { MyWorkspace } from './types'; import { myWorkspaceApi } from './my-api'; -import type { WorkspacePolicy } from '#app/entities/system-settings'; +import type { WorkspacePolicy } from '#app/entities/platform-settings'; import { filterWorkspacesByPolicy, resolveCurrentWorkspaceId } from './visibility'; import { CURRENT_WORKSPACE_STORAGE_KEY } from './storage'; diff --git a/frontend/app/src/entities/workspace/types.ts b/frontend/app/src/entities/workspace/types.ts index 72902032f..88edd4e30 100644 --- a/frontend/app/src/entities/workspace/types.ts +++ b/frontend/app/src/entities/workspace/types.ts @@ -1,4 +1,4 @@ -import type { WorkspaceManagedType } from '#app/entities/system-settings'; +import type { ManagementType } from '#app/entities/platform-settings'; export interface WorkspaceOwner { id: string; @@ -6,14 +6,20 @@ export interface WorkspaceOwner { email: string; } +export interface ExternalReference { + source: 'AIP'; + externalId: string; +} + export interface Workspace { id: string; name: string; description: string | null; - allowedDomains?: string[]; + autoJoinDomains?: string[]; owners: WorkspaceOwner[]; memberCount: number; - managedType: WorkspaceManagedType; + managedType: ManagementType; + externalReference?: ExternalReference | null; createdAt: string | null; updatedAt: string | null; } @@ -22,10 +28,11 @@ export interface MyWorkspace { id: string; name: string; description: string | null; - allowedDomains?: string[]; + autoJoinDomains?: string[]; owners: WorkspaceOwner[]; memberCount: number; - managedType: WorkspaceManagedType; + managedType: ManagementType; + externalReference?: ExternalReference | null; role: 'OWNER' | 'MEMBER'; createdAt: string | null; updatedAt: string | null; @@ -34,15 +41,13 @@ export interface MyWorkspace { export interface CreateWorkspaceRequest { name: string; description?: string; - allowedDomains: string[]; - managedType?: WorkspaceManagedType; + autoJoinDomains: string[]; } export interface UpdateWorkspaceRequest { name: string; description?: string; - allowedDomains: string[]; - managedType?: WorkspaceManagedType; + autoJoinDomains: string[]; } export interface WorkspaceMember { diff --git a/frontend/app/src/entities/workspace/visibility.ts b/frontend/app/src/entities/workspace/visibility.ts index f7e605cff..2a0cbdff4 100644 --- a/frontend/app/src/entities/workspace/visibility.ts +++ b/frontend/app/src/entities/workspace/visibility.ts @@ -1,4 +1,4 @@ -import type { WorkspacePolicy } from '#app/entities/system-settings'; +import type { WorkspacePolicy } from '#app/entities/platform-settings'; import type { MyWorkspace } from './types'; export function filterWorkspacesByPolicy( @@ -11,8 +11,8 @@ export function filterWorkspacesByPolicy( if (workspace.managedType === 'USER_MANAGED') { return workspacePolicy.useUserManaged; } - if (workspace.managedType === 'SYSTEM_MANAGED') { - return workspacePolicy.useSystemManaged; + if (workspace.managedType === 'PLATFORM_MANAGED') { + return workspacePolicy.usePlatformManaged; } return false; }); diff --git a/frontend/app/src/features/auth/login/model/use-login-bootstrap.test.ts b/frontend/app/src/features/auth/login/model/use-login-bootstrap.test.ts index cee9c19b8..cc771db30 100644 --- a/frontend/app/src/features/auth/login/model/use-login-bootstrap.test.ts +++ b/frontend/app/src/features/auth/login/model/use-login-bootstrap.test.ts @@ -76,7 +76,7 @@ describe('useLoginBootstrap', () => { renderHook(() => useLoginBootstrap()); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/system/users?page=2#roles', true); + expect(mockNavigate).toHaveBeenCalledWith('/console/users?page=2#roles', true); }); }); @@ -99,7 +99,7 @@ describe('useLoginBootstrap', () => { await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith( - '/auth/password-change?next=%2Fsystem%2Fusers%3Fpage%3D2%23roles', + '/auth/password-change?next=%2Fconsole%2Fusers%3Fpage%3D2%23roles', true ); }); diff --git a/frontend/app/src/features/command-palette/model/recents-store.test.ts b/frontend/app/src/features/command-palette/model/recents-store.test.ts index 52cf41ccc..445dd9632 100644 --- a/frontend/app/src/features/command-palette/model/recents-store.test.ts +++ b/frontend/app/src/features/command-palette/model/recents-store.test.ts @@ -12,7 +12,7 @@ describe('recents-store', () => { const recentB: RecentItem = { id: 'menu-audit-log', title: '감사 로그', - url: '/audit-logs', + url: '/console/api-audit-logs', }; beforeEach(() => { diff --git a/frontend/app/src/features/command-palette/model/shortcuts-store.test.ts b/frontend/app/src/features/command-palette/model/shortcuts-store.test.ts index 2dc1bac08..27e687a1a 100644 --- a/frontend/app/src/features/command-palette/model/shortcuts-store.test.ts +++ b/frontend/app/src/features/command-palette/model/shortcuts-store.test.ts @@ -19,7 +19,7 @@ describe('shortcuts-store', () => { const shortcutB: ShortcutItem = { id: 'menu-audit-log', title: '감사 로그', - url: '/audit-logs', + url: '/console/api-audit-logs', }; beforeEach(() => { diff --git a/frontend/app/src/features/command-palette/ui/CommandPalette.test.tsx b/frontend/app/src/features/command-palette/ui/CommandPalette.test.tsx index 6ccc19bf7..f68f2b2ff 100644 --- a/frontend/app/src/features/command-palette/ui/CommandPalette.test.tsx +++ b/frontend/app/src/features/command-palette/ui/CommandPalette.test.tsx @@ -209,7 +209,7 @@ describe('CommandPalette', () => { id: 'go-users', title: 'Users', category: 'page', - group: 'System', + group: 'Platform', action: vi.fn(), }, { @@ -229,7 +229,7 @@ describe('CommandPalette', () => { render( `/pages/${id}`} />); expect(screen.queryByRole('tab', { name: 'All' })).toBeNull(); - expect(screen.getByText('System')).toBeDefined(); + expect(screen.getByText('Platform')).toBeDefined(); expect(screen.getByText('Guides')).toBeDefined(); expect(screen.getByText('Actions')).toBeDefined(); }); @@ -262,7 +262,7 @@ describe('CommandPalette', () => { id: 'go-users', title: 'Users', category: 'page', - group: 'System', + group: 'Platform', action: vi.fn(), }, ]; @@ -272,7 +272,7 @@ describe('CommandPalette', () => { expect(screen.queryByText('Search tips')).toBeNull(); expect(screen.getByText('Recent')).toBeDefined(); - expect(screen.getByText('System')).toBeDefined(); + expect(screen.getByText('Platform')).toBeDefined(); expect(screen.queryByText('to close')).toBeNull(); }); @@ -308,6 +308,84 @@ describe('CommandPalette', () => { }); }); + it('legacy /account/setting recent는 platform general command로 복구해서 보여주고 실행해야 한다', () => { + const generalAction = vi.fn(); + commands = [ + { + id: 'page-settings-platform-general', + title: 'General', + category: 'page', + group: 'Platform', + action: generalAction, + keywords: ['/settings/platform/general'], + }, + ]; + recents = [ + { + id: 'legacy-platform-setting', + title: '설정', + icon: undefined, + url: '/account/setting', + }, + ]; + + render( '/settings/platform/general'} />); + + expect(screen.getAllByText('General')).toHaveLength(2); + expect(screen.queryByText('설정')).toBeNull(); + + const recentSection = screen.getByText('Recent').closest('section'); + fireEvent.click(recentSection!.querySelector('[role="button"]') as HTMLElement); + + expect(generalAction).toHaveBeenCalledTimes(1); + expect(mocks.navigate).not.toHaveBeenCalled(); + expect(mocks.addRecent).toHaveBeenCalledWith({ + id: 'page-settings-platform-general', + title: 'General', + icon: undefined, + url: '/settings/platform/general', + }); + }); + + it('legacy /console/menus recent는 platform menus command로 복구해서 보여주고 실행해야 한다', () => { + const menusAction = vi.fn(); + commands = [ + { + id: 'page-settings-platform-menus', + title: 'Menus', + category: 'page', + group: 'Platform', + action: menusAction, + keywords: ['/settings/platform/menus'], + }, + ]; + recents = [ + { + id: 'legacy-console-menus', + title: 'Console Menus', + icon: undefined, + url: '/console/menus', + }, + ]; + + render( '/settings/platform/menus'} />); + + expect(screen.getAllByText('Menus')).toHaveLength(2); + expect(screen.queryByText('Console Menus')).toBeNull(); + + const recentSection = screen.getByText('Recent').closest('section'); + fireEvent.click(recentSection!.querySelector('[role="button"]') as HTMLElement); + + expect(menusAction).toHaveBeenCalledTimes(1); + expect(mocks.navigate).not.toHaveBeenCalled(); + expect(mocks.addRecent).toHaveBeenCalledWith({ + id: 'page-settings-platform-menus', + title: 'Menus', + icon: undefined, + url: '/settings/platform/menus', + }); + }); + it('stale menu recent도 url이 같으면 현재 command action으로 복구해야 한다', () => { const activityAction = vi.fn(); commands = [ @@ -350,10 +428,10 @@ describe('CommandPalette', () => { it('현재 권한으로 보이지 않는 settings recent는 숨겨야 한다', () => { recents = [ { - id: 'page-settings-system-general', + id: 'page-settings-platform-general', title: 'General', icon: undefined, - url: '/settings/system/general', + url: '/settings/platform/general', }, ]; diff --git a/frontend/app/src/features/command-palette/ui/CommandPalette.tsx b/frontend/app/src/features/command-palette/ui/CommandPalette.tsx index e8e544d82..ccdf4909c 100644 --- a/frontend/app/src/features/command-palette/ui/CommandPalette.tsx +++ b/frontend/app/src/features/command-palette/ui/CommandPalette.tsx @@ -50,8 +50,12 @@ function normalizeStoredPageUrl(url: string): string { case '/settings': case '/settings/account': case '/account/profile': - case '/account/setting': return '/settings/account/profile'; + case '/account/setting': + return '/settings/platform/general'; + case '/console/menus': + case '/console/menus/': + return '/settings/platform/menus'; default: return url; } diff --git a/frontend/app/src/features/logs/ui/log-detail-modal-body.test.tsx b/frontend/app/src/features/logs/ui/log-detail-modal-body.test.tsx index 2ff528599..ffe684dbe 100644 --- a/frontend/app/src/features/logs/ui/log-detail-modal-body.test.tsx +++ b/frontend/app/src/features/logs/ui/log-detail-modal-body.test.tsx @@ -62,7 +62,7 @@ describe('log-detail-modal-body', () => { expect(container.textContent).toContain('Related Errors (1)'); expect(container.textContent).toContain('timeout'); - const relatedErrorLink = container.querySelector('a[href^="/system/error-logs?"]'); + const relatedErrorLink = container.querySelector('a[href^="/console/error-logs?"]'); expect(relatedErrorLink).toBeTruthy(); const relatedErrorHref = relatedErrorLink?.getAttribute('href') ?? ''; const relatedErrorParams = new URLSearchParams(relatedErrorHref.split('?')[1] || ''); @@ -130,7 +130,7 @@ describe('log-detail-modal-body', () => { expect(container.textContent).toContain('Related Audit Log'); expect(container.textContent).toContain('/api/v1/users'); - const relatedAuditLink = container.querySelector('a[href^="/system/api-audit-logs?"]'); + const relatedAuditLink = container.querySelector('a[href^="/console/api-audit-logs?"]'); expect(relatedAuditLink).toBeTruthy(); const relatedAuditHref = relatedAuditLink?.getAttribute('href') ?? ''; const relatedAuditParams = new URLSearchParams(relatedAuditHref.split('?')[1] || ''); diff --git a/frontend/app/src/features/logs/ui/log-detail-modal-body.tsx b/frontend/app/src/features/logs/ui/log-detail-modal-body.tsx index da3edb533..cdd23af54 100644 --- a/frontend/app/src/features/logs/ui/log-detail-modal-body.tsx +++ b/frontend/app/src/features/logs/ui/log-detail-modal-body.tsx @@ -10,7 +10,7 @@ import { formatDateTime } from '#app/shared/utils'; import { useTranslation } from 'react-i18next'; function buildStandaloneLogUrl( - path: '/system/error-logs' | '/system/api-audit-logs', + path: '/console/error-logs' | '/console/api-audit-logs', paramName: 'errorLogId' | 'auditLogId', id: string, title: string, @@ -113,7 +113,7 @@ export function AuditLogDetailModalBody({
{relatedErrors.map((err) => { const url = buildStandaloneLogUrl( - '/system/error-logs', + '/console/error-logs', 'errorLogId', err.id, t('errorLogs.title'), @@ -289,7 +289,7 @@ export function ErrorLogDetailModalBody({ {relatedAudit && (() => { const url = buildStandaloneLogUrl( - '/system/api-audit-logs', + '/console/api-audit-logs', 'auditLogId', relatedAudit.id, t('apiAuditLogs.title'), diff --git a/frontend/app/src/features/menus/manage-menus/model/types.ts b/frontend/app/src/features/menus/manage-menus/model/types.ts index ae5f1481c..018d5915f 100644 --- a/frontend/app/src/features/menus/manage-menus/model/types.ts +++ b/frontend/app/src/features/menus/manage-menus/model/types.ts @@ -1,9 +1,12 @@ +import type { ManagementType } from '#app/entities/platform-settings'; + export interface MenuForm { id: string; name: string; namesI18n?: Record; icon: string; program: string; + managementType: ManagementType; permissions: string[]; } @@ -13,6 +16,7 @@ export const DEFAULT_FORM: MenuForm = { namesI18n: {}, icon: '', program: 'NONE', + managementType: 'USER_MANAGED', permissions: [], }; diff --git a/frontend/app/src/features/menus/manage-menus/model/use-menus-page.test.ts b/frontend/app/src/features/menus/manage-menus/model/use-menus-page.test.ts index 8c38b4699..c3b47438f 100644 --- a/frontend/app/src/features/menus/manage-menus/model/use-menus-page.test.ts +++ b/frontend/app/src/features/menus/manage-menus/model/use-menus-page.test.ts @@ -67,12 +67,12 @@ const mockPrograms = [ { code: 'NONE', path: null, permissions: [] }, { code: 'MENU_MANAGEMENT', - path: '/system/menus', + path: '/settings/platform/menus', permissions: ['MENU_MANAGEMENT_READ', 'MENU_MANAGEMENT_WRITE'], }, { code: 'USER_MANAGEMENT', - path: '/system/users', + path: '/console/users', permissions: ['USER_MANAGEMENT_READ', 'USER_MANAGEMENT_WRITE'], }, ]; @@ -83,6 +83,7 @@ const mockMenuTree = [ name: 'Dashboard', icon: 'home', program: 'MENU_MANAGEMENT', + managementType: 'PLATFORM_MANAGED', permissions: ['MENU_MANAGEMENT_READ'], children: [], }, @@ -111,6 +112,7 @@ describe('useMenusPage', () => { namesI18n: {}, icon: '', program: 'NONE', + managementType: 'USER_MANAGED', permissions: [], }); }); @@ -200,6 +202,7 @@ describe('useMenusPage', () => { expect(result.current.form.getValues('id')).toBe('menu-1'); expect(result.current.form.getValues('name')).toBe('Dashboard'); expect(result.current.form.getValues('program')).toBe('MENU_MANAGEMENT'); + expect(result.current.form.getValues('managementType')).toBe('PLATFORM_MANAGED'); expect(result.current.permissions).toContain('MENU_MANAGEMENT_READ'); expect(result.current.permissions).toContain('MENU_MANAGEMENT_WRITE'); }); @@ -276,6 +279,7 @@ describe('useMenusPage', () => { namesI18n: {}, icon: 'home', program: 'MENU_MANAGEMENT', + managementType: 'PLATFORM_MANAGED', permissions: expect.arrayContaining(['MENU_MANAGEMENT_READ', 'MENU_MANAGEMENT_WRITE']), }); expect(mocks.toast).toHaveBeenCalledWith('Saved', 'success'); @@ -315,6 +319,7 @@ describe('useMenusPage', () => { namesI18n: {}, icon: '', program: 'NONE', + managementType: 'USER_MANAGED', permissions: [], }); }); @@ -673,6 +678,7 @@ describe('useMenusPage', () => { name: 'NONE', icon: 'square-dashed', program: 'NONE', + managementType: 'USER_MANAGED', }); }); }); diff --git a/frontend/app/src/features/menus/manage-menus/model/use-menus-page.ts b/frontend/app/src/features/menus/manage-menus/model/use-menus-page.ts index 26f5cd7e2..6c53ec191 100644 --- a/frontend/app/src/features/menus/manage-menus/model/use-menus-page.ts +++ b/frontend/app/src/features/menus/manage-menus/model/use-menus-page.ts @@ -18,6 +18,7 @@ const menuFormSchema = z.object({ namesI18n: z.record(z.string(), z.string()).optional(), icon: z.string(), program: z.string(), + managementType: z.enum(['USER_MANAGED', 'PLATFORM_MANAGED']), permissions: z.array(z.string()), }); @@ -27,8 +28,15 @@ const api = { getRoles: () => roleApi.list(), getPrograms: () => menuApi.getPrograms(), getMenuTree: (roleId: string) => menuApi.getTree(roleId), - createRoot: (roleId: string, data: { name: string; icon?: string; program?: string }) => - menuApi.createRoot(roleId, data), + createRoot: ( + roleId: string, + data: { + name: string; + icon?: string; + program?: string; + managementType?: 'USER_MANAGED' | 'PLATFORM_MANAGED'; + } + ) => menuApi.createRoot(roleId, data), update: ( id: string, data: { @@ -36,6 +44,7 @@ const api = { namesI18n?: Record; icon?: string; program?: string; + managementType?: 'USER_MANAGED' | 'PLATFORM_MANAGED'; permissions: string[]; } ) => menuApi.update(id, data), @@ -128,6 +137,7 @@ export function useMenusPage() { name: 'NONE', icon: 'square-dashed', program: 'NONE', + managementType: 'USER_MANAGED', }); menus = await api.getMenuTree(selectedRoleId); } @@ -168,6 +178,7 @@ export function useMenusPage() { namesI18n: menu.namesI18n || {}, icon: menu.icon || '', program, + managementType: menu.managementType ?? 'USER_MANAGED', permissions: withRequiredPermissions(program, [...menu.permissions]), }); }, @@ -233,6 +244,7 @@ export function useMenusPage() { namesI18n: data.namesI18n, icon: data.icon || undefined, program: data.program, + managementType: data.managementType, permissions: withRequiredPermissions(data.program, data.permissions), }); showToast('Saved', 'success'); diff --git a/frontend/app/src/features/menus/manage-menus/ui/menu-detail-panel.test.tsx b/frontend/app/src/features/menus/manage-menus/ui/menu-detail-panel.test.tsx index 3c5539891..ad5cfdb2d 100644 --- a/frontend/app/src/features/menus/manage-menus/ui/menu-detail-panel.test.tsx +++ b/frontend/app/src/features/menus/manage-menus/ui/menu-detail-panel.test.tsx @@ -31,13 +31,14 @@ const selectedMenu: Menu = { }, icon: 'users', program: 'USER_MANAGEMENT', + managementType: 'PLATFORM_MANAGED', permissions: ['USER_MANAGEMENT_READ', 'USER_MANAGEMENT_WRITE'], }; const programs: Program[] = [ { code: 'USER_MANAGEMENT', - path: '/system/users', + path: '/console/users', permissions: ['USER_MANAGEMENT_READ', 'USER_MANAGEMENT_WRITE'], }, ]; @@ -48,6 +49,7 @@ type MenuDetailFormValues = { namesI18n?: Record; icon: string; program: string; + managementType: 'USER_MANAGED' | 'PLATFORM_MANAGED'; permissions: string[]; }; @@ -65,6 +67,7 @@ function MenuDetailPanelHarness({ namesI18n: selectedMenu.namesI18n, icon: selectedMenu.icon ?? '', program: selectedMenu.program ?? '', + managementType: selectedMenu.managementType ?? 'USER_MANAGED', permissions: selectedMenu.permissions, }, }); @@ -128,6 +131,14 @@ describe('MenuDetailPanel', () => { ); }); + it('management type select는 현재 값을 표시해야 한다', () => { + render(); + + expect(screen.getByRole('combobox', { name: 'Management Type' }).textContent).toContain( + 'Platform-managed' + ); + }); + it('권한 label 클릭은 토글을 호출하고 pointer cursor를 가져야 함', () => { const onTogglePermission = vi.fn(); diff --git a/frontend/app/src/features/menus/manage-menus/ui/menu-detail-panel.tsx b/frontend/app/src/features/menus/manage-menus/ui/menu-detail-panel.tsx index 2d77279e1..1f3c7562a 100644 --- a/frontend/app/src/features/menus/manage-menus/ui/menu-detail-panel.tsx +++ b/frontend/app/src/features/menus/manage-menus/ui/menu-detail-panel.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { Controller } from 'react-hook-form'; import type { Menu, Program } from '#app/entities/menu'; +import type { ManagementType } from '#app/entities/platform-settings'; import { Checkbox } from '#app/components/ui/checkbox'; import { Field, FieldError, FieldLabel, FieldSet } from '#app/components/ui/field'; import { Input } from '#app/components/ui/input'; @@ -20,6 +21,7 @@ import type { useMenusPage } from '../model/use-menus-page'; type PageReturn = ReturnType; const LOCALE_KEYS = ['en', 'ko', 'ja'] as const; +const MANAGEMENT_TYPES: ManagementType[] = ['USER_MANAGED', 'PLATFORM_MANAGED']; interface MenuDetailPanelProps { selectedMenu: Menu | null; @@ -102,6 +104,32 @@ export function MenuDetailPanel({ /> + + + {t('menu.managementType')} + + ( + + )} + /> + + {t('menu.name')} ({ menuApi: { @@ -30,8 +31,48 @@ describe('useMenuForm', () => { programs: [{ code: 'P1', path: '/p1', permissions: [] }], }; - const { result } = renderHook(() => useMenuForm({ initialData })); + const { result, unmount } = renderHook(() => useMenuForm({ initialData })); expect(result.current.state.programs).toEqual(initialData.programs); + unmount(); + }); + + it('생성 payload는 managementType을 포함해야 한다', async () => { + vi.mocked(menuApi.createRoot).mockResolvedValue({} as never); + + const { result, unmount } = renderHook(() => + useMenuForm({ + initialData: { + roleId: 'role-1', + parentId: null, + programs: [{ code: 'P1', path: '/p1', permissions: [] }], + }, + }) + ); + + act(() => { + result.current.form.setValue('name', 'Platform menu'); + result.current.form.setValue('icon', 'settings'); + result.current.form.setValue('program', 'P1'); + result.current.form.setValue('managementType', 'PLATFORM_MANAGED'); + }); + + await act(async () => { + await result.current.actions.onSubmit({ + name: 'Platform menu', + namesI18n: {}, + icon: 'settings', + program: 'P1', + managementType: 'PLATFORM_MANAGED', + }); + }); + + expect(menuApi.createRoot).toHaveBeenCalledWith( + 'role-1', + expect.objectContaining({ + managementType: 'PLATFORM_MANAGED', + }) + ); + unmount(); }); }); diff --git a/frontend/app/src/features/menus/menu-form/model/use-menu-form.ts b/frontend/app/src/features/menus/menu-form/model/use-menu-form.ts index e088f4e4b..23962430c 100644 --- a/frontend/app/src/features/menus/menu-form/model/use-menu-form.ts +++ b/frontend/app/src/features/menus/menu-form/model/use-menu-form.ts @@ -16,6 +16,7 @@ const menuSchema = z.object({ namesI18n: z.record(z.string(), z.string()).optional(), icon: z.string().min(1, 'Icon is required'), program: z.string(), + managementType: z.enum(['USER_MANAGED', 'PLATFORM_MANAGED']), }); type MenuFormValues = z.infer; @@ -30,12 +31,24 @@ interface MenuMetaState { const api = { createRoot: ( roleId: string, - data: { name: string; namesI18n?: Record; icon?: string; program?: string } + data: { + name: string; + namesI18n?: Record; + icon?: string; + program?: string; + managementType?: 'USER_MANAGED' | 'PLATFORM_MANAGED'; + } ) => menuApi.createRoot(roleId, data), createChild: ( roleId: string, parentId: string, - data: { name: string; namesI18n?: Record; icon?: string; program?: string } + data: { + name: string; + namesI18n?: Record; + icon?: string; + program?: string; + managementType?: 'USER_MANAGED' | 'PLATFORM_MANAGED'; + } ) => menuApi.createChild(roleId, parentId, data), }; @@ -47,6 +60,19 @@ interface UseMenuFormOptions { export function useMenuForm(options?: UseMenuFormOptions) { const toast = useToast(); const { dismiss } = useOverlayController(); + const initialData = options?.initialData; + const initialDataSignature = + initialData == null + ? null + : JSON.stringify({ + roleId: initialData.roleId, + parentId: initialData.parentId, + programs: initialData.programs.map((program) => ({ + code: program.code, + path: program.path, + permissions: program.permissions, + })), + }); const complete = (data?: unknown) => { options?.onComplete?.(data); }; @@ -79,6 +105,7 @@ export function useMenuForm(options?: UseMenuFormOptions) { namesI18n: {}, icon: '', program: 'NONE', + managementType: 'USER_MANAGED', }, }); @@ -87,13 +114,12 @@ export function useMenuForm(options?: UseMenuFormOptions) { const icon = watch('icon'); useEffect(() => { - const data = options?.initialData; - if (data) { - setMetaValue('roleId', data.roleId); - setMetaValue('parentId', data.parentId); - setMetaValue('programs', data.programs); - } - }, [options?.initialData, setMetaValue]); + if (initialData == null) return; + + setMetaValue('roleId', initialData.roleId); + setMetaValue('parentId', initialData.parentId); + setMetaValue('programs', initialData.programs); + }, [initialDataSignature, setMetaValue]); const onSubmit = async (formData: MenuFormValues) => { const roleId = getMetaValues('roleId'); @@ -105,6 +131,7 @@ export function useMenuForm(options?: UseMenuFormOptions) { namesI18n: formData.namesI18n, icon: formData.icon || undefined, program: formData.program, + managementType: formData.managementType, }; setMetaValue('loading', true); diff --git a/frontend/app/src/features/menus/menu-form/ui/menu-form.tsx b/frontend/app/src/features/menus/menu-form/ui/menu-form.tsx index 865a73ff6..46ea2607e 100644 --- a/frontend/app/src/features/menus/menu-form/ui/menu-form.tsx +++ b/frontend/app/src/features/menus/menu-form/ui/menu-form.tsx @@ -16,6 +16,7 @@ import { Spinner } from '#app/shared/spinner'; import type { useMenuForm } from '../model/use-menu-form'; type MenuFormFlow = ReturnType; +const MANAGEMENT_TYPES = ['USER_MANAGED', 'PLATFORM_MANAGED'] as const; interface MenuFormProps { flow: MenuFormFlow; @@ -72,6 +73,31 @@ export function MenuForm({ flow }: MenuFormProps) { {form.errors.name?.message} + + {t('menu.managementType')} + ( + + )} + /> + {form.errors.managementType?.message} + + {LOCALE_KEYS.map((locale) => (
diff --git a/frontend/app/src/features/notifications/email-template-form/model/use-email-template-form.ts b/frontend/app/src/features/notifications/email-template-form/model/use-email-template-form.ts index dd6dfc520..322fd8e5f 100644 --- a/frontend/app/src/features/notifications/email-template-form/model/use-email-template-form.ts +++ b/frontend/app/src/features/notifications/email-template-form/model/use-email-template-form.ts @@ -87,7 +87,7 @@ export function useEmailTemplateForm(options?: UseEmailTemplateFormOptions) { .then(setAvailableVariables) .catch(() => {}); http - .get<{ brandName: string }>('/system-settings', { showToast: false }) + .get<{ brandName: string }>('/platform-settings', { showToast: false }) .then((settings) => { systemVarsRef.current = { brandName: settings.brandName, diff --git a/frontend/app/src/features/tour/tours/example-tours.ts b/frontend/app/src/features/tour/tours/example-tours.ts index 5a3c919b1..7b21624d9 100644 --- a/frontend/app/src/features/tour/tours/example-tours.ts +++ b/frontend/app/src/features/tour/tours/example-tours.ts @@ -46,7 +46,7 @@ export function registerExampleTours(): void { steps: [ { element: '[data-testid="menus-left-panel"]', - url: '/system/menus/', + url: '/settings/platform/menus/', titleKey: 'tour.menuManagement.steps.tree.title', descriptionKey: 'tour.menuManagement.steps.tree.description', side: 'right', diff --git a/frontend/app/src/features/workspaces/manage-workspaces/model/columns.test.tsx b/frontend/app/src/features/workspaces/manage-workspaces/model/columns.test.tsx index 2d6ea5370..a1c229bc5 100644 --- a/frontend/app/src/features/workspaces/manage-workspaces/model/columns.test.tsx +++ b/frontend/app/src/features/workspaces/manage-workspaces/model/columns.test.tsx @@ -10,12 +10,12 @@ const workspace: Workspace = { name: 'Default Workspace', description: null, owners: [ - { id: 'owner-1', name: '시스템 관리자', email: 'admin@deck.io' }, + { id: 'owner-1', name: '플랫폼 관리자', email: 'admin@deck.io' }, { id: 'owner-2', name: '사용자', email: 'user@deck.io' }, { id: 'owner-3', name: '휴면 사용자', email: 'dormant@deck.io' }, ], memberCount: 7, - managedType: 'SYSTEM_MANAGED', + managedType: 'PLATFORM_MANAGED', createdAt: null, updatedAt: null, }; @@ -32,7 +32,7 @@ describe('getWorkspaceColumns', () => { const html = renderToStaticMarkup(ownerColumn.renderCell(workspace)); expect(ownerColumn.header).toBe('workspaces.columns.owners'); - expect(html).toContain('시스템 관리자'); + expect(html).toContain('플랫폼 관리자'); expect(html).toContain('2'); expect(html).toContain('title='); }); diff --git a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-detail-page.tsx b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-detail-page.tsx index d9d288472..cf7c94bd9 100644 --- a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-detail-page.tsx +++ b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-detail-page.tsx @@ -28,6 +28,7 @@ export function WorkspaceDetailPage({ workspaceId, onBack }: WorkspaceDetailPage const submitRef = useRef<(() => void) | null>(null); const pendingBackRef = useRef<(() => void) | null>(null); const { openConfirm, confirmDialog } = useConfirmDialog(); + const isExternal = !!workspace?.externalReference; const loadWorkspace = () => { setLoading(true); @@ -70,16 +71,18 @@ export function WorkspaceDetailPage({ workspaceId, onBack }: WorkspaceDetailPage onBreadcrumbClick={handleBack} showRefresh={true} actions={ - submitRef.current?.()} - > - - {t('workspaces.detail.save')} - + !isExternal ? ( + submitRef.current?.()} + > + + {t('workspaces.detail.save')} + + ) : undefined } > {loading ? ( @@ -115,21 +118,25 @@ export function WorkspaceDetailPage({ workspaceId, onBack }: WorkspaceDetailPage {t('workspaces.detail.tabs.members')} ({workspace.memberCount}) - - {t('workspaces.detail.tabs.invites')} ({inviteCount}) - + {!isExternal ? ( + + {t('workspaces.detail.tabs.invites')} ({inviteCount}) + + ) : null} - + - - - + {!isExternal ? ( + + + + ) : null}
diff --git a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-form-content.test.tsx b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-form-content.test.tsx index 1b319bccd..a31f43bf8 100644 --- a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-form-content.test.tsx +++ b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-form-content.test.tsx @@ -54,18 +54,18 @@ describe('WorkspaceFormContent', () => { expect(workspaceApi.create).toHaveBeenCalledWith({ name: 'Workspace A', description: 'Description', - allowedDomains: [], + autoJoinDomains: [], }); }); expect(onComplete).toHaveBeenCalledTimes(1); }); - it('허용 도메인 UI를 숨기고 생성 요청에는 빈 배열을 명시적으로 전달해야 함', async () => { + it('자동 가입 도메인 UI를 숨기고 생성 요청에는 빈 배열을 명시적으로 전달해야 함', async () => { vi.mocked(workspaceApi.create).mockResolvedValue({} as never); render(); - expect(screen.queryByLabelText('Allowed domains')).toBeNull(); + expect(screen.queryByLabelText('Auto-join domains')).toBeNull(); fireEvent.input(screen.getByPlaceholderText('Workspace name'), { target: { value: 'Workspace A' }, @@ -76,7 +76,7 @@ describe('WorkspaceFormContent', () => { expect(workspaceApi.create).toHaveBeenCalledWith({ name: 'Workspace A', description: undefined, - allowedDomains: [], + autoJoinDomains: [], }); }); }); diff --git a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-form-content.tsx b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-form-content.tsx index 86ed1165e..1b5ae0b13 100644 --- a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-form-content.tsx +++ b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-form-content.tsx @@ -23,15 +23,15 @@ type WorkspaceFormData = z.infer; interface WorkspaceFormContentProps { mode: 'create' | 'edit'; - workspace?: Pick; + workspace?: Pick; api?: { create: ( data: CreateWorkspaceRequest - ) => Promise>; + ) => Promise>; update: ( id: string, data: UpdateWorkspaceRequest - ) => Promise>; + ) => Promise>; }; onComplete: () => void; onCancel: () => void; @@ -68,12 +68,12 @@ export function WorkspaceFormContent({ try { const description = formData.description?.trim(); - const allowedDomains = workspace?.allowedDomains ?? []; + const autoJoinDomains = workspace?.autoJoinDomains ?? []; if (mode === 'create') { await (api ?? workspaceApi).create({ name: formData.name, description: description || undefined, - allowedDomains, + autoJoinDomains, }); toast(t('workspaces.form.createdToast'), 'success'); } else { @@ -83,7 +83,7 @@ export function WorkspaceFormContent({ await (api ?? workspaceApi).update(workspaceId, { name: formData.name, description: description || undefined, - allowedDomains, + autoJoinDomains, }); toast(t('workspaces.form.updatedToast'), 'success'); } diff --git a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.test.tsx b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.test.tsx index 224d1a5bc..eee1d25fc 100644 --- a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.test.tsx +++ b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.test.tsx @@ -28,7 +28,7 @@ describe('WorkspaceInfoSection', () => { id: 'workspace-1', name: 'Workspace A', description: 'Description', - allowedDomains: ['acme.com'], + autoJoinDomains: ['acme.com'], owners: [], memberCount: 1, managedType: 'USER_MANAGED', @@ -41,7 +41,7 @@ describe('WorkspaceInfoSection', () => { vi.mocked(workspaceApi.update).mockResolvedValue({} as never); }); - it('허용 도메인 UI를 숨기고 저장 시 기존 allowedDomains를 유지해야 함', async () => { + it('자동 가입 도메인 UI를 숨기고 저장 시 기존 autoJoinDomains를 유지해야 함', async () => { let submit: (() => void) | null = null; render( @@ -55,7 +55,7 @@ describe('WorkspaceInfoSection', () => { />, ); - expect(screen.queryByLabelText('Allowed domains')).toBeNull(); + expect(screen.queryByLabelText('Auto-join domains')).toBeNull(); fireEvent.input(screen.getByPlaceholderText('Workspace name'), { target: { value: 'Workspace B' }, @@ -69,8 +69,33 @@ describe('WorkspaceInfoSection', () => { expect(workspaceApi.update).toHaveBeenCalledWith('workspace-1', { name: 'Workspace B', description: 'Description', - allowedDomains: ['acme.com'], + autoJoinDomains: ['acme.com'], }); }); }); + + it('external workspace는 읽기 전용으로 보여주고 오너 필드를 숨겨야 함', () => { + render( + , + ); + + expect( + screen.getByText( + 'External workspaces are synced from an external source and cannot be modified in Deck.' + ) + ).not.toBeNull(); + expect(screen.getByPlaceholderText('Workspace name').getAttribute('readonly')).toBe(''); + expect(screen.getByPlaceholderText('Description (optional)').getAttribute('readonly')).toBe( + '' + ); + expect(screen.queryByTestId('workspace-owner-field')).toBeNull(); + }); }); diff --git a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.tsx b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.tsx index ac43ff9ea..932bfc9fe 100644 --- a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.tsx +++ b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-info-tab.tsx @@ -33,6 +33,7 @@ export function WorkspaceInfoSection({ }: WorkspaceInfoSectionProps) { const { t } = useTranslation('system'); const toast = useToast(); + const isExternal = !!workspace.externalReference; const { register, @@ -53,7 +54,7 @@ export function WorkspaceInfoSection({ await workspaceApi.update(workspace.id, { name: formData.name, description: formData.description?.trim() || undefined, - allowedDomains: workspace.allowedDomains ?? [], + autoJoinDomains: workspace.autoJoinDomains ?? [], }); reset({ name: formData.name, @@ -102,6 +103,7 @@ export function WorkspaceInfoSection({ required placeholder={t('workspaces.form.namePlaceholder')} maxLength={100} + readOnly={isExternal} aria-invalid={errors.name ? true : undefined} /> {errors.name?.message && {errors.name.message}} @@ -115,19 +117,25 @@ export function WorkspaceInfoSection({ {...register('description')} placeholder={t('workspaces.form.descriptionPlaceholder')} maxLength={500} + readOnly={isExternal} aria-invalid={errors.description ? true : undefined} /> {errors.description?.message && {errors.description.message}}
+ {isExternal ? ( +

{t('workspaces.messages.externalLocked')}

+ ) : null} {/* Allowed domains UI stays hidden until domain ownership verification is introduced. */} - + {!isExternal ? ( + + ) : null}
{t('workspaces.detail.createdAt')} diff --git a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-members-tab.test.tsx b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-members-tab.test.tsx index b428376cb..767f2a3dd 100644 --- a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-members-tab.test.tsx +++ b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-members-tab.test.tsx @@ -8,6 +8,9 @@ import { WorkspaceMembersTab } from './workspace-members-tab'; const modalOpen = vi.fn(); const confirmExecute = vi.fn((options: any) => options.apiCall?.()); let capturedGridProps: Record | null = null; +const mockGrid = vi.hoisted(() => ({ + selectionMode: undefined as string | undefined, +})); vi.mock('#app/entities/workspace', () => ({ workspaceApi: { @@ -57,6 +60,7 @@ vi.mock('#app/shared/grid', async () => { ...actual, Grid: function MockGrid(props: any) { capturedGridProps = props; + mockGrid.selectionMode = props.selectionMode; return h('div', {}, [ h('div', { 'data-testid': 'workspace-members-grid', key: 'grid' }), h( @@ -92,10 +96,12 @@ describe('WorkspaceMembersTab', () => { beforeEach(() => { vi.clearAllMocks(); capturedGridProps = null; + mockGrid.selectionMode = undefined; + capturedGridProps = null; vi.mocked(workspaceApi.listMembers).mockResolvedValue([ { userId: 'owner-1', - userName: '시스템 관리자', + userName: '플랫폼 관리자', userEmail: 'admin@deck.io', role: 'MEMBER', isOwner: true, @@ -187,4 +193,29 @@ describe('WorkspaceMembersTab', () => { expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Remove' })).toBeNull(); }); + + it('readOnly workspace면 write 권한이 있어도 멤버 관리 액션을 숨겨야 함', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(workspaceApi.listMembers).toHaveBeenCalledWith('workspace-1'); + }); + + expect(screen.queryByRole('button', { name: 'Import' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Remove' })).toBeNull(); + expect(mockGrid.selectionMode).toBe('none'); + }); }); diff --git a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-members-tab.tsx b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-members-tab.tsx index 0d3909c66..664539f8d 100644 --- a/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-members-tab.tsx +++ b/frontend/app/src/features/workspaces/manage-workspaces/ui/workspace-members-tab.tsx @@ -13,6 +13,7 @@ import { ImportMembersModal } from './import-members-modal'; interface WorkspaceMembersTabProps { workspaceId: string; + readOnly?: boolean; } const INITIAL_QUERY: GridQuery = { @@ -23,7 +24,7 @@ const INITIAL_QUERY: GridQuery = { const UserPickerOverlayModal = withOverlayCompletion(UserPickerModal); const ImportMembersOverlayModal = withOverlayLifecycle(ImportMembersModal); -export function WorkspaceMembersTab({ workspaceId }: WorkspaceMembersTabProps) { +export function WorkspaceMembersTab({ workspaceId, readOnly = false }: WorkspaceMembersTabProps) { const { t } = useTranslation('system'); const translate = useTranslateFn(t); const [keyword, setKeyword] = useState(''); @@ -149,42 +150,44 @@ export function WorkspaceMembersTab({ workspaceId }: WorkspaceMembersTabProps) { placeholder={t('users.searchPlaceholder')} className="!w-40 shrink-0" /> -
- { - handleImport(); - }} - > - - - {t('workspaces.members.importFromWorkspace')} - - - { - handleAddMembers(); - }} - > - - {t('workspaces.members.add')} - - { - void handleRemove(); - }} - disabled={selectedIds.length === 0} - > - - {t('workspaces.members.remove')} - -
+ {!readOnly ? ( +
+ { + handleImport(); + }} + > + + + {t('workspaces.members.importFromWorkspace')} + + + { + handleAddMembers(); + }} + > + + {t('workspaces.members.add')} + + { + void handleRemove(); + }} + disabled={selectedIds.length === 0} + > + + {t('workspaces.members.remove')} + +
+ ) : null}
@@ -195,7 +198,7 @@ export function WorkspaceMembersTab({ workspaceId }: WorkspaceMembersTabProps) { query={query} rowId="userId" loading={loading} - selectionMode="multiple" + selectionMode={readOnly ? 'none' : 'multiple'} selectedRowIds={selectedIds} onQueryChange={setQuery} onSelectionChange={(selectedRows) => { diff --git a/frontend/app/src/features/workspaces/my-workspaces/model/columns.test.tsx b/frontend/app/src/features/workspaces/my-workspaces/model/columns.test.tsx index 1e42495a9..a380c4aef 100644 --- a/frontend/app/src/features/workspaces/my-workspaces/model/columns.test.tsx +++ b/frontend/app/src/features/workspaces/my-workspaces/model/columns.test.tsx @@ -12,7 +12,7 @@ const ownerWorkspace: MyWorkspace = { owners: [ { id: 'owner-id', - name: '시스템 관리자', + name: '플랫폼 관리자', email: 'admin@deck.io', }, { @@ -52,7 +52,7 @@ describe('getMyWorkspaceColumns', () => { const roleHtml = renderToStaticMarkup(roleColumn.renderCell(ownerWorkspace)); expect(ownerColumn.header).toBe('myWorkspaces.columns.owners'); - expect(ownerHtml).toContain('시스템 관리자'); + expect(ownerHtml).toContain('플랫폼 관리자'); expect(ownerHtml).toContain('1'); expect(roleHtml).toContain('Owner'); }); diff --git a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-detail-page.test.tsx b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-detail-page.test.tsx new file mode 100644 index 000000000..8f7dbb8c5 --- /dev/null +++ b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-detail-page.test.tsx @@ -0,0 +1,119 @@ +import type { ReactNode } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { myWorkspaceApi, type MyWorkspace } from '#app/entities/workspace'; +import { MyWorkspaceDetailPage } from './my-workspace-detail-page'; + +vi.mock('#app/entities/workspace', () => ({ + myWorkspaceApi: { + list: vi.fn(), + }, +})); + +vi.mock('#app/layouts', () => ({ + SystemLayout: function MockSystemLayout({ + actions, + children, + }: { + actions?: ReactNode; + children?: ReactNode; + }) { + return ( +
+
{actions}
+ {children} +
+ ); + }, +})); + +vi.mock('#app/components/ui/tabs', () => ({ + Tabs: ({ children }: { children?: ReactNode }) =>
{children}
, + TabsList: ({ children }: { children?: ReactNode }) =>
{children}
, + TabsTrigger: ({ children }: { children?: ReactNode }) => , + TabsContent: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock('#app/shared/confirm-dialog', () => ({ + useConfirmDialog: () => ({ + openConfirm: vi.fn(), + confirmDialog: null, + }), +})); + +vi.mock('#app/shared/splitter', () => ({ + Splitter: ({ left, right }: { left?: ReactNode; right?: ReactNode }) => ( +
+ {left} + {right} +
+ ), +})); + +vi.mock('#app/shared/card', () => ({ + Card: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock('#app/shared/spinner', () => ({ + Spinner: () =>
, +})); + +vi.mock('#app/shared/icon', () => ({ + Icon: () => , +})); + +vi.mock('#app/shared/authorization', () => ({ + AuthorizedActionButton: ({ + children, + disabled, + }: { + children?: ReactNode; + disabled?: boolean; + }) => , +})); + +vi.mock('./my-workspace-info-tab', () => ({ + MyWorkspaceInfoSection: () =>
, +})); + +vi.mock('./my-workspace-members-tab', () => ({ + MyWorkspaceMembersTab: () =>
, +})); + +vi.mock('./my-workspace-invites-tab', () => ({ + MyWorkspaceInvitesTab: () =>
, +})); + +describe('MyWorkspaceDetailPage', () => { + const externalOwnerWorkspace: MyWorkspace = { + id: 'workspace-1', + name: 'External Workspace', + description: 'Synced from AIP', + owners: [{ id: 'owner-1', name: 'Owner One', email: 'owner@example.com' }], + memberCount: 4, + managedType: 'PLATFORM_MANAGED', + externalReference: { source: 'AIP', externalId: 'aip-org-1' }, + role: 'OWNER', + createdAt: '2026-03-30T21:48:46', + updatedAt: '2026-03-30T21:48:46', + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(myWorkspaceApi.list).mockResolvedValue([externalOwnerWorkspace] as never); + }); + + it('external owner workspace는 저장 액션과 invites 탭을 숨기고 members만 보여야 함', async () => { + render(); + + await waitFor(() => { + expect(myWorkspaceApi.list).toHaveBeenCalledTimes(1); + }); + + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull(); + expect(screen.getByTestId('my-workspace-info-tab')).toBeDefined(); + expect(screen.getByTestId('my-workspace-members-tab')).toBeDefined(); + expect(screen.queryByTestId('my-workspace-invites-tab')).toBeNull(); + expect(screen.queryByRole('button', { name: /Invites/i })).toBeNull(); + }); +}); diff --git a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-detail-page.tsx b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-detail-page.tsx index 5d66c9b63..86f334ecd 100644 --- a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-detail-page.tsx +++ b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-detail-page.tsx @@ -48,9 +48,11 @@ export function MyWorkspaceDetailPage({ workspaceId, onBack }: MyWorkspaceDetail }, [workspaceId]); const isOwner = workspace?.role === 'OWNER'; + const isExternal = !!workspace?.externalReference; + const canManage = isOwner && !isExternal; const handleBack = () => { - if (!isOwner || !isDirty) { + if (!canManage || !isDirty) { onBack(); return; } @@ -72,7 +74,7 @@ export function MyWorkspaceDetailPage({ workspaceId, onBack }: MyWorkspaceDetail onBreadcrumbClick={handleBack} showRefresh={true} actions={ - isOwner ? ( + canManage ? ( { submitRef.current = submit; } @@ -119,7 +121,7 @@ export function MyWorkspaceDetailPage({ workspaceId, onBack }: MyWorkspaceDetail onValueChange={(value) => setActiveTab(value as 'members' | 'invites')} className="flex min-h-0 min-w-0 flex-1 flex-col" > - {isOwner ? ( + {canManage ? ( {t('workspaces.detail.tabs.members')} ({workspace.memberCount}) @@ -134,7 +136,7 @@ export function MyWorkspaceDetailPage({ workspaceId, onBack }: MyWorkspaceDetail - {isOwner && ( + {canManage && ( { id: 'workspace-1', name: 'Workspace A', description: 'Description', - allowedDomains: ['acme.com'], + autoJoinDomains: ['acme.com'], owners: [], memberCount: 1, managedType: 'USER_MANAGED', @@ -42,7 +42,7 @@ describe('MyWorkspaceInfoSection', () => { vi.mocked(myWorkspaceApi.update).mockResolvedValue({} as never); }); - it('허용 도메인 UI를 숨기고 저장 시 기존 allowedDomains를 유지해야 함', async () => { + it('자동 가입 도메인 UI를 숨기고 저장 시 기존 autoJoinDomains를 유지해야 함', async () => { let submit: (() => void) | null = null; render( @@ -56,7 +56,7 @@ describe('MyWorkspaceInfoSection', () => { />, ); - expect(screen.queryByLabelText('Allowed domains')).toBeNull(); + expect(screen.queryByLabelText('Auto-join domains')).toBeNull(); fireEvent.input(screen.getByPlaceholderText('Workspace name'), { target: { value: 'Workspace B' }, @@ -70,8 +70,31 @@ describe('MyWorkspaceInfoSection', () => { expect(myWorkspaceApi.update).toHaveBeenCalledWith('workspace-1', { name: 'Workspace B', description: 'Description', - allowedDomains: ['acme.com'], + autoJoinDomains: ['acme.com'], }); }); }); + + it('external workspace는 owner여도 읽기 전용으로 보여주고 오너 필드를 숨겨야 함', () => { + render( + , + ); + + expect( + screen.getByText( + 'External workspaces are synced from an external source and cannot be modified in Deck.' + ) + ).not.toBeNull(); + expect(screen.getByDisplayValue('Workspace A').getAttribute('readonly')).toBe(''); + expect(screen.getByDisplayValue('Description').getAttribute('readonly')).toBe(''); + expect(screen.queryByTestId('workspace-owner-field')).toBeNull(); + }); }); diff --git a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-info-tab.tsx b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-info-tab.tsx index 62313f171..afc71f13d 100644 --- a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-info-tab.tsx +++ b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-info-tab.tsx @@ -33,6 +33,8 @@ export function MyWorkspaceInfoSection({ }: MyWorkspaceInfoSectionProps) { const { t } = useTranslation('system'); const isOwner = workspace.role === 'OWNER'; + const isExternal = !!workspace.externalReference; + const isReadOnly = !isOwner || isExternal; const toast = useToast(); const { @@ -54,7 +56,7 @@ export function MyWorkspaceInfoSection({ await myWorkspaceApi.update(workspace.id, { name: formData.name, description: formData.description?.trim() || undefined, - allowedDomains: workspace.allowedDomains ?? [], + autoJoinDomains: workspace.autoJoinDomains ?? [], }); reset({ name: formData.name, @@ -98,10 +100,12 @@ export function MyWorkspaceInfoSection({ ); - if (!isOwner) { + if (isReadOnly) { return (
-

{t('myWorkspaces.readOnly')}

+

+ {isExternal ? t('workspaces.messages.externalLocked') : t('myWorkspaces.readOnly')} +

{renderReadOnlyField('my-workspace-name', t('workspaces.form.nameLabel'), workspace.name)} {workspace.description && renderReadOnlyField( diff --git a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-members-tab.test.tsx b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-members-tab.test.tsx index 3c0cbc729..6981bab2f 100644 --- a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-members-tab.test.tsx +++ b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-members-tab.test.tsx @@ -81,7 +81,7 @@ const ownerWorkspace: MyWorkspace = { id: 'workspace-1', name: 'Default Workspace', description: null, - owners: [{ id: 'owner-1', name: '시스템 관리자', email: 'admin@deck.io' }], + owners: [{ id: 'owner-1', name: '플랫폼 관리자', email: 'admin@deck.io' }], memberCount: 3, managedType: 'USER_MANAGED', role: 'OWNER', @@ -106,7 +106,7 @@ describe('MyWorkspaceMembersTab', () => { vi.mocked(myWorkspaceApi.listMembers).mockResolvedValue([ { userId: 'owner-1', - userName: '시스템 관리자', + userName: '플랫폼 관리자', userEmail: 'admin@deck.io', isOwner: true, createdAt: '2026-03-18T01:20:01', @@ -179,4 +179,34 @@ describe('MyWorkspaceMembersTab', () => { expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Remove' })).toBeNull(); }); + + it('external workspace면 owner 권한이 있어도 멤버 관리 액션을 숨겨야 함', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(myWorkspaceApi.listMembers).toHaveBeenCalledWith('workspace-1'); + }); + + expect(screen.queryByRole('button', { name: 'Import' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Remove' })).toBeNull(); + }); }); diff --git a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-members-tab.tsx b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-members-tab.tsx index 6d24448f5..77a39805f 100644 --- a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-members-tab.tsx +++ b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-members-tab.tsx @@ -26,7 +26,7 @@ const ImportMembersOverlayModal = withOverlayLifecycle(ImportMembersModal); export function MyWorkspaceMembersTab({ workspace }: MyWorkspaceMembersTabProps) { const { t } = useTranslation('system'); const translate = useTranslateFn(t); - const isOwner = workspace.role === 'OWNER'; + const canManage = workspace.role === 'OWNER' && !workspace.externalReference; const [keyword, setKeyword] = useState(''); const [appliedKeyword, setAppliedKeyword] = useState(''); const [selectedIds, setSelectedIds] = useState([]); @@ -150,7 +150,7 @@ export function MyWorkspaceMembersTab({ workspace }: MyWorkspaceMembersTabProps) placeholder={t('users.searchPlaceholder')} className="!w-40 shrink-0" /> - {isOwner && ( + {canManage && (
{ diff --git a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspaces-page-actions.test.tsx b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspaces-page-actions.test.tsx index 86e268db8..ad2affeda 100644 --- a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspaces-page-actions.test.tsx +++ b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspaces-page-actions.test.tsx @@ -1,57 +1,56 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { AuthorizationProvider } from '#app/shared/authorization'; -import { auth } from '#app/features/auth'; +import type { MyWorkspace } from '#app/entities/workspace'; import { MyWorkspacesPageActions } from './my-workspaces-page-actions'; -describe('MyWorkspacesPageActions', () => { - function renderWithPermissions( - ui: React.ReactElement, - permissions: string[] = [auth.P.MY_WORKSPACE_WRITE], - ) { - return render( - - {ui} - , - ); - } +vi.mock('#app/shared/authorization', () => ({ + AuthorizedActionButton: ({ + children, + disabled, + onClick, + }: { + children?: ReactNode; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), +})); + +vi.mock('#app/shared/icon', () => ({ + Icon: () => , +})); - const baseProps = { - selectedIds: [] as string[], - onCreateWorkspace: vi.fn(), - onDeleteSelected: vi.fn(), - onLeave: vi.fn(), +describe('MyWorkspacesPageActions', () => { + const externalWorkspace: MyWorkspace = { + id: 'workspace-1', + name: 'External Workspace', + description: null, + owners: [{ id: 'owner-1', name: 'Owner One', email: 'owner@example.com' }], + memberCount: 3, + managedType: 'PLATFORM_MANAGED', + externalReference: { source: 'AIP', externalId: 'aip-org-1' }, + role: 'OWNER', + createdAt: null, + updatedAt: null, }; - it('owner가 선택되어도 leave 버튼을 보여주고 동작해야 함', () => { - const onLeave = vi.fn(); - - renderWithPermissions( + it('external workspace selection이면 leave를 숨기고 delete를 비활성화해야 함', () => { + render( , ); - const button = screen.getByRole('button', { name: /leave/i }); - fireEvent.click(button); - - expect(onLeave).toHaveBeenCalledTimes(1); - }); - - it('선택된 workspace가 없으면 leave 버튼을 숨겨야 함', () => { - renderWithPermissions(); - - expect(screen.queryByRole('button', { name: /leave/i })).toBeNull(); - }); - - it('write 권한이 없으면 create와 delete를 숨겨야 함', () => { - renderWithPermissions(, []); - - expect(screen.queryByRole('button', { name: /create/i })).toBeNull(); - expect(screen.queryByRole('button', { name: /delete/i })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Leave' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Delete' }).getAttribute('disabled')).toBe(''); }); }); diff --git a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspaces-page-actions.tsx b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspaces-page-actions.tsx index 6669a7fc4..65c324c5f 100644 --- a/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspaces-page-actions.tsx +++ b/frontend/app/src/features/workspaces/my-workspaces/ui/my-workspaces-page-actions.tsx @@ -1,10 +1,12 @@ +import type { MyWorkspace } from '#app/entities/workspace'; import { Icon } from '#app/shared/icon'; import { AuthorizedActionButton } from '#app/shared/authorization'; import { useTranslation } from 'react-i18next'; interface MyWorkspacesPageActionsProps { - selected: { role: 'OWNER' | 'MEMBER' } | null; + selected: MyWorkspace | null; selectedIds: string[]; + hasExternalSelection: boolean; onCreateWorkspace: () => void; onDeleteSelected: () => void; onLeave: () => void; @@ -13,13 +15,14 @@ interface MyWorkspacesPageActionsProps { export function MyWorkspacesPageActions({ selected, selectedIds, + hasExternalSelection, onCreateWorkspace, onDeleteSelected, onLeave, }: MyWorkspacesPageActionsProps) { const { t } = useTranslation(['common', 'system']); - const canLeave = selected != null; + const canLeave = selected != null && !selected.externalReference; return ( <> @@ -47,7 +50,7 @@ export function MyWorkspacesPageActions({ size="sm" variant="destructive" onClick={onDeleteSelected} - disabled={selectedIds.length === 0} + disabled={selectedIds.length === 0 || hasExternalSelection} > {t('common:button.delete')} diff --git a/frontend/app/src/features/workspaces/shared/workspace-owner-field.test.tsx b/frontend/app/src/features/workspaces/shared/workspace-owner-field.test.tsx index b7ba13fbc..98a63a1c8 100644 --- a/frontend/app/src/features/workspaces/shared/workspace-owner-field.test.tsx +++ b/frontend/app/src/features/workspaces/shared/workspace-owner-field.test.tsx @@ -17,7 +17,7 @@ vi.mock('#app/shared/overlay', () => ({ })); const owners: WorkspaceOwner[] = [ - { id: 'owner-1', name: '시스템 관리자', email: 'admin@deck.io' }, + { id: 'owner-1', name: '플랫폼 관리자', email: 'admin@deck.io' }, { id: 'owner-2', name: '매니저', email: 'manager@deck.io' }, { id: 'owner-3', name: '휴면 사용자', email: 'dormant@deck.io' }, ]; @@ -26,7 +26,7 @@ const members: WorkspaceMember[] = [ { id: 'member-1', userId: 'owner-1', - userName: '시스템 관리자', + userName: '플랫폼 관리자', userEmail: 'admin@deck.io', isOwner: true, createdAt: null, @@ -94,9 +94,9 @@ describe('WorkspaceOwnerField', () => { const input = screen.getByLabelText('Owners') as HTMLInputElement; - expect(input.value).toContain('시스템 관리자'); + expect(input.value).toContain('플랫폼 관리자'); expect(input.value).toContain('2'); - expect(input.title).toContain('시스템 관리자 (admin@deck.io)'); + expect(input.title).toContain('플랫폼 관리자 (admin@deck.io)'); expect(input.title).toContain('휴면 사용자 (dormant@deck.io)'); }); @@ -157,7 +157,7 @@ describe('WorkspaceOwnerField', () => { const options = modalOpen.mock.calls[0]![1]; await options.props.afterComplete?.([ - { id: 'owner-1', name: '시스템 관리자', email: 'admin@deck.io' }, + { id: 'owner-1', name: '플랫폼 관리자', email: 'admin@deck.io' }, { id: 'user-1', name: '사용자', email: 'user@deck.io' }, ]); diff --git a/frontend/app/src/layouts/console-layout.tsx b/frontend/app/src/layouts/console-layout.tsx new file mode 100644 index 000000000..45e04c617 --- /dev/null +++ b/frontend/app/src/layouts/console-layout.tsx @@ -0,0 +1,4 @@ +export { + SystemLayout as ConsoleLayout, + type SystemLayoutProps as ConsoleLayoutProps, +} from './system-layout'; diff --git a/frontend/app/src/layouts/index.ts b/frontend/app/src/layouts/index.ts index 84872176b..a8fecb0fd 100644 --- a/frontend/app/src/layouts/index.ts +++ b/frontend/app/src/layouts/index.ts @@ -1,3 +1,4 @@ +export { ConsoleLayout } from './console-layout'; export { ErrorPage } from './error-page'; export { SettingsLayout } from './settings-layout'; export { StandaloneLayout } from './standalone-layout'; diff --git a/frontend/app/src/layouts/layout-landmarks-standards.test.tsx b/frontend/app/src/layouts/layout-landmarks-standards.test.tsx index 1e7a5116e..032bc102f 100644 --- a/frontend/app/src/layouts/layout-landmarks-standards.test.tsx +++ b/frontend/app/src/layouts/layout-landmarks-standards.test.tsx @@ -54,8 +54,8 @@ vi.mock('#app/shared/theme-cookie', () => ({ getThemeCookie: () => mockGetThemeCookie(), })); -vi.mock('#app/entities/system-settings', () => ({ - systemSettingsApi: { +vi.mock('#app/entities/platform-settings', () => ({ + platformSettingsApi: { getPublicBranding: () => mockGetPublicBranding(), }, })); diff --git a/frontend/app/src/layouts/standalone-layout.test.tsx b/frontend/app/src/layouts/standalone-layout.test.tsx index 1c449e45f..2abb89899 100644 --- a/frontend/app/src/layouts/standalone-layout.test.tsx +++ b/frontend/app/src/layouts/standalone-layout.test.tsx @@ -10,8 +10,8 @@ vi.mock('#app/shared/theme-cookie', () => ({ getThemeCookie: () => mockGetThemeCookie(), })); -vi.mock('#app/entities/system-settings', () => ({ - systemSettingsApi: { +vi.mock('#app/entities/platform-settings', () => ({ + platformSettingsApi: { getPublicBranding: () => mockGetPublicBranding(), }, })); diff --git a/frontend/app/src/layouts/standalone-layout.tsx b/frontend/app/src/layouts/standalone-layout.tsx index 3719db900..0fc2bc76c 100644 --- a/frontend/app/src/layouts/standalone-layout.tsx +++ b/frontend/app/src/layouts/standalone-layout.tsx @@ -4,7 +4,7 @@ * - 브랜딩 로드 */ -import { systemSettingsApi } from '#app/entities/system-settings'; +import { platformSettingsApi } from '#app/entities/platform-settings'; import { usePageLoaded } from '#app/shared/page'; import { initBranding, @@ -46,7 +46,7 @@ export function StandaloneLayout({ const theme = (savedTheme as 'light' | 'dark') || getSystemTheme(); applyTheme(theme); - void systemSettingsApi + void platformSettingsApi .getPublicBranding() .then((branding) => { if (!active) return; diff --git a/frontend/app/src/layouts/system-layout.tsx b/frontend/app/src/layouts/system-layout.tsx index f2907b622..5bda0f6d1 100644 --- a/frontend/app/src/layouts/system-layout.tsx +++ b/frontend/app/src/layouts/system-layout.tsx @@ -11,7 +11,7 @@ import { Spinner } from '#app/shared/spinner'; import { initScrollHint } from '#app/shared/scroll-hint'; import { initThemeSync } from '#app/shared/utils'; -interface SystemLayoutProps { +export interface SystemLayoutProps { title?: string; icon?: string; breadcrumb?: string; diff --git a/frontend/app/src/pages/account/canonical-imports.test.ts b/frontend/app/src/pages/account/canonical-imports.test.ts index 401c5692a..17c2e4da3 100644 --- a/frontend/app/src/pages/account/canonical-imports.test.ts +++ b/frontend/app/src/pages/account/canonical-imports.test.ts @@ -3,11 +3,12 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const ACCOUNT_PAGE_FILES = [ - 'src/pages/account/setting/setting.page.tsx', - 'src/pages/account/setting/general-tab.tsx', - 'src/pages/account/setting/branding-tab.tsx', - 'src/pages/account/setting/auth-tab.tsx', - 'src/pages/account/setting/roles-tab.tsx', + 'src/pages/settings/tabs/general-tab.tsx', + 'src/pages/settings/tabs/branding-tab.tsx', + 'src/pages/settings/tabs/auth-tab.tsx', + 'src/pages/settings/tabs/roles-tab.tsx', + 'src/pages/settings/tabs/workspace-tab.tsx', + 'src/pages/settings/tabs/menus-tab.tsx', 'src/pages/account/profile/info-tab.tsx', 'src/pages/account/profile/security-tab.tsx', 'src/pages/account/profile/security-password-section.tsx', diff --git a/frontend/app/src/pages/account/profile/connections-tab.test.tsx b/frontend/app/src/pages/account/profile/connections-tab.test.tsx index 62bd8aec7..99e26ba59 100644 --- a/frontend/app/src/pages/account/profile/connections-tab.test.tsx +++ b/frontend/app/src/pages/account/profile/connections-tab.test.tsx @@ -75,11 +75,11 @@ describe('ConnectionsTab', () => { (authApi.config as Mock).mockResolvedValue(mockAuthConfig); Object.defineProperty(window, 'location', { - value: { - search: '', - pathname: '/account/profile', - href: '', - }, + value: { + search: '', + pathname: '/settings/account/profile', + href: '', + }, writable: true, }); @@ -286,7 +286,7 @@ describe('ConnectionsTab', () => { Object.defineProperty(window, 'location', { value: { search: '?linked=true', - pathname: '/account/profile', + pathname: '/settings/account/profile', href: '', }, writable: true, @@ -305,7 +305,7 @@ describe('ConnectionsTab', () => { Object.defineProperty(window, 'location', { value: { search: '?error=Connection%20failed', - pathname: '/account/profile', + pathname: '/settings/account/profile', href: '', }, writable: true, diff --git a/frontend/app/src/pages/account/profile/preferences-tab.test.tsx b/frontend/app/src/pages/account/profile/preferences-tab.test.tsx index 3dda34dcb..143de8910 100644 --- a/frontend/app/src/pages/account/profile/preferences-tab.test.tsx +++ b/frontend/app/src/pages/account/profile/preferences-tab.test.tsx @@ -184,7 +184,7 @@ describe('PreferencesTab', () => { user.set({ id: 'user-1', username: 'admin', - name: '시스템 관리자', + name: '플랫폼 관리자', email: 'admin@deck.io', roleIds: ['role-1'], roles: [{ id: 'role-1', label: 'Administrator' }], diff --git a/frontend/app/src/pages/account/setting/globalization-tab.test.tsx b/frontend/app/src/pages/account/setting/globalization-tab.test.tsx deleted file mode 100644 index 8c5c6504c..000000000 --- a/frontend/app/src/pages/account/setting/globalization-tab.test.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; -import { GlobalizationTab } from './globalization-tab'; -import { systemSettingsApi } from '#app/entities/system-settings'; -import { showToast } from '#app/shared/toast'; -import { STANDARD_COUNTRY_CODES } from '#app/shared/globalization/standard-codes'; - -vi.mock('#app/entities/system-settings', () => ({ - systemSettingsApi: { - updateGlobalizationPolicy: vi.fn(), - }, - setSystemSettings: vi.fn(), -})); - -vi.mock('#app/shared/toast', () => ({ - showToast: vi.fn(), -})); - -const settings = { - brandName: 'Deck', - baseUrl: 'https://deck.test', - countryPolicy: { - enabledCountryCodes: ['KR'], - defaultCountryCode: 'KR', - }, - currencyPolicy: { - defaultCurrencyCode: 'KRW', - preferredCurrencyCodes: ['KRW', 'USD', 'JPY', 'EUR'], - }, -}; - -describe('GlobalizationTab', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - cleanup(); - }); - - it('국가/통화 정책 필드를 렌더링해야 한다', () => { - render(); - - expect(screen.getByText('Country Policy')).toBeDefined(); - expect(screen.getByText('Currency Preferences')).toBeDefined(); - expect(screen.getByTestId('countries-in-use-input')).toBeDefined(); - expect(screen.getByTestId('primary-country-input')).toBeDefined(); - expect(screen.getByTestId('preferred-currencies-input')).toBeDefined(); - expect(screen.getByTestId('primary-currency-input')).toBeDefined(); - expect(screen.getAllByTestId('settings-row')).toHaveLength(4); - }); - - it('국가 전체 선택 액션을 지원해야 한다', async () => { - render(); - - fireEvent.click(screen.getByTestId('countries-in-use-input')); - fireEvent.click(await screen.findByTestId('countries-in-use-input-select-all')); - - await waitFor(() => { - expect(screen.getByTestId('countries-in-use-input').textContent).toContain( - `${STANDARD_COUNTRY_CODES.length} selected` - ); - }); - - fireEvent.click(await screen.findByTestId('countries-in-use-input-select-all')); - - await waitFor(() => { - expect(screen.getByTestId('countries-in-use-input').textContent).toContain('Select countries'); - }); - }); - - it('국가/통화 정책을 저장해야 한다', async () => { - const response = { - ...settings, - countryPolicy: { - enabledCountryCodes: ['KR', 'US', 'JP'], - defaultCountryCode: 'JP', - }, - currencyPolicy: { - defaultCurrencyCode: 'USD', - preferredCurrencyCodes: ['USD', 'KRW', 'EUR'], - }, - }; - - (systemSettingsApi.updateGlobalizationPolicy as Mock).mockResolvedValue(response); - - render(); - - fireEvent.click(screen.getByTestId('countries-in-use-input')); - fireEvent.click(await screen.findByTestId('countries-in-use-input-option-US')); - fireEvent.click(await screen.findByTestId('countries-in-use-input-option-JP')); - await waitFor(() => { - expect(screen.getByTestId('countries-in-use-input').textContent).toContain('3 selected'); - }); - - fireEvent.click(screen.getByTestId('primary-country-input')); - fireEvent.click(await screen.findByTestId('primary-country-input-option-JP')); - await waitFor(() => { - expect(screen.getByTestId('primary-country-input').textContent).toMatch(/Japan \(JP\)/i); - }); - - fireEvent.click(screen.getByTestId('preferred-currencies-input')); - fireEvent.click(await screen.findByTestId('preferred-currencies-input-option-JPY')); - await waitFor(() => { - expect(screen.getByTestId('preferred-currencies-input').textContent).toContain('3 selected'); - }); - - fireEvent.click(screen.getByTestId('primary-currency-input')); - fireEvent.click(await screen.findByTestId('primary-currency-input-option-USD')); - await waitFor(() => { - expect(screen.getByTestId('primary-currency-input').textContent).toMatch( - /US Dollar \(USD\)/i - ); - }); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); - - await waitFor(() => { - expect(systemSettingsApi.updateGlobalizationPolicy).toHaveBeenCalledWith({ - countryPolicy: { - enabledCountryCodes: ['KR', 'US', 'JP'], - defaultCountryCode: 'JP', - }, - currencyPolicy: { - defaultCurrencyCode: 'USD', - preferredCurrencyCodes: ['USD', 'KRW', 'EUR'], - }, - }); - }); - }); - - it('primary 값은 선택 목록 안에서만 고를 수 있어야 한다', async () => { - render(); - - expect(screen.getByTestId('primary-country-input').textContent).toMatch(/Korea \(KR\)/i); - expect(screen.getByTestId('primary-currency-input').textContent).toMatch( - /South Korean Won \(KRW\)/i - ); - }); - - it('기본 국가 선택도 검색 가능한 combobox로 동작해야 한다', async () => { - render( - - ); - - fireEvent.click(screen.getByTestId('primary-country-input')); - fireEvent.input(screen.getByPlaceholderText('Search countries'), { - target: { value: 'United' }, - }); - - expect(await screen.findByTestId('primary-country-input-option-US')).toBeDefined(); - }); - - it('국가가 비어 있으면 저장하지 않고 에러를 보여줘야 한다', async () => { - render(); - - fireEvent.click(screen.getByTestId('countries-in-use-input')); - fireEvent.click(await screen.findByTestId('countries-in-use-input-select-all')); - fireEvent.click(await screen.findByTestId('countries-in-use-input-select-all')); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); - - await waitFor(() => { - expect(systemSettingsApi.updateGlobalizationPolicy).not.toHaveBeenCalled(); - expect(showToast).toHaveBeenCalledWith('Select at least one country.', 'error'); - }); - }); - - it('비정상 국가 정책을 정규화해도 기본 상수는 오염되지 않아야 한다', async () => { - const { rerender } = render( - - ); - - expect(screen.getByTestId('primary-country-input').textContent).toMatch(/United States \(US\)/i); - - rerender(); - - await waitFor(() => { - expect(screen.getByTestId('primary-country-input').textContent).toMatch(/Korea \(KR\)/i); - }); - }); -}); diff --git a/frontend/app/src/pages/account/setting/globalization-tab.tsx b/frontend/app/src/pages/account/setting/globalization-tab.tsx deleted file mode 100644 index c67872ca2..000000000 --- a/frontend/app/src/pages/account/setting/globalization-tab.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - setSystemSettings, - systemSettingsApi, - type CountryPolicy, - type CurrencyPolicy, - type SystemSettings, -} from '#app/entities/system-settings'; -import { - buildCountryLookupOptions, - buildCurrencyLookupOptions, - CountryCodeSelect, - CurrencyCodeSelect, - DEFAULT_COUNTRY_POLICY, - DEFAULT_CURRENCY_POLICY, - normalizeCountryPolicyValue, - normalizeCurrencyPolicyValue, - PolicyMultiSelect, -} from '#app/shared/globalization'; -import { - SettingsActionGroup, - SettingsControl, - SettingsPageActions, - SettingsRow, - SettingsSection, - SettingsSubmitButton, -} from '#app/shared/settings-list'; -import { showToast } from '#app/shared/toast'; - -interface GlobalizationTabProps { - settings: SystemSettings | null; - onSettingsChange?: (settings: SystemSettings) => void; -} - -function normalizeCountryPolicy(policy?: CountryPolicy | null): CountryPolicy { - return normalizeCountryPolicyValue(policy); -} - -function normalizeCurrencyPolicy(policy?: CurrencyPolicy | null): CurrencyPolicy { - return normalizeCurrencyPolicyValue(policy); -} - -export function GlobalizationTab({ settings, onSettingsChange }: GlobalizationTabProps) { - const { t, i18n } = useTranslation('account'); - const locale = i18n.resolvedLanguage ?? i18n.language; - const countryOptions = useMemo(() => buildCountryLookupOptions(locale), [locale]); - const currencyOptions = useMemo(() => buildCurrencyLookupOptions(locale), [locale]); - const [countryPolicy, setCountryPolicy] = useState(DEFAULT_COUNTRY_POLICY); - const [currencyPolicy, setCurrencyPolicy] = useState(DEFAULT_CURRENCY_POLICY); - const [saving, setSaving] = useState(false); - - useEffect(() => { - setCountryPolicy(normalizeCountryPolicy(settings?.countryPolicy)); - setCurrencyPolicy(normalizeCurrencyPolicy(settings?.currencyPolicy)); - }, [settings]); - - function handleEnabledCountryCodesChange(nextCodes: string[]) { - setCountryPolicy((current) => { - const enabledCountryCodes = Array.from(new Set(nextCodes)); - return { - enabledCountryCodes, - defaultCountryCode: enabledCountryCodes.includes(current.defaultCountryCode) - ? current.defaultCountryCode - : (enabledCountryCodes[0] ?? current.defaultCountryCode), - }; - }); - } - - function handleDefaultCountryCodeChange(nextCode: string) { - setCountryPolicy((current) => - normalizeCountryPolicy({ - enabledCountryCodes: current.enabledCountryCodes, - defaultCountryCode: nextCode, - }) - ); - } - - function handlePreferredCurrencyCodesChange(nextCodes: string[]) { - setCurrencyPolicy((current) => { - const preferredCurrencyCodes = Array.from(new Set(nextCodes)); - return { - preferredCurrencyCodes, - defaultCurrencyCode: preferredCurrencyCodes.includes(current.defaultCurrencyCode) - ? current.defaultCurrencyCode - : (preferredCurrencyCodes[0] ?? current.defaultCurrencyCode), - }; - }); - } - - function handleDefaultCurrencyCodeChange(nextCode: string) { - setCurrencyPolicy((current) => - normalizeCurrencyPolicy({ - preferredCurrencyCodes: current.preferredCurrencyCodes, - defaultCurrencyCode: nextCode, - }) - ); - } - - const save = async (event: React.FormEvent) => { - event.preventDefault(); - - if (countryPolicy.enabledCountryCodes.length === 0) { - showToast(t('setting.globalization.countryRequired'), 'error'); - return; - } - - if (currencyPolicy.preferredCurrencyCodes.length === 0) { - showToast(t('setting.globalization.currencyRequired'), 'error'); - return; - } - - const nextCountryPolicy = normalizeCountryPolicy(countryPolicy); - const nextCurrencyPolicy = normalizeCurrencyPolicy(currencyPolicy); - - setSaving(true); - try { - const nextSettings = await systemSettingsApi.updateGlobalizationPolicy({ - countryPolicy: nextCountryPolicy, - currencyPolicy: nextCurrencyPolicy, - }); - setSystemSettings(nextSettings); - setCountryPolicy(normalizeCountryPolicy(nextSettings.countryPolicy ?? nextCountryPolicy)); - setCurrencyPolicy(normalizeCurrencyPolicy(nextSettings.currencyPolicy ?? nextCurrencyPolicy)); - onSettingsChange?.(nextSettings); - showToast(t('setting.globalization.savedMessage'), 'success'); - } finally { - setSaving(false); - } - }; - - return ( -
-
- - - - - t('setting.globalization.selectedCount', { count }) - } - triggerTestId="countries-in-use-input" - optionTestIdPrefix="countries-in-use-input-option" - selectAllTestId="countries-in-use-input-select-all" - /> - - } - /> - - - - - } - /> - - - - - - t('setting.globalization.selectedCount', { count }) - } - triggerTestId="preferred-currencies-input" - optionTestIdPrefix="preferred-currencies-input-option" - selectAllTestId="preferred-currencies-input-select-all" - /> - - } - /> - - - - - } - /> - - - - - - -
-
- ); -} diff --git a/frontend/app/src/pages/account/setting/setting.page.test.tsx b/frontend/app/src/pages/account/setting/setting.page.test.tsx deleted file mode 100644 index 2f42df625..000000000 --- a/frontend/app/src/pages/account/setting/setting.page.test.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; -import type { ReactNode } from 'react'; -import { SettingPage } from './setting.page'; -import { accountApi } from '#app/entities/account'; -import { systemSettingsApi } from '#app/entities/system-settings'; - -// Mocks -vi.mock('#app/entities/account', () => ({ - accountApi: { - me: vi.fn(), - }, -})); - -vi.mock('#app/entities/system-settings', async () => { - const actual = await vi.importActual( - '#app/entities/system-settings' - ); - return { - ...actual, - systemSettingsApi: { - ...actual.systemSettingsApi, - get: vi.fn(), - }, - setSystemSettings: vi.fn(), - }; -}); - -vi.mock('#app/shared/branding', async () => { - const { createElement: h } = await import('react'); - return { - initBranding: vi.fn(), - BrandLogo: ({ className }: { className?: string }) => - h('img', { className: `brand-logo ${className ?? ''}`.trim(), alt: 'logo' }), - }; -}); - -vi.mock('#app/components/ui/button', async () => { - const { createElement: h } = await import('react'); - return { - Button: function MockButton(props: Record) { - const buttonProps = props as React.HTMLAttributes & { - children?: ReactNode; - }; - return h('button', buttonProps, buttonProps.children); - }, - }; -}); -vi.mock('#app/shared/icon', async () => { - const { createElement: h } = await import('react'); - return { - Icon: function MockIcon(props: { name?: string }) { - return h('svg', { 'data-testid': `icon-${props.name ?? 'mock'}` }); - }, - }; -}); -vi.mock('#app/shared/standalone-page-header', async () => { - const { createElement: h } = await import('react'); - return { - StandalonePageHeader: function MockStandalonePageHeader(props: { title: string }) { - return h('h2', { 'data-testid': 'standalone-page-header' }, props.title); - }, - }; -}); -vi.mock('#app/components/ui/tabs', async () => { - const actual = - await vi.importActual('#app/components/ui/tabs'); - - return { - ...actual, - Tabs: actual.Tabs, - TabsList: actual.TabsList, - TabsTrigger: actual.TabsTrigger, - }; -}); -vi.mock('./general-tab', async () => { - const { createElement: h } = await import('react'); - return { - GeneralTab: function MockGeneralTab() { - return h('div', { 'data-testid': 'general-tab' }, 'General Tab Content'); - }, - }; -}); - -vi.mock('./workspace-tab', async () => { - const { createElement: h } = await import('react'); - return { - WorkspaceTab: function MockWorkspaceTab() { - return h('div', { 'data-testid': 'workspace-tab' }, 'Workspace Tab Content'); - }, - }; -}); - -vi.mock('./globalization-tab', async () => { - const { createElement: h } = await import('react'); - return { - GlobalizationTab: function MockGlobalizationTab() { - return h('div', { 'data-testid': 'globalization-tab' }, 'Globalization Tab Content'); - }, - }; -}); - -vi.mock('./branding-tab', async () => { - const { createElement: h } = await import('react'); - return { - BrandingTab: function MockBrandingTab() { - return h('div', { 'data-testid': 'branding-tab' }, 'Branding Tab Content'); - }, - }; -}); - -vi.mock('./auth-tab', async () => { - const { createElement: h } = await import('react'); - return { - AuthTab: function MockAuthTab() { - return h('div', { 'data-testid': 'auth-tab' }, 'Auth Tab Content'); - }, - }; -}); - -vi.mock('./roles-tab', async () => { - const { createElement: h } = await import('react'); - return { - RolesTab: function MockRolesTab() { - return h('div', { 'data-testid': 'roles-tab' }, 'Roles Tab Content'); - }, - }; -}); - -// Test data -const mockMe = { - isOwner: true, -}; - -const mockSettings = { - brandName: 'Test Brand', - baseUrl: 'https://deck.test', - workspacePolicy: null, -}; - -describe('SettingPage', () => { - beforeEach(() => { - vi.clearAllMocks(); - (accountApi.me as Mock).mockResolvedValue(mockMe); - (systemSettingsApi.get as Mock).mockResolvedValue(mockSettings); - - Object.defineProperty(window, 'location', { - value: { - replace: vi.fn(), - }, - writable: true, - }); - }); - - afterEach(() => { - cleanup(); - }); - - describe('렌더링', () => { - it('페이지 타이틀을 렌더링해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Setting')).toBeDefined(); - }); - }); - - it('Back to App 버튼을 렌더링해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('General')).toBeDefined(); - }); - - expect(screen.getByText('Back to App')).toBeDefined(); - }); - - }); - - describe('권한 확인', () => { - it('Owner가 아니면 Access Denied 메시지를 표시해야 함', async () => { - (accountApi.me as Mock).mockResolvedValue({ isOwner: false }); - - render(); - - await waitFor(() => { - expect(screen.getByText('Access Denied')).toBeDefined(); - expect(screen.getByText('Only the Owner can access this page.')).toBeDefined(); - }); - }); - - it('403 에러 시 Access Denied 메시지를 표시해야 함', async () => { - const error = new Error('Forbidden') as Error & { status: number }; - error.status = 403; - (accountApi.me as Mock).mockRejectedValue(error); - - render(); - - await waitFor(() => { - expect(screen.getByText('Access Denied')).toBeDefined(); - }); - }); - }); - - describe('탭 표시', () => { - it('Owner면 모든 탭을 표시해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('General')).toBeDefined(); - expect(screen.getByText('Workspace')).toBeDefined(); - expect(screen.getByText('Globalization')).toBeDefined(); - expect(screen.getByText('Branding')).toBeDefined(); - expect(screen.getByText('Auth')).toBeDefined(); - expect(screen.getByText('Roles')).toBeDefined(); - }); - }); - - it('Globalization 탭은 Workspace 다음 순서를 유지해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('General')).toBeDefined(); - }); - - const tabLabels = screen - .getAllByRole('tab') - .map((tab) => tab.textContent) - .filter((label): label is string => Boolean(label)); - - expect(tabLabels).toEqual([ - 'General', - 'Branding', - 'Workspace', - 'Globalization', - 'Auth', - 'Roles', - ]); - }); - - it('기본으로 General 탭이 활성화되어야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByTestId('general-tab')).toBeDefined(); - }); - }); - - it('Profile과 동일하게 스크롤 가능한 탭 래퍼 계약을 유지해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('General')).toBeDefined(); - }); - - const tabsWrapper = screen.getByTestId('setting-tabs-wrapper'); - expect(tabsWrapper.className).toContain('relative'); - expect(tabsWrapper.className).toContain('isolate'); - - const tabsList = tabsWrapper.querySelector('[data-slot="tabs-list"]'); - expect(tabsList).not.toBeNull(); - expect(tabsList?.className).toContain('w-full'); - expect(tabsList?.className).toContain('flex-nowrap'); - expect(tabsList?.className).toContain('overflow-x-auto'); - expect(tabsList?.getAttribute('data-scrollable')).toBe('true'); - }); - }); - - describe('탭 전환', () => { - it('Branding 탭 클릭 시 Branding 탭으로 전환해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Branding')).toBeDefined(); - }); - - const brandingTab = screen.getByText('Branding'); - fireEvent.mouseDown(brandingTab); - fireEvent.click(brandingTab); - - await waitFor(() => { - expect(screen.getByTestId('branding-tab')).toBeDefined(); - }); - }); - - it('Auth 탭 클릭 시 Auth 탭으로 전환해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Auth')).toBeDefined(); - }); - - const authTab = screen.getByText('Auth'); - fireEvent.mouseDown(authTab); - fireEvent.click(authTab); - - await waitFor(() => { - expect(screen.getByTestId('auth-tab')).toBeDefined(); - }); - }); - - it('Workspace 탭 클릭 시 Workspace 탭으로 전환해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Workspace')).toBeDefined(); - }); - - const workspaceTab = screen.getByText('Workspace'); - fireEvent.mouseDown(workspaceTab); - fireEvent.click(workspaceTab); - - await waitFor(() => { - expect(screen.getByTestId('workspace-tab')).toBeDefined(); - }); - }); - - it('Globalization 탭 클릭 시 Globalization 탭으로 전환해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Globalization')).toBeDefined(); - }); - - const globalizationTab = screen.getByText('Globalization'); - fireEvent.mouseDown(globalizationTab); - fireEvent.click(globalizationTab); - - await waitFor(() => { - expect(screen.getByTestId('globalization-tab')).toBeDefined(); - }); - }); - - it('Roles 탭 클릭 시 Roles 탭으로 전환해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Roles')).toBeDefined(); - }); - - const rolesTab = screen.getByText('Roles'); - fireEvent.mouseDown(rolesTab); - fireEvent.click(rolesTab); - - await waitFor(() => { - expect(screen.getByTestId('roles-tab')).toBeDefined(); - }); - }); - }); - - describe('네비게이션', () => { - it('Back to App 버튼 클릭 시 메인 페이지로 리다이렉트해야 함', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('General')).toBeDefined(); - }); - - const backButton = screen.getByText('Back to App').closest('button'); - fireEvent.click(backButton!); - - expect(window.location.replace).toHaveBeenCalledWith('/'); - }); - }); - - describe('초기화', () => { - it('마운트 시 브랜딩을 초기화해야 함', async () => { - const { initBranding } = await import('#app/shared/branding'); - render(); - - await waitFor(() => { - expect(initBranding).toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/frontend/app/src/pages/account/setting/setting.page.tsx b/frontend/app/src/pages/account/setting/setting.page.tsx deleted file mode 100644 index ce9645c8b..000000000 --- a/frontend/app/src/pages/account/setting/setting.page.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { StandaloneLayout } from '#app/layouts'; -import { Button } from '#app/components/ui/button'; -import { Tabs, TabsList, TabsTrigger } from '#app/components/ui/tabs'; -import { navigate } from '#app/shared/runtime'; -import { Icon } from '#app/shared/icon'; -import { StandalonePageHeader } from '#app/shared/standalone-page-header'; -import { useTranslation } from 'react-i18next'; -import { GeneralTab } from './general-tab'; -import { WorkspaceTab } from './workspace-tab'; -import { GlobalizationTab } from './globalization-tab'; -import { BrandingTab } from './branding-tab'; -import { AuthTab } from './auth-tab'; -import { RolesTab } from './roles-tab'; -import { useSettingPage } from './use-setting-page'; - -export function SettingPage() { - const { t } = useTranslation('account'); - const { state, actions } = useSettingPage(); - - return ( - navigate('/', true)} className="gap-1"> - - {t('setting.backToApp')} - - } - contentClass="items-start justify-center pt-8 pb-4" - > -
-
-
- - - {state.accessDenied && ( -
- -

{t('setting.accessDeniedTitle')}

-

{t('setting.accessDeniedDescription')}

-
- )} - - {!state.accessDenied && state.loaded && ( - <> -
- actions.setActiveTab(value as typeof state.activeTab)} - > - - {t('setting.tabGeneral')} - {t('setting.tabBranding')} - {t('setting.tabWorkspace')} - - {t('setting.tabGlobalization')} - - {t('setting.tabAuth')} - {t('setting.tabRoles')} - - -
- - {state.activeTab === 'general' && ( - - )} - {state.activeTab === 'workspace' && ( - - )} - {state.activeTab === 'globalization' && ( - - )} - {state.activeTab === 'branding' && } - {state.activeTab === 'auth' && } - {state.activeTab === 'roles' && } - - )} -
-
-
-
- ); -} - -export default SettingPage; diff --git a/frontend/app/src/pages/account/setting/use-setting-page.test.ts b/frontend/app/src/pages/account/setting/use-setting-page.test.ts deleted file mode 100644 index e5cd8c501..000000000 --- a/frontend/app/src/pages/account/setting/use-setting-page.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; - -vi.mock('#app/entities/account', () => ({ - accountApi: { me: vi.fn() }, -})); -vi.mock('#app/entities/system-settings', () => ({ - systemSettingsApi: { get: vi.fn() }, - setSystemSettings: vi.fn(), -})); - -import { accountApi } from '#app/entities/account'; -import { systemSettingsApi } from '#app/entities/system-settings'; -import type { SystemSettings } from '#app/entities/system-settings'; -import { useSettingPage } from './use-setting-page'; - -const mockedMe = vi.mocked(accountApi.me); -const mockedGetSettings = vi.mocked(systemSettingsApi.get); -const emptyContactProfile = { - primaryCountryCode: null, - phoneNumbers: [], - addresses: [], - identifiers: [], -}; -const mockSettings: SystemSettings = { - brandName: 'Deck', - baseUrl: 'https://deck.test', - workspacePolicy: null, -}; - -describe('useSettingPage', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('초기 상태를 올바르게 반환해야 함', () => { - mockedMe.mockReturnValue(new Promise(() => {})); // never resolves - const { result } = renderHook(() => useSettingPage()); - - expect(result.current.state.loaded).toBe(false); - expect(result.current.state.accessDenied).toBe(false); - expect(result.current.state.activeTab).toBe('general'); - expect(result.current.state.settings).toBeNull(); - }); - - it('Owner가 아닌 경우 accessDenied=true 설정', async () => { - mockedMe.mockResolvedValue({ - name: 'User', - email: 'u@t.com', - contactProfile: emptyContactProfile, - hasInternalIdentity: true, - isOwner: false, - }); - const { result } = renderHook(() => useSettingPage()); - - await waitFor(() => expect(result.current.state.accessDenied).toBe(true)); - expect(result.current.state.loaded).toBe(false); - expect(result.current.state.settings).toBeNull(); - expect(mockedGetSettings).not.toHaveBeenCalled(); - }); - - it('Owner인 경우 settings 로드 및 loaded=true', async () => { - mockedMe.mockResolvedValue({ - name: 'Owner', - email: 'o@t.com', - contactProfile: emptyContactProfile, - hasInternalIdentity: true, - isOwner: true, - }); - mockedGetSettings.mockResolvedValue(mockSettings); - const { result } = renderHook(() => useSettingPage()); - - await waitFor(() => expect(result.current.state.loaded).toBe(true)); - expect(result.current.state.accessDenied).toBe(false); - expect(result.current.state.settings).toEqual(mockSettings); - }); - - it('settings의 brandName으로 페이지 title을 갱신해야 함', async () => { - mockedMe.mockResolvedValue({ - name: 'Owner', - email: 'o@t.com', - contactProfile: emptyContactProfile, - hasInternalIdentity: true, - isOwner: true, - }); - mockedGetSettings.mockResolvedValue({ - brandName: 'My Brand', - baseUrl: 'https://deck.test', - }); - document.title = 'Setting - Deck'; - - renderHook(() => useSettingPage()); - - await waitFor(() => { - expect(document.title).toBe('Setting - My Brand'); - }); - }); - - it('API 에러 status=403이면 accessDenied=true', async () => { - mockedMe.mockRejectedValue({ status: 403 }); - const { result } = renderHook(() => useSettingPage()); - - await waitFor(() => expect(result.current.state.accessDenied).toBe(true)); - expect(result.current.state.loaded).toBe(false); - }); - - it('API 에러 (403 이외)는 무시하고 accessDenied=false 유지', async () => { - mockedMe.mockRejectedValue({ status: 500 }); - const { result } = renderHook(() => useSettingPage()); - - // 비동기 처리가 완료될 시간을 확보한 뒤 상태 확인 - await act(async () => { - await new Promise((r) => setTimeout(r, 50)); - }); - - expect(result.current.state.accessDenied).toBe(false); - expect(result.current.state.loaded).toBe(false); - expect(result.current.state.settings).toBeNull(); - }); - - it('setActiveTab으로 탭 전환 가능', () => { - mockedMe.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => useSettingPage()); - - expect(result.current.state.activeTab).toBe('general'); - - act(() => { - result.current.actions.setActiveTab('workspace'); - }); - expect(result.current.state.activeTab).toBe('workspace'); - - act(() => { - result.current.actions.setActiveTab('globalization'); - }); - expect(result.current.state.activeTab).toBe('globalization'); - - act(() => { - result.current.actions.setActiveTab('branding'); - }); - expect(result.current.state.activeTab).toBe('branding'); - - act(() => { - result.current.actions.setActiveTab('auth'); - }); - expect(result.current.state.activeTab).toBe('auth'); - - act(() => { - result.current.actions.setActiveTab('roles'); - }); - expect(result.current.state.activeTab).toBe('roles'); - }); -}); diff --git a/frontend/app/src/pages/account/setting/use-setting-page.ts b/frontend/app/src/pages/account/setting/use-setting-page.ts deleted file mode 100644 index dd4c13a5d..000000000 --- a/frontend/app/src/pages/account/setting/use-setting-page.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useState } from 'react'; -import { accountApi } from '#app/entities/account'; -import { - setSystemSettings, - systemSettingsApi, - type SystemSettings, -} from '#app/entities/system-settings'; - -export type SettingTab = 'general' | 'workspace' | 'globalization' | 'branding' | 'auth' | 'roles'; - -function applySettingPageTitle(brandName: string): void { - document.title = `Setting - ${brandName.trim()}`; -} - -export function useSettingPage() { - const [loaded, setLoaded] = useState(false); - const [accessDenied, setAccessDenied] = useState(false); - const [activeTab, setActiveTab] = useState('general'); - const [settings, setSettings] = useState(null); - - useEffect(() => { - const loadSettings = async () => { - try { - const me = await accountApi.me(); - if (!me.isOwner) { - setAccessDenied(true); - return; - } - - const data = await systemSettingsApi.get(); - setSettings(data); - setSystemSettings(data); - applySettingPageTitle(data.brandName); - setLoaded(true); - } catch (error) { - const maybeStatus = (error as { status?: number } | null | undefined)?.status; - if (maybeStatus === 403) { - setAccessDenied(true); - } - } - }; - - void loadSettings(); - }, []); - - return { - state: { - loaded, - accessDenied, - activeTab, - settings, - }, - actions: { - setActiveTab, - setSettings, - }, - }; -} diff --git a/frontend/app/src/pages/auth/password-change/password-change.page.test.tsx b/frontend/app/src/pages/auth/password-change/password-change.page.test.tsx index 870d5c123..54b9d1e09 100644 --- a/frontend/app/src/pages/auth/password-change/password-change.page.test.tsx +++ b/frontend/app/src/pages/auth/password-change/password-change.page.test.tsx @@ -191,13 +191,13 @@ describe('PasswordChangePage', () => { }); describe('인증 확인', () => { - it('passwordMustChange가 false면 oauth continue로 리다이렉트해야 함', async () => { + it('passwordMustChange가 false면 console dashboard로 리다이렉트해야 함', async () => { (authApi.me as Mock).mockResolvedValue({ passwordMustChange: false }); render(); await waitFor(() => { - expect(window.location.replace).toHaveBeenCalledWith('/oauth2/continue'); + expect(window.location.replace).toHaveBeenCalledWith('/console/dashboard'); }); }); @@ -289,7 +289,7 @@ describe('PasswordChangePage', () => { }); describe('비밀번호 변경', () => { - it('비밀번호 변경 성공 시 OAuth continue 페이지로 리다이렉트해야 함', async () => { + it('비밀번호 변경 성공 시 console dashboard로 리다이렉트해야 함', async () => { (authApi.forceChangePassword as Mock).mockResolvedValue({ success: true }); render(); @@ -307,7 +307,7 @@ describe('PasswordChangePage', () => { await waitFor(() => { expect(authApi.forceChangePassword).toHaveBeenCalledWith('oldpassword', 'newpassword123'); - expect(window.location.replace).toHaveBeenCalledWith('/oauth2/continue'); + expect(window.location.replace).toHaveBeenCalledWith('/console/dashboard'); }); }); }); diff --git a/frontend/app/src/pages/auth/password-change/use-password-change-page.test.ts b/frontend/app/src/pages/auth/password-change/use-password-change-page.test.ts index a6f4a126b..5706d00a0 100644 --- a/frontend/app/src/pages/auth/password-change/use-password-change-page.test.ts +++ b/frontend/app/src/pages/auth/password-change/use-password-change-page.test.ts @@ -64,7 +64,7 @@ describe('usePasswordChangePage', () => { renderHook(() => usePasswordChangePage()); await waitFor(() => { - expect(mocks.navigate).toHaveBeenCalledWith('/system/users?page=2#roles', true); + expect(mocks.navigate).toHaveBeenCalledWith('/console/users?page=2#roles', true); }); }); @@ -116,7 +116,7 @@ describe('usePasswordChangePage', () => { }); expect(mockedAuthApi.forceChangePassword).toHaveBeenCalledWith('old', 'new'); - expect(mocks.navigate).toHaveBeenCalledWith('/system/users?page=2#roles', true); + expect(mocks.navigate).toHaveBeenCalledWith('/console/users?page=2#roles', true); expect(result.current.state.loading).toBe(false); }); diff --git a/frontend/app/src/pages/dashboard/dashboard-owner-widgets.tsx b/frontend/app/src/pages/dashboard/dashboard-owner-widgets.tsx index 7bf3e7bb3..9cdc071e6 100644 --- a/frontend/app/src/pages/dashboard/dashboard-owner-widgets.tsx +++ b/frontend/app/src/pages/dashboard/dashboard-owner-widgets.tsx @@ -3,7 +3,7 @@ import { Badge } from '#app/shared/badge'; import { Card } from '#app/shared/card'; import { Icon } from '#app/shared/icon'; import { useTranslation } from 'react-i18next'; -import type { SystemStatus, AuditLogSummary } from '#app/entities/dashboard'; +import type { PlatformStatus, AuditLogSummary } from '#app/entities/dashboard'; import type { BadgeTone } from '#app/shared/utils'; import type { LoginChartDatum, ErrorChartDatum, RoleChartDatum } from './use-dashboard-page'; @@ -12,7 +12,7 @@ interface DashboardOwnerWidgetsProps { loginChartData: LoginChartDatum[] | null; errorChartData: ErrorChartDatum[] | null; pendingInvitesCount: number | null; - systemStatus: SystemStatus | null; + platformStatus: PlatformStatus | null; roleChartData: RoleChartDatum[] | null; recentAuditLogs: AuditLogSummary[] | null; } @@ -67,7 +67,7 @@ export function DashboardOwnerWidgets({ loginChartData, errorChartData, pendingInvitesCount, - systemStatus, + platformStatus, roleChartData, recentAuditLogs, }: DashboardOwnerWidgetsProps) { @@ -85,27 +85,27 @@ export function DashboardOwnerWidgets({
{pendingInvitesCount ?? 0}
- - {systemStatus && ( + + {platformStatus && (
{t('ownerWidgets.email')}
{t('ownerWidgets.slack')}
- {t('ownerWidgets.activeChannels', { count: systemStatus.activeNotificationChannels })} + {t('ownerWidgets.activeChannels', { count: platformStatus.activeNotificationChannels })}
)} diff --git a/frontend/app/src/pages/dashboard/dashboard.page.test.tsx b/frontend/app/src/pages/dashboard/dashboard.page.test.tsx index fdbc8ede5..5fd5184b5 100644 --- a/frontend/app/src/pages/dashboard/dashboard.page.test.tsx +++ b/frontend/app/src/pages/dashboard/dashboard.page.test.tsx @@ -6,7 +6,7 @@ import type { AuditLogSummary, LoginHistoryItem, SecurityStatus, - SystemStatus, + PlatformStatus, } from '#app/entities/dashboard'; import type { ErrorChartDatum, LoginChartDatum, RoleChartDatum } from './use-dashboard-page'; @@ -64,20 +64,20 @@ vi.mock('./dashboard-owner-widgets', () => ({ DashboardOwnerWidgets: ({ activeUsersCount, pendingInvitesCount, - systemStatus, + platformStatus, recentAuditLogs, }: { activeUsersCount: number | null; pendingInvitesCount: number | null; - systemStatus: SystemStatus | null; + platformStatus: PlatformStatus | null; recentAuditLogs: AuditLogSummary[] | null; }) => (
Owner Widgets {activeUsersCount ?? 0} {pendingInvitesCount ?? 0} - {systemStatus?.emailEnabled ? 'Email' : 'No Email'} - {systemStatus?.slackEnabled ? 'Slack' : 'No Slack'} + {platformStatus?.emailEnabled ? 'Email' : 'No Email'} + {platformStatus?.slackEnabled ? 'Slack' : 'No Slack'} {recentAuditLogs?.[0]?.path ?? 'no-logs'}
), @@ -95,7 +95,7 @@ interface DashboardState { errorChartData: ErrorChartDatum[] | null; isOwner: boolean; pendingInvitesCount: number | null; - systemStatus: SystemStatus | null; + platformStatus: PlatformStatus | null; roleChartData: RoleChartDatum[] | null; recentAuditLogs: AuditLogSummary[] | null; } @@ -125,7 +125,7 @@ function buildState(overrides: Partial = {}): DashboardState { errorChartData: [{ date: 'Jan 15', errors: 5 }], isOwner: true, pendingInvitesCount: 2, - systemStatus: { + platformStatus: { emailEnabled: true, slackEnabled: false, activeNotificationChannels: 3, @@ -196,7 +196,7 @@ describe('DashboardPage', () => { isOwner: false, activeUsersCount: null, pendingInvitesCount: null, - systemStatus: null, + platformStatus: null, roleChartData: null, recentAuditLogs: null, }), @@ -215,7 +215,7 @@ describe('DashboardPage', () => { state: buildState({ activeUsersCount: 41, pendingInvitesCount: 7, - systemStatus: { + platformStatus: { emailEnabled: false, slackEnabled: true, activeNotificationChannels: 5, diff --git a/frontend/app/src/pages/dashboard/dashboard.page.tsx b/frontend/app/src/pages/dashboard/dashboard.page.tsx index 3ae1f663d..ef5500467 100644 --- a/frontend/app/src/pages/dashboard/dashboard.page.tsx +++ b/frontend/app/src/pages/dashboard/dashboard.page.tsx @@ -28,7 +28,7 @@ export function DashboardPage() { loginChartData={state.loginChartData} errorChartData={state.errorChartData} pendingInvitesCount={state.pendingInvitesCount} - systemStatus={state.systemStatus} + platformStatus={state.platformStatus} roleChartData={state.roleChartData} recentAuditLogs={state.recentAuditLogs} /> diff --git a/frontend/app/src/pages/dashboard/use-dashboard-page.test.ts b/frontend/app/src/pages/dashboard/use-dashboard-page.test.ts index d749354d6..3adda16f5 100644 --- a/frontend/app/src/pages/dashboard/use-dashboard-page.test.ts +++ b/frontend/app/src/pages/dashboard/use-dashboard-page.test.ts @@ -29,7 +29,7 @@ describe('useDashboardPage', () => { activeUsersCount: null, errorStats: null, pendingInvitesCount: null, - systemStatus: null, + platformStatus: null, roleDistribution: [ { roleLabel: 'Administrator', diff --git a/frontend/app/src/pages/dashboard/use-dashboard-page.ts b/frontend/app/src/pages/dashboard/use-dashboard-page.ts index 74bbc55ab..0b8076cff 100644 --- a/frontend/app/src/pages/dashboard/use-dashboard-page.ts +++ b/frontend/app/src/pages/dashboard/use-dashboard-page.ts @@ -3,7 +3,7 @@ import { dashboardApi, type LoginHistoryItem, type SecurityStatus, - type SystemStatus as SystemStatusType, + type PlatformStatus as PlatformStatusType, type RoleDistribution, type AuditLogSummary, } from '#app/entities/dashboard'; @@ -100,7 +100,7 @@ export function useDashboardPage() { const [errorChartData, setErrorChartData] = useState(null); const [isOwner, setIsOwner] = useState(false); const [pendingInvitesCount, setPendingInvitesCount] = useState(null); - const [systemStatus, setSystemStatus] = useState(null); + const [platformStatus, setPlatformStatus] = useState(null); const [roleChartData, setRoleChartData] = useState(null); const [recentAuditLogs, setRecentAuditLogs] = useState(null); @@ -116,7 +116,7 @@ export function useDashboardPage() { setActiveUsersCount(data.activeUsersCount); setIsOwner(data.loginStats !== null); setPendingInvitesCount(data.pendingInvitesCount); - setSystemStatus(data.systemStatus); + setPlatformStatus(data.platformStatus); setRecentAuditLogs(data.recentAuditLogs); if (data.loginStats) { @@ -150,7 +150,7 @@ export function useDashboardPage() { errorChartData, isOwner, pendingInvitesCount, - systemStatus, + platformStatus, roleChartData, recentAuditLogs, }, diff --git a/frontend/app/src/pages/legal/content/app/privacy.en.md b/frontend/app/src/pages/legal/content/app/privacy.en.md index 4c2db3376..704bac786 100644 --- a/frontend/app/src/pages/legal/content/app/privacy.en.md +++ b/frontend/app/src/pages/legal/content/app/privacy.en.md @@ -14,7 +14,7 @@ When you sign in with Google, Naver, Kakao, Okta, Auth0, or Microsoft, {{brandNa ## Product data -This service processes users, roles, menus, program permissions, workspaces, notification channels/rules/templates, and system settings required to operate the control plane. It does not currently use Google Calendar or Microsoft Calendar integrations directly. +This service processes users, roles, menus, program permissions, workspaces, notification channels/rules/templates, and platform settings required to operate the control plane. It does not currently use Google Calendar or Microsoft Calendar integrations directly. ## Retention and security diff --git a/frontend/app/src/pages/legal/content/app/privacy.ko.md b/frontend/app/src/pages/legal/content/app/privacy.ko.md index ead89f673..29e4c3d42 100644 --- a/frontend/app/src/pages/legal/content/app/privacy.ko.md +++ b/frontend/app/src/pages/legal/content/app/privacy.ko.md @@ -14,7 +14,7 @@ Google, Naver, Kakao, Okta, Auth0, Microsoft 로그인 시 제공자와 설정 ## 제품 데이터 -이 서비스는 control-plane 운영을 위해 사용자, 역할, 메뉴, 프로그램 권한, workspace, notification channel/rule/template, system setting 데이터를 처리합니다. 현재 Google Calendar 또는 Microsoft Calendar 연동을 직접 사용하지 않습니다. +이 서비스는 control-plane 운영을 위해 사용자, 역할, 메뉴, 프로그램 권한, workspace, notification channel/rule/template, platform setting 데이터를 처리합니다. 현재 Google Calendar 또는 Microsoft Calendar 연동을 직접 사용하지 않습니다. ## 보관 기준 diff --git a/frontend/app/src/pages/login/login.page.test.tsx b/frontend/app/src/pages/login/login.page.test.tsx index ae5167038..51bd41d6e 100644 --- a/frontend/app/src/pages/login/login.page.test.tsx +++ b/frontend/app/src/pages/login/login.page.test.tsx @@ -342,7 +342,7 @@ describe('LoginPage', () => { fireEvent.click(submitBtn!); await waitFor(() => { - expect(window.location.replace).toHaveBeenCalledWith('/system/users?page=2#roles'); + expect(window.location.replace).toHaveBeenCalledWith('/console/users?page=2#roles'); }); }); @@ -378,7 +378,7 @@ describe('LoginPage', () => { await waitFor(() => { expect(window.location.replace).toHaveBeenCalledWith( - '/auth/password-change?next=%2Fsystem%2Fusers%3Fpage%3D2%23roles' + '/auth/password-change?next=%2Fconsole%2Fusers%3Fpage%3D2%23roles' ); }); }); @@ -680,7 +680,7 @@ describe('LoginPage', () => { render(); await waitFor(() => { - expect(window.location.replace).toHaveBeenCalledWith('/system/users?page=2#roles'); + expect(window.location.replace).toHaveBeenCalledWith('/console/users?page=2#roles'); }); }); @@ -713,7 +713,7 @@ describe('LoginPage', () => { await waitFor(() => { expect(window.location.replace).toHaveBeenCalledWith( - '/auth/password-change?next=%2Fsystem%2Fusers%3Fpage%3D2%23roles' + '/auth/password-change?next=%2Fconsole%2Fusers%3Fpage%3D2%23roles' ); }); }); diff --git a/frontend/app/src/pages/my-workspaces/my-workspace-detail.page.test.tsx b/frontend/app/src/pages/my-workspaces/my-workspace-detail.page.test.tsx index 7e2517980..4a661b0c7 100644 --- a/frontend/app/src/pages/my-workspaces/my-workspace-detail.page.test.tsx +++ b/frontend/app/src/pages/my-workspaces/my-workspace-detail.page.test.tsx @@ -31,7 +31,7 @@ vi.mock('#app/widgets/tabbar', () => ({ describe('MyWorkspaceDetailRoutePage', () => { it('active tab URL에서 workspaceId를 읽어 detail page를 렌더링해야 함', () => { - mockUseActiveTabUrl.mockReturnValue('/my-workspaces/my-ws-1'); + mockUseActiveTabUrl.mockReturnValue('/console/my-workspaces/my-ws-1'); render(); @@ -41,11 +41,11 @@ describe('MyWorkspaceDetailRoutePage', () => { }); it('breadcrumb back은 목록 URL replace로 돌아가야 함', () => { - mockUseActiveTabUrl.mockReturnValue('/my-workspaces/my-ws-1'); + mockUseActiveTabUrl.mockReturnValue('/console/my-workspaces/my-ws-1'); render(); screen.getByTestId('my-workspace-detail').click(); - expect(mockUpdateActiveTabUrl).toHaveBeenCalledWith('/my-workspaces/', 'replace'); + expect(mockUpdateActiveTabUrl).toHaveBeenCalledWith('/console/my-workspaces/', 'replace'); }); }); diff --git a/frontend/app/src/pages/my-workspaces/my-workspace-detail.page.tsx b/frontend/app/src/pages/my-workspaces/my-workspace-detail.page.tsx index edc607fb6..c2d6bfc4d 100644 --- a/frontend/app/src/pages/my-workspaces/my-workspace-detail.page.tsx +++ b/frontend/app/src/pages/my-workspaces/my-workspace-detail.page.tsx @@ -8,7 +8,7 @@ export function MyWorkspaceDetailRoutePage() { if ( descriptor.kind !== 'workspace-detail' || - descriptor.canonicalPath !== '/my-workspaces/' || + descriptor.canonicalPath !== '/console/my-workspaces/' || !descriptor.workspaceId ) { return null; @@ -17,7 +17,7 @@ export function MyWorkspaceDetailRoutePage() { return ( updateActiveTabUrl('/my-workspaces/', 'replace')} + onBack={() => updateActiveTabUrl('/console/my-workspaces/', 'replace')} /> ); } diff --git a/frontend/app/src/pages/my-workspaces/my-workspaces.page.test.tsx b/frontend/app/src/pages/my-workspaces/my-workspaces.page.test.tsx index eb895c508..268653e1c 100644 --- a/frontend/app/src/pages/my-workspaces/my-workspaces.page.test.tsx +++ b/frontend/app/src/pages/my-workspaces/my-workspaces.page.test.tsx @@ -123,7 +123,7 @@ const mockMyWorkspaces: MyWorkspace[] = [ }, ], role: 'MEMBER', - managedType: 'SYSTEM_MANAGED', + managedType: 'PLATFORM_MANAGED', memberCount: 12, createdAt: '2026-03-14T00:00:00Z', updatedAt: '2026-03-14T00:00:00Z', @@ -216,7 +216,7 @@ describe('MyWorkspacesPage', () => { mockGrid.props?.onRowClick?.(mockMyWorkspaces[0]!); }); - expect(mockUpdateActiveTabUrl).toHaveBeenCalledWith('/my-workspaces/my-ws-1'); + expect(mockUpdateActiveTabUrl).toHaveBeenCalledWith('/console/my-workspaces/my-ws-1'); }); it('member row click 시 detail page로 전환하지 않아야 함', async () => { diff --git a/frontend/app/src/pages/my-workspaces/my-workspaces.page.tsx b/frontend/app/src/pages/my-workspaces/my-workspaces.page.tsx index 46c52d2ce..f7d7e5e5e 100644 --- a/frontend/app/src/pages/my-workspaces/my-workspaces.page.tsx +++ b/frontend/app/src/pages/my-workspaces/my-workspaces.page.tsx @@ -22,6 +22,7 @@ export function MyWorkspacesPage() { const columns = useGridColumns(() => getMyWorkspaceColumns(translate), [translate]); const [selected, setSelected] = useState(null); const [selectedIds, setSelectedIds] = useState([]); + const [hasExternalSelection, setHasExternalSelection] = useState(false); const [query, setQuery] = useState(INITIAL_QUERY); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); @@ -69,6 +70,7 @@ export function MyWorkspacesPage() { actions.batchDelete(selectedIds)} onLeave={() => selected && actions.leaveWorkspace(selected)} @@ -88,10 +90,11 @@ export function MyWorkspacesPage() { onSelectionChange={(selectedRows) => { setSelected(selectedRows.at(-1) ?? null); setSelectedIds(selectedRows.map((row) => row.id)); + setHasExternalSelection(selectedRows.some((row) => !!row.externalReference)); }} onRowClick={(ws) => { if (ws.role === 'OWNER') { - updateActiveTabUrl(`/my-workspaces/${ws.id}`); + updateActiveTabUrl(`/console/my-workspaces/${ws.id}`); } }} /> diff --git a/frontend/app/src/pages/settings/settings-nav.ts b/frontend/app/src/pages/settings/settings-nav.ts index 374aebfbd..4680316c2 100644 --- a/frontend/app/src/pages/settings/settings-nav.ts +++ b/frontend/app/src/pages/settings/settings-nav.ts @@ -5,7 +5,7 @@ export interface SettingsLeaf { titleKey: string; subtitleKey: string; icon: string; - ownerOnly?: boolean; + platformAdminOnly?: boolean; } export interface SettingsGroup { @@ -18,6 +18,12 @@ export const settingsAccountProfilePath = '/settings/account/profile'; export const settingsAccountPreferencesPath = '/settings/account/preferences'; export const settingsAccountSecurityPath = '/settings/account/security'; export const settingsAccountSessionsPath = '/settings/account/sessions'; +export const settingsPlatformGeneralPath = '/settings/platform/general'; +export const settingsPlatformBrandingPath = '/settings/platform/branding'; +export const settingsPlatformAuthenticationPath = '/settings/platform/authentication'; +export const settingsPlatformWorkspacePolicyPath = '/settings/platform/workspace-policy'; +export const settingsPlatformRolesPath = '/settings/platform/roles'; +export const settingsPlatformMenusPath = '/settings/platform/menus'; export const settingsNav: SettingsGroup[] = [ { @@ -59,62 +65,62 @@ export const settingsNav: SettingsGroup[] = [ ], }, { - key: 'system', - labelKey: 'settingsShell.groups.system', + key: 'platform', + labelKey: 'settingsShell.groups.platform', leaves: [ { key: 'general', labelKey: 'settingsShell.leaves.general.label', - path: '/settings/system/general', + path: settingsPlatformGeneralPath, titleKey: 'settingsShell.leaves.general.title', subtitleKey: 'settingsShell.leaves.general.subtitle', icon: 'Settings2', - ownerOnly: true, + platformAdminOnly: true, }, { key: 'branding', labelKey: 'settingsShell.leaves.branding.label', - path: '/settings/system/branding', + path: settingsPlatformBrandingPath, titleKey: 'settingsShell.leaves.branding.title', subtitleKey: 'settingsShell.leaves.branding.subtitle', icon: 'Palette', - ownerOnly: true, + platformAdminOnly: true, }, { key: 'authentication', labelKey: 'settingsShell.leaves.authentication.label', - path: '/settings/system/authentication', + path: settingsPlatformAuthenticationPath, titleKey: 'settingsShell.leaves.authentication.title', subtitleKey: 'settingsShell.leaves.authentication.subtitle', icon: 'KeyRound', - ownerOnly: true, + platformAdminOnly: true, }, { - key: 'workspace', - labelKey: 'settingsShell.leaves.workspace.label', - path: '/settings/system/workspace', - titleKey: 'settingsShell.leaves.workspace.title', - subtitleKey: 'settingsShell.leaves.workspace.subtitle', + key: 'workspace-policy', + labelKey: 'settingsShell.leaves.workspacePolicy.label', + path: settingsPlatformWorkspacePolicyPath, + titleKey: 'settingsShell.leaves.workspacePolicy.title', + subtitleKey: 'settingsShell.leaves.workspacePolicy.subtitle', icon: 'Building2', - ownerOnly: true, - }, - { - key: 'globalization', - labelKey: 'settingsShell.leaves.globalization.label', - path: '/settings/system/globalization', - titleKey: 'settingsShell.leaves.globalization.title', - subtitleKey: 'settingsShell.leaves.globalization.subtitle', - icon: 'Globe2', - ownerOnly: true, + platformAdminOnly: true, }, { key: 'roles', labelKey: 'settingsShell.leaves.roles.label', - path: '/settings/system/roles', + path: settingsPlatformRolesPath, titleKey: 'settingsShell.leaves.roles.title', subtitleKey: 'settingsShell.leaves.roles.subtitle', icon: 'UsersRound', - ownerOnly: true, + platformAdminOnly: true, + }, + { + key: 'menus', + labelKey: 'settingsShell.leaves.menus.label', + path: settingsPlatformMenusPath, + titleKey: 'settingsShell.leaves.menus.title', + subtitleKey: 'settingsShell.leaves.menus.subtitle', + icon: 'PanelsTopLeft', + platformAdminOnly: true, }, ], }, diff --git a/frontend/app/src/pages/settings/settings-path.ts b/frontend/app/src/pages/settings/settings-path.ts new file mode 100644 index 000000000..ad94f76d2 --- /dev/null +++ b/frontend/app/src/pages/settings/settings-path.ts @@ -0,0 +1,61 @@ +export type SettingsPath = { + group: string; + leaf: string; + detailSegments: string[]; + hasExplicitLeaf: boolean; +}; + +export type SettingsLeafVariant = + | 'default' + | 'account-security-overview' + | 'account-security-password' + | 'account-security-2fa-setup' + | 'invalid'; + +export function parseSettingsPath(pathname: string): SettingsPath { + const [root, group, leaf, ...detailSegments] = pathname.split('/').filter(Boolean); + + if (root !== 'settings') { + return { + group: 'account', + leaf: 'profile', + detailSegments: [], + hasExplicitLeaf: false, + }; + } + + return { + group: group || 'account', + leaf: leaf || 'profile', + detailSegments, + hasExplicitLeaf: Boolean(leaf), + }; +} + +export function resolveSettingsLeafVariant(path: SettingsPath): SettingsLeafVariant { + if (path.detailSegments.length === 0) { + if (path.group === 'account' && path.leaf === 'security') { + return 'account-security-overview'; + } + + return 'default'; + } + + if (path.group !== 'account' || path.leaf !== 'security') { + return 'invalid'; + } + + if (path.detailSegments.length === 1 && path.detailSegments[0] === 'password') { + return 'account-security-password'; + } + + if ( + path.detailSegments.length === 2 && + path.detailSegments[0] === '2fa' && + path.detailSegments[1] === 'setup' + ) { + return 'account-security-2fa-setup'; + } + + return 'invalid'; +} diff --git a/frontend/app/src/pages/settings/settings.page.test.tsx b/frontend/app/src/pages/settings/settings.page.test.tsx index 29b1299bc..d4dfc36f3 100644 --- a/frontend/app/src/pages/settings/settings.page.test.tsx +++ b/frontend/app/src/pages/settings/settings.page.test.tsx @@ -111,7 +111,7 @@ vi.mock('#app/pages/account/profile/sessions-tab', async () => { }; }); -vi.mock('#app/pages/account/setting/general-tab', async () => { +vi.mock('#app/pages/settings/tabs/general-tab', async () => { const { createElement: h } = await import('react'); return { GeneralTab: function MockGeneralTab() { @@ -120,7 +120,7 @@ vi.mock('#app/pages/account/setting/general-tab', async () => { }; }); -vi.mock('#app/pages/account/setting/workspace-tab', async () => { +vi.mock('#app/pages/settings/tabs/workspace-tab', async () => { const { createElement: h } = await import('react'); return { WorkspaceTab: function MockWorkspaceTab() { @@ -129,16 +129,7 @@ vi.mock('#app/pages/account/setting/workspace-tab', async () => { }; }); -vi.mock('#app/pages/account/setting/globalization-tab', async () => { - const { createElement: h } = await import('react'); - return { - GlobalizationTab: function MockGlobalizationTab() { - return h('div', { 'data-testid': 'globalization-tab' }, 'Globalization Tab'); - }, - }; -}); - -vi.mock('#app/pages/account/setting/branding-tab', async () => { +vi.mock('#app/pages/settings/tabs/branding-tab', async () => { const { createElement: h } = await import('react'); return { BrandingTab: function MockBrandingTab() { @@ -147,7 +138,7 @@ vi.mock('#app/pages/account/setting/branding-tab', async () => { }; }); -vi.mock('#app/pages/account/setting/auth-tab', async () => { +vi.mock('#app/pages/settings/tabs/auth-tab', async () => { const { createElement: h } = await import('react'); return { AuthTab: function MockAuthTab() { @@ -156,7 +147,7 @@ vi.mock('#app/pages/account/setting/auth-tab', async () => { }; }); -vi.mock('#app/pages/account/setting/roles-tab', async () => { +vi.mock('#app/pages/settings/tabs/roles-tab', async () => { const { createElement: h } = await import('react'); return { RolesTab: function MockRolesTab() { @@ -165,6 +156,24 @@ vi.mock('#app/pages/account/setting/roles-tab', async () => { }; }); +vi.mock('#app/pages/settings/tabs/menus-tab', async () => { + const { createElement: h } = await import('react'); + return { + MenusTab: function MockMenusTab() { + return h('div', { 'data-testid': 'menus-tab' }, 'Menus Tab'); + }, + }; +}); + +vi.mock('#app/pages/errors/404/not-found.page', async () => { + const { createElement: h } = await import('react'); + return { + NotFoundPage: function MockNotFoundPage() { + return h('div', { 'data-testid': 'not-found-page' }, 'Not Found'); + }, + }; +}); + const ownerUser = { id: 'user-1', username: 'owner', @@ -202,14 +211,14 @@ vi.mock('#app/shared/branding', async () => { }; }); -vi.mock('#app/entities/system-settings', async () => { - const actual = await vi.importActual( - '#app/entities/system-settings' +vi.mock('#app/entities/platform-settings', async () => { + const actual = await vi.importActual( + '#app/entities/platform-settings' ); return { ...actual, - systemSettingsApi: { - ...actual.systemSettingsApi, + platformSettingsApi: { + ...actual.platformSettingsApi, get: vi.fn().mockResolvedValue({ brandName: 'Deck', baseUrl: 'https://deck.test', @@ -217,7 +226,7 @@ vi.mock('#app/entities/system-settings', async () => { }), getPublicBranding: vi.fn(() => new Promise(() => {})), }, - setSystemSettings: vi.fn(), + setPlatformSettings: vi.fn(), }; }); @@ -247,7 +256,7 @@ describe('SettingsPage route shell', () => { user.set(null); }); - it('/settings/account/profile 경로에서 Account/System 그룹과 Profile active 상태를 보여야 함', async () => { + it('/settings/account/profile 경로에서 Account/Platform 그룹과 Profile active 상태를 보여야 함', async () => { window.history.replaceState({}, '', '/settings/account/profile'); render( @@ -259,7 +268,7 @@ describe('SettingsPage route shell', () => { await waitFor(() => { expect(screen.getByRole('heading', { name: 'Profile' })).toBeDefined(); expect(screen.getAllByText('Account').length).toBeGreaterThan(0); - expect(screen.getByText('System')).toBeDefined(); + expect(screen.getByText('Platform')).toBeDefined(); expect(screen.getByRole('link', { name: 'Profile' }).getAttribute('data-active')).toBe( 'true' ); @@ -300,7 +309,7 @@ describe('SettingsPage route shell', () => { await waitFor(() => { expect(userApi.me).toHaveBeenCalledTimes(1); - expect(screen.getByText('System')).toBeDefined(); + expect(screen.getByText('Platform')).toBeDefined(); expect(screen.getByRole('link', { name: 'General' })).toBeDefined(); expect(screen.getByRole('link', { name: 'Profile' }).getAttribute('data-active')).toBe( 'true' @@ -308,8 +317,8 @@ describe('SettingsPage route shell', () => { }); }); - it('/settings/system/workspace 경로에서 Workspace leaf가 active 상태를 보여야 함', async () => { - window.history.replaceState({}, '', '/settings/system/workspace'); + it('/settings/platform/workspace-policy 경로에서 Workspace Policy leaf가 active 상태를 보여야 함', async () => { + window.history.replaceState({}, '', '/settings/platform/workspace-policy'); render( @@ -318,15 +327,15 @@ describe('SettingsPage route shell', () => { ); await waitFor(() => { - expect(screen.getByRole('link', { name: 'Workspace' }).getAttribute('data-active')).toBe( - 'true' - ); + expect( + screen.getByRole('link', { name: 'Workspace Policy' }).getAttribute('data-active') + ).toBe('true'); expect(screen.getByTestId('workspace-tab')).toBeDefined(); }); }); - it('/settings/system/globalization 경로에서 Globalization leaf가 active 상태를 보여야 함', async () => { - window.history.replaceState({}, '', '/settings/system/globalization'); + it('/settings/platform/menus 경로에서 Menus leaf가 active 상태를 보여야 함', async () => { + window.history.replaceState({}, '', '/settings/platform/menus'); render( @@ -335,10 +344,10 @@ describe('SettingsPage route shell', () => { ); await waitFor(() => { - expect(screen.getByRole('link', { name: 'Globalization' }).getAttribute('data-active')).toBe( + expect(screen.getByRole('link', { name: 'Menus' }).getAttribute('data-active')).toBe( 'true' ); - expect(screen.getByTestId('globalization-tab')).toBeDefined(); + expect(screen.getByTestId('menus-tab')).toBeDefined(); }); }); @@ -359,7 +368,7 @@ describe('SettingsPage route shell', () => { }); }); - it('non-owner는 System 그룹을 보지 않아야 함', async () => { + it('non-owner는 Platform 그룹을 보지 않아야 함', async () => { user.set(memberUser); window.history.replaceState({}, '', '/settings/account/profile'); @@ -371,13 +380,13 @@ describe('SettingsPage route shell', () => { await waitFor(() => { expect(screen.getAllByText('Account').length).toBeGreaterThan(0); - expect(screen.queryByText('System')).toBeNull(); + expect(screen.queryByText('Platform')).toBeNull(); expect(screen.queryByRole('link', { name: 'Branding' })).toBeNull(); }); }); it('group/page에 따라 active leaf와 본문이 바뀌어야 함', async () => { - window.history.replaceState({}, '', '/settings/system/workspace'); + window.history.replaceState({}, '', '/settings/platform/workspace-policy'); render( @@ -386,17 +395,17 @@ describe('SettingsPage route shell', () => { ); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Workspace' })).toBeDefined(); - expect(screen.getAllByText('System').length).toBeGreaterThan(0); - expect(screen.getByRole('link', { name: 'Workspace' }).getAttribute('data-active')).toBe( - 'true' - ); + expect(screen.getByRole('heading', { name: 'Workspace Policy' })).toBeDefined(); + expect(screen.getAllByText('Platform').length).toBeGreaterThan(0); + expect( + screen.getByRole('link', { name: 'Workspace Policy' }).getAttribute('data-active') + ).toBe('true'); expect(screen.getByTestId('workspace-tab')).toBeDefined(); }); }); - it('system/globalization leaf는 기존 globalization form을 렌더링해야 함', async () => { - window.history.replaceState({}, '', '/settings/system/globalization'); + it('platform/menus leaf는 기존 menu management wrapper를 렌더링해야 함', async () => { + window.history.replaceState({}, '', '/settings/platform/menus'); render( @@ -405,8 +414,8 @@ describe('SettingsPage route shell', () => { ); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Globalization' })).toBeDefined(); - expect(screen.getByTestId('globalization-tab')).toBeDefined(); + expect(screen.getByRole('heading', { name: 'Menus' })).toBeDefined(); + expect(screen.getByTestId('menus-tab')).toBeDefined(); }); }); @@ -484,8 +493,68 @@ describe('SettingsPage route shell', () => { }); }); - it('마운트 시 system settings를 미리 로드해야 함', async () => { - const { systemSettingsApi } = await import('#app/entities/system-settings'); + it('security leaf typo URL은 security 화면으로 새지 않고 fallback 되어야 함', async () => { + window.history.replaceState({}, '', '/settings/account/securityevil'); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Profile' }).getAttribute('data-active')).toBe( + 'true' + ); + expect(screen.getByTestId('info-tab')).toBeDefined(); + expect(screen.queryByTestId('security-overview-page')).toBeNull(); + }); + }); + + it('platform admin deep link는 hydration 전 404를 먼저 보여주지 않아야 함', async () => { + let resolveUser: (value: typeof ownerUser) => void = () => undefined; + user.set(null); + vi.mocked(userApi.me).mockImplementation( + () => + new Promise((resolve) => { + resolveUser = resolve; + }) + ); + window.history.replaceState({}, '', '/settings/platform/roles'); + + render( + + + + ); + + expect(screen.queryByTestId('not-found-page')).toBeNull(); + + resolveUser(ownerUser); + + await waitFor(() => { + expect(screen.getByTestId('roles-tree-panel')).toBeDefined(); + }); + }); + + it('platform deep link에서 사용자 hydrate가 실패하면 빈 화면에 머무르지 않고 404로 닫혀야 함', async () => { + user.set(null); + vi.mocked(userApi.me).mockRejectedValue(new Error('failed to load user')); + window.history.replaceState({}, '', '/settings/platform/roles'); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('not-found-page')).toBeDefined(); + }); + }); + + it('마운트 시 platform settings를 미리 로드해야 함', async () => { + const { platformSettingsApi } = await import('#app/entities/platform-settings'); window.history.replaceState({}, '', '/settings/account/profile'); render( @@ -495,7 +564,7 @@ describe('SettingsPage route shell', () => { ); await waitFor(() => { - expect(systemSettingsApi.get).toHaveBeenCalledTimes(1); + expect(platformSettingsApi.get).toHaveBeenCalledTimes(1); }); }); @@ -527,8 +596,8 @@ describe('SettingsPage route shell', () => { }); }); - it('system/general leaf는 기존 general form을 렌더링해야 함', async () => { - window.history.replaceState({}, '', '/settings/system/general'); + it('platform/general leaf는 기존 general form을 렌더링해야 함', async () => { + window.history.replaceState({}, '', '/settings/platform/general'); render( @@ -541,8 +610,22 @@ describe('SettingsPage route shell', () => { }); }); - it('system/roles leaf는 기존 roles tree panel을 렌더링해야 함', async () => { - window.history.replaceState({}, '', '/settings/system/roles'); + it('platform/roles leaf는 기존 roles tree panel을 렌더링해야 함', async () => { + window.history.replaceState({}, '', '/settings/platform/roles'); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('roles-tree-panel')).toBeDefined(); + }); + }); + + it('trailing slash가 있어도 platform leaf deep link는 정상 렌더링되어야 함', async () => { + window.history.replaceState({}, '', '/settings/platform/roles/'); render( @@ -554,4 +637,37 @@ describe('SettingsPage route shell', () => { expect(screen.getByTestId('roles-tree-panel')).toBeDefined(); }); }); + + it('group path만 주어지면 해당 group의 첫 leaf로 fallback 되어야 함', async () => { + window.history.replaceState({}, '', '/settings/platform/'); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('general-settings-form')).toBeDefined(); + expect(screen.getByRole('link', { name: 'General' }).getAttribute('data-active')).toBe( + 'true' + ); + }); + }); + + it('platform admin이 아니면 platform 전용 leaf deep link를 404로 차단해야 함', async () => { + window.history.replaceState({}, '', '/settings/platform/roles'); + user.set(memberUser); + vi.mocked(userApi.me).mockResolvedValue(memberUser); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('not-found-page')).toBeDefined(); + }); + }); }); diff --git a/frontend/app/src/pages/settings/settings.page.tsx b/frontend/app/src/pages/settings/settings.page.tsx index 75725e08b..b49217688 100644 --- a/frontend/app/src/pages/settings/settings.page.tsx +++ b/frontend/app/src/pages/settings/settings.page.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from 'react'; import { StandaloneLayout, SettingsLayout } from '#app/layouts'; import { - setSystemSettings, - systemSettingsApi, - type SystemSettings, -} from '#app/entities/system-settings'; + setPlatformSettings, + platformSettingsApi, + type PlatformSettings, +} from '#app/entities/platform-settings'; import { userApi } from '#app/entities/user'; import { Icon } from '#app/shared/icon'; import { navigate } from '#app/shared/runtime'; @@ -14,12 +14,13 @@ import { InfoTab } from '#app/pages/account/profile/info-tab'; import { PreferencesTab } from '#app/pages/account/profile/preferences-tab'; import { SecurityOverviewPage } from '#app/pages/account/profile/security-overview.page'; import { SessionsTab } from '#app/pages/account/profile/sessions-tab'; -import { GeneralTab } from '#app/pages/account/setting/general-tab'; -import { WorkspaceTab } from '#app/pages/account/setting/workspace-tab'; -import { GlobalizationTab } from '#app/pages/account/setting/globalization-tab'; -import { BrandingTab } from '#app/pages/account/setting/branding-tab'; -import { AuthTab } from '#app/pages/account/setting/auth-tab'; -import { RolesTab } from '#app/pages/account/setting/roles-tab'; +import { NotFoundPage } from '#app/pages/errors/404/not-found.page'; +import { GeneralTab } from '#app/pages/settings/tabs/general-tab'; +import { WorkspaceTab } from '#app/pages/settings/tabs/workspace-tab'; +import { BrandingTab } from '#app/pages/settings/tabs/branding-tab'; +import { AuthTab } from '#app/pages/settings/tabs/auth-tab'; +import { RolesTab } from '#app/pages/settings/tabs/roles-tab'; +import { MenusTab } from '#app/pages/settings/tabs/menus-tab'; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '#app/shared/sidebar'; import { useTranslation } from 'react-i18next'; import { meta } from '#app/shared/meta'; @@ -32,7 +33,8 @@ export function SettingsPage() { const page = useSettingsPage(location.pathname); const currentUser = user.useStore(); const [hasInternalIdentity, setHasInternalIdentity] = useState(true); - const [settings, setSettings] = useState(null); + const [settings, setSettings] = useState(null); + const [isUserResolved, setIsUserResolved] = useState(Boolean(currentUser)); const effectiveHasInternalIdentity = currentUser?.hasInternalIdentity ?? hasInternalIdentity; useEffect(() => { @@ -41,23 +43,59 @@ export function SettingsPage() { useEffect(() => { if (user.get()) { + setIsUserResolved(true); return; } - void userApi.me().then((currentUser) => { - user.set(currentUser); - }); + void userApi + .me() + .then((currentUser) => { + user.set(currentUser); + }) + .catch(() => { + user.set(null); + }) + .finally(() => { + setIsUserResolved(true); + }); }, []); useEffect(() => { - void systemSettingsApi.get().then((data) => { + if (currentUser) { + setIsUserResolved(true); + } + }, [currentUser]); + + useEffect(() => { + void platformSettingsApi.get().then((data) => { setSettings(data); - setSystemSettings(data); + setPlatformSettings(data); }); }, []); + useEffect(() => { + if (page.leafVariant !== 'default' || page.isDeniedPath) { + return; + } + + const canonicalPath = page.currentLeaf?.path; + if (!canonicalPath || location.pathname === canonicalPath) { + return; + } + + navigate(`${canonicalPath}${location.search}${location.hash}`, true); + }, [location.hash, location.pathname, location.search, page.currentLeaf?.path, page.isDeniedPath, page.leafVariant]); + + if (!isUserResolved && page.requiresUserResolution) { + return null; + } + + if (isUserResolved && page.isDeniedPath) { + return ; + } + const content = (() => { - if (location.pathname.startsWith('/settings/account/security/password')) { + if (page.leafVariant === 'account-security-password') { return ( ; } @@ -93,23 +131,25 @@ export function SettingsPage() { return ; case 'account/sessions': return ; - case 'system/general': + case 'platform/general': return ; - case 'system/workspace': + case 'platform/workspace-policy': return ; - case 'system/globalization': - return ; - case 'system/branding': + case 'platform/branding': return ; - case 'system/authentication': + case 'platform/authentication': return ; - case 'system/roles': + case 'platform/roles': return ; + case 'platform/menus': + return ; default: return ; } })(); + const contentWidthClass = page.currentLeaf?.key === 'menus' ? 'max-w-6xl' : 'max-w-2xl'; + return (
@@ -143,7 +183,7 @@ export function SettingsPage() {
} > -
{content}
+
{content}
diff --git a/frontend/app/src/pages/account/setting/auth-tab.test.tsx b/frontend/app/src/pages/settings/tabs/auth-tab.test.tsx similarity index 88% rename from frontend/app/src/pages/account/setting/auth-tab.test.tsx rename to frontend/app/src/pages/settings/tabs/auth-tab.test.tsx index 20b8281a3..fc3adf0bc 100644 --- a/frontend/app/src/pages/account/setting/auth-tab.test.tsx +++ b/frontend/app/src/pages/settings/tabs/auth-tab.test.tsx @@ -2,11 +2,11 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vite import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { AuthTab } from './auth-tab'; -import { systemSettingsApi } from '#app/entities/system-settings'; +import { platformSettingsApi } from '#app/entities/platform-settings'; // Mocks -vi.mock('#app/entities/system-settings', () => ({ - systemSettingsApi: { +vi.mock('#app/entities/platform-settings', () => ({ + platformSettingsApi: { getAuth: vi.fn(), updateAuth: vi.fn(), getOAuthProviders: vi.fn(), @@ -78,8 +78,8 @@ const mockOAuthProviders = [ describe('AuthTab', () => { beforeEach(() => { vi.clearAllMocks(); - (systemSettingsApi.getAuth as Mock).mockResolvedValue(mockAuthSettings); - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue(mockOAuthProviders); + (platformSettingsApi.getAuth as Mock).mockResolvedValue(mockAuthSettings); + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue(mockOAuthProviders); }); afterEach(() => { @@ -120,7 +120,7 @@ describe('AuthTab', () => { render(); await waitFor(() => { - expect(systemSettingsApi.getOAuthProviders).toHaveBeenCalledWith(); + expect(platformSettingsApi.getOAuthProviders).toHaveBeenCalledWith(); }); expect(screen.getByText('Save')).toBeDefined(); @@ -143,7 +143,7 @@ describe('AuthTab', () => { render(); await waitFor(() => { - expect(systemSettingsApi.getAuth).toHaveBeenCalledWith(); + expect(platformSettingsApi.getAuth).toHaveBeenCalledWith(); }); }); @@ -151,12 +151,12 @@ describe('AuthTab', () => { render(); await waitFor(() => { - expect(systemSettingsApi.getOAuthProviders).toHaveBeenCalledWith(); + expect(platformSettingsApi.getOAuthProviders).toHaveBeenCalledWith(); }); }); it('API가 internalLoginEnabled=false를 반환하면 토글이 꺼진 상태여야 함', async () => { - (systemSettingsApi.getAuth as Mock).mockResolvedValue({ internalLoginEnabled: false }); + (platformSettingsApi.getAuth as Mock).mockResolvedValue({ internalLoginEnabled: false }); render(); @@ -167,7 +167,7 @@ describe('AuthTab', () => { }); it('API가 OAuth enabled=true를 반환하면 해당 Provider 토글이 켜진 상태여야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ { provider: 'GOOGLE', enabled: true, clientId: '', clientSecretSet: false }, ...mockOAuthProviders.slice(1), ]); @@ -256,7 +256,7 @@ describe('AuthTab', () => { it('저장 성공 시 성공 토스트를 표시해야 함', async () => { const { showToast } = await import('#app/shared/toast'); - (systemSettingsApi.updateAuth as Mock).mockResolvedValue({}); + (platformSettingsApi.updateAuth as Mock).mockResolvedValue({}); render(); @@ -268,7 +268,7 @@ describe('AuthTab', () => { fireEvent.click(saveBtn!); await waitFor(() => { - expect(systemSettingsApi.updateAuth).toHaveBeenCalledWith(expect.any(Object)); + expect(platformSettingsApi.updateAuth).toHaveBeenCalledWith(expect.any(Object)); expect(showToast).toHaveBeenCalledWith('Authentication settings saved', 'success'); }); }); @@ -276,8 +276,8 @@ describe('AuthTab', () => { describe('저장 시 API 호출 스펙', () => { it('updateAuth에는 internalLoginEnabled만 전송해야 함', async () => { - (systemSettingsApi.updateAuth as Mock).mockResolvedValue({}); - (systemSettingsApi.updateOAuthProviders as Mock).mockResolvedValue({}); + (platformSettingsApi.updateAuth as Mock).mockResolvedValue({}); + (platformSettingsApi.updateOAuthProviders as Mock).mockResolvedValue({}); render(); await waitFor(() => expect(screen.getByText('Save')).toBeDefined()); @@ -285,15 +285,15 @@ describe('AuthTab', () => { fireEvent.click(screen.getByText('Save').closest('button')!); await waitFor(() => { - expect(systemSettingsApi.updateAuth).toHaveBeenCalledWith({ + expect(platformSettingsApi.updateAuth).toHaveBeenCalledWith({ internalLoginEnabled: true, }); }); }); it('updateOAuthProviders에는 프로바이더별로 분리된 설정을 전송해야 함', async () => { - (systemSettingsApi.updateAuth as Mock).mockResolvedValue({}); - (systemSettingsApi.updateOAuthProviders as Mock).mockResolvedValue({}); + (platformSettingsApi.updateAuth as Mock).mockResolvedValue({}); + (platformSettingsApi.updateOAuthProviders as Mock).mockResolvedValue({}); render(); await waitFor(() => expect(screen.getByText('Save')).toBeDefined()); @@ -301,7 +301,7 @@ describe('AuthTab', () => { fireEvent.click(screen.getByText('Save').closest('button')!); await waitFor(() => { - expect(systemSettingsApi.updateOAuthProviders).toHaveBeenCalledWith({ + expect(platformSettingsApi.updateOAuthProviders).toHaveBeenCalledWith({ providers: { GOOGLE: { enabled: false, clientId: null, clientSecret: null, domain: null }, NAVER: { enabled: false, clientId: null, clientSecret: null, domain: null }, @@ -317,7 +317,7 @@ describe('AuthTab', () => { describe('OAuth Provider 유효성 검사', () => { it('필수 입력 라벨은 자동 표시용 fieldset 구조를 따라야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ { provider: 'GOOGLE', enabled: true, clientId: '', clientSecretSet: false }, ...mockOAuthProviders.slice(1), ]); @@ -339,7 +339,7 @@ describe('AuthTab', () => { }); it('활성화된 Provider는 Client ID와 Client Secret 입력을 required로 표시해야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ { provider: 'GOOGLE', enabled: true, clientId: '', clientSecretSet: false }, ...mockOAuthProviders.slice(1), ]); @@ -360,7 +360,7 @@ describe('AuthTab', () => { }); it('활성화된 Okta의 Domain 입력은 required 속성을 가져야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ ...mockOAuthProviders.slice(0, 3), { provider: 'OKTA', @@ -383,7 +383,7 @@ describe('AuthTab', () => { }); it('활성화된 Microsoft의 Tenant ID 입력은 required 속성을 가져야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ ...mockOAuthProviders.slice(0, 4), { provider: 'MICROSOFT', @@ -407,7 +407,7 @@ describe('AuthTab', () => { }); it('기존 Client Secret이 설정된 경우 Client Secret 입력은 required가 아니어야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ { provider: 'GOOGLE', enabled: true, clientId: 'google-client-id', clientSecretSet: true }, ...mockOAuthProviders.slice(1), ]); @@ -424,7 +424,7 @@ describe('AuthTab', () => { it('활성화된 Provider에 Client ID가 없으면 에러 토스트를 표시해야 함', async () => { const { showToast } = await import('#app/shared/toast'); - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ { provider: 'GOOGLE', enabled: true, clientId: '', clientSecretSet: false }, ...mockOAuthProviders.slice(1), ]); @@ -442,7 +442,7 @@ describe('AuthTab', () => { }); it('활성화된 Provider에 Client ID가 없으면 Client ID 입력으로 포커스 이동해야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ { provider: 'GOOGLE', enabled: true, clientId: '', clientSecretSet: false }, ...mockOAuthProviders.slice(1), ]); @@ -463,7 +463,7 @@ describe('AuthTab', () => { }); it('활성화된 Provider에 Client Secret이 없으면 Client Secret 입력으로 포커스 이동해야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ { provider: 'GOOGLE', enabled: true, clientId: 'google-client-id', clientSecretSet: false }, ...mockOAuthProviders.slice(1), ]); @@ -486,7 +486,7 @@ describe('AuthTab', () => { }); it('Okta의 Domain이 없으면 Domain 입력으로 포커스 이동해야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ ...mockOAuthProviders.slice(0, 3), { provider: 'OKTA', @@ -514,7 +514,7 @@ describe('AuthTab', () => { }); it('Microsoft의 Tenant ID가 없으면 Tenant ID 입력으로 포커스 이동해야 함', async () => { - (systemSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ + (platformSettingsApi.getOAuthProviders as Mock).mockResolvedValue([ ...mockOAuthProviders.slice(0, 4), { provider: 'MICROSOFT', diff --git a/frontend/app/src/pages/account/setting/auth-tab.tsx b/frontend/app/src/pages/settings/tabs/auth-tab.tsx similarity index 98% rename from frontend/app/src/pages/account/setting/auth-tab.tsx rename to frontend/app/src/pages/settings/tabs/auth-tab.tsx index 06d62d1b0..2ef8acfb8 100644 --- a/frontend/app/src/pages/account/setting/auth-tab.tsx +++ b/frontend/app/src/pages/settings/tabs/auth-tab.tsx @@ -4,7 +4,7 @@ import { ItemMedia } from '#app/components/ui/item'; import { Field, FieldLabel, FieldSet } from '#app/components/ui/field'; import { Input } from '#app/components/ui/input'; import { Separator } from '#app/components/ui/separator'; -import { systemSettingsApi } from '#app/entities/system-settings'; +import { platformSettingsApi } from '#app/entities/platform-settings'; import { PasswordInput } from '#app/shared/password-input'; import { SettingsActionGroup, @@ -156,10 +156,10 @@ export function AuthTab() { const loadSettings = async () => { try { - const authData = await systemSettingsApi.getAuth(); + const authData = await platformSettingsApi.getAuth(); setInternalLoginEnabled(authData.internalLoginEnabled); - const oauthData = await systemSettingsApi.getOAuthProviders(); + const oauthData = await platformSettingsApi.getOAuthProviders(); const newOauth = { ...oauth }; for (const provider of oauthData) { const key = provider.provider as OAuthProvider; @@ -235,7 +235,7 @@ export function AuthTab() { setSavingAuth(true); try { - await systemSettingsApi.updateAuth({ internalLoginEnabled }); + await platformSettingsApi.updateAuth({ internalLoginEnabled }); const providers: Record = {}; for (const [key, value] of Object.entries(oauth)) { @@ -246,7 +246,7 @@ export function AuthTab() { domain: value.domain || null, }; } - await systemSettingsApi.updateOAuthProviders({ providers }); + await platformSettingsApi.updateOAuthProviders({ providers }); // Clear secrets and mark as set const newOauth = { ...oauth }; diff --git a/frontend/app/src/pages/account/setting/branding-tab.test.tsx b/frontend/app/src/pages/settings/tabs/branding-tab.test.tsx similarity index 93% rename from frontend/app/src/pages/account/setting/branding-tab.test.tsx rename to frontend/app/src/pages/settings/tabs/branding-tab.test.tsx index 7607a843d..c8983a0ce 100644 --- a/frontend/app/src/pages/account/setting/branding-tab.test.tsx +++ b/frontend/app/src/pages/settings/tabs/branding-tab.test.tsx @@ -3,13 +3,13 @@ import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/re import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { BrandingTab } from './branding-tab'; -import { systemSettingsApi } from '#app/entities/system-settings'; +import { platformSettingsApi } from '#app/entities/platform-settings'; const brandingTabSource = readFileSync(resolve(__dirname, './branding-tab.tsx'), 'utf-8'); // Mocks -vi.mock('#app/entities/system-settings', () => ({ - systemSettingsApi: { +vi.mock('#app/entities/platform-settings', () => ({ + platformSettingsApi: { setLogoUrl: vi.fn(), uploadLogo: vi.fn(), resetLogo: vi.fn(), @@ -216,7 +216,7 @@ describe('BrandingTab', () => { it('URL 설정 성공 시 성공 토스트를 표시해야 함', async () => { const { showToast } = await import('#app/shared/toast'); const { clearBrandingCache } = await import('#app/shared/branding'); - (systemSettingsApi.setLogoUrl as Mock).mockResolvedValue({ + (platformSettingsApi.setLogoUrl as Mock).mockResolvedValue({ logoHorizontalUrl: 'https://new-url.com/logo-light.svg', }); @@ -237,7 +237,7 @@ describe('BrandingTab', () => { fireEvent.click(setButtons[0].closest('button')!); await waitFor(() => { - expect(systemSettingsApi.setLogoUrl).toHaveBeenCalledWith( + expect(platformSettingsApi.setLogoUrl).toHaveBeenCalledWith( 'HORIZONTAL_LIGHT', 'https://new-url.com/logo-light.svg' ); @@ -247,7 +247,7 @@ describe('BrandingTab', () => { }); it('Dark Logo URL 설정 시 HORIZONTAL_DARK 타입으로 호출해야 함', async () => { - (systemSettingsApi.setLogoUrl as Mock).mockResolvedValue({ + (platformSettingsApi.setLogoUrl as Mock).mockResolvedValue({ logoHorizontalDarkUrl: 'https://new-url.com/logo-dark.svg', }); @@ -270,7 +270,7 @@ describe('BrandingTab', () => { fireEvent.click(setButtons[1].closest('button')!); await waitFor(() => { - expect(systemSettingsApi.setLogoUrl).toHaveBeenCalledWith( + expect(platformSettingsApi.setLogoUrl).toHaveBeenCalledWith( 'HORIZONTAL_DARK', 'https://new-url.com/logo-dark.svg' ); @@ -282,7 +282,7 @@ describe('BrandingTab', () => { it('Clear 버튼 클릭 시 로고를 초기화해야 함', async () => { const { showToast } = await import('#app/shared/toast'); const { clearBrandingCache } = await import('#app/shared/branding'); - (systemSettingsApi.resetLogo as Mock).mockResolvedValue({}); + (platformSettingsApi.resetLogo as Mock).mockResolvedValue({}); render(); @@ -299,7 +299,7 @@ describe('BrandingTab', () => { fireEvent.click(clearButtons[0].closest('button')!); await waitFor(() => { - expect(systemSettingsApi.resetLogo).toHaveBeenCalledWith('HORIZONTAL_LIGHT'); + expect(platformSettingsApi.resetLogo).toHaveBeenCalledWith('HORIZONTAL_LIGHT'); expect(showToast).toHaveBeenCalledWith('Logo cleared', 'success'); expect(clearBrandingCache).toHaveBeenCalled(); }); @@ -308,8 +308,8 @@ describe('BrandingTab', () => { describe('로고 업로드', () => { it('파일을 선택하면 선택된 파일명을 표시해야 함', async () => { - (systemSettingsApi.uploadLogo as Mock).mockResolvedValue({ - logoHorizontalUrl: '/api/v1/system-settings/logo/HORIZONTAL', + (platformSettingsApi.uploadLogo as Mock).mockResolvedValue({ + logoHorizontalUrl: '/api/v1/platform-settings/logo/HORIZONTAL', }); const { container } = render(); @@ -329,8 +329,8 @@ describe('BrandingTab', () => { it('업로드 성공 시 backend가 반환한 URL로 미리보기를 갱신해야 함', async () => { const { showToast } = await import('#app/shared/toast'); const { clearBrandingCache } = await import('#app/shared/branding'); - (systemSettingsApi.uploadLogo as Mock).mockResolvedValue({ - logoHorizontalUrl: '/api/v1/system-settings/logo/HORIZONTAL', + (platformSettingsApi.uploadLogo as Mock).mockResolvedValue({ + logoHorizontalUrl: '/api/v1/platform-settings/logo/HORIZONTAL', }); const { container } = render(); @@ -349,7 +349,7 @@ describe('BrandingTab', () => { fireEvent.change(fileInput, { target: { files: [file] } }); await waitFor(() => { - expect(systemSettingsApi.uploadLogo).toHaveBeenCalledWith( + expect(platformSettingsApi.uploadLogo).toHaveBeenCalledWith( 'HORIZONTAL_LIGHT', expect.stringContaining('data:image/svg+xml;base64,') ); @@ -358,7 +358,7 @@ describe('BrandingTab', () => { }); const preview = screen.getByAltText('Logo (Light Theme)') as HTMLImageElement; - expect(preview.src).toContain('/api/v1/system-settings/logo/HORIZONTAL'); + expect(preview.src).toContain('/api/v1/platform-settings/logo/HORIZONTAL'); }); }); diff --git a/frontend/app/src/pages/account/setting/branding-tab.tsx b/frontend/app/src/pages/settings/tabs/branding-tab.tsx similarity index 98% rename from frontend/app/src/pages/account/setting/branding-tab.tsx rename to frontend/app/src/pages/settings/tabs/branding-tab.tsx index 6ddfed9d1..7aff1b268 100644 --- a/frontend/app/src/pages/account/setting/branding-tab.tsx +++ b/frontend/app/src/pages/settings/tabs/branding-tab.tsx @@ -8,7 +8,7 @@ import { SelectTrigger, SelectValue, } from '#app/components/ui/select'; -import { systemSettingsApi, type SystemSettings } from '#app/entities/system-settings'; +import { platformSettingsApi, type PlatformSettings } from '#app/entities/platform-settings'; import { Icon } from '#app/shared/icon'; import { SettingsActionButton, @@ -24,7 +24,7 @@ import { Spinner } from '#app/shared/spinner'; import { useTranslation } from 'react-i18next'; interface BrandingTabProps { - settings: SystemSettings | null; + settings: PlatformSettings | null; } type BrandingLogoType = @@ -340,7 +340,7 @@ export function BrandingTab({ settings }: BrandingTabProps) { setSavingLogo(type); try { - const data = await systemSettingsApi.setLogoUrl(type, url); + const data = await platformSettingsApi.setLogoUrl(type, url); const newUrl = data[RESPONSE_KEY_MAP[type]] || null; updateLogoState(type, { url: newUrl, inputValue: '', selectedFileName: '' }); applyBrandingUrlsFromResponse(data); @@ -360,7 +360,7 @@ export function BrandingTab({ settings }: BrandingTabProps) { setSavingLogo(type); try { const base64 = await fileToBase64(file); - const data = await systemSettingsApi.uploadLogo(type, base64); + const data = await platformSettingsApi.uploadLogo(type, base64); const newUrl = data[RESPONSE_KEY_MAP[type]] || null; updateLogoState(type, { url: newUrl, selectedFileName: file.name }); applyBrandingUrlsFromResponse(data); @@ -373,7 +373,7 @@ export function BrandingTab({ settings }: BrandingTabProps) { const clearLogo = async (type: BrandingLogoType) => { setSavingLogo(type); try { - const data = await systemSettingsApi.resetLogo(type); + const data = await platformSettingsApi.resetLogo(type); updateLogoState(type, { url: null, selectedFileName: '' }); applyBrandingUrlsFromResponse(data); showToast(t('setting.branding.logoCleared'), 'success'); diff --git a/frontend/app/src/pages/account/setting/general-tab.test.tsx b/frontend/app/src/pages/settings/tabs/general-tab.test.tsx similarity index 90% rename from frontend/app/src/pages/account/setting/general-tab.test.tsx rename to frontend/app/src/pages/settings/tabs/general-tab.test.tsx index 4a90f0d7a..0d58c933e 100644 --- a/frontend/app/src/pages/account/setting/general-tab.test.tsx +++ b/frontend/app/src/pages/settings/tabs/general-tab.test.tsx @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; import { GeneralTab } from './general-tab'; -import { systemSettingsApi } from '#app/entities/system-settings'; +import { platformSettingsApi } from '#app/entities/platform-settings'; -vi.mock('#app/entities/system-settings', () => ({ - systemSettingsApi: { +vi.mock('#app/entities/platform-settings', () => ({ + platformSettingsApi: { updateGeneral: vi.fn(), }, - setSystemSettings: vi.fn(), + setPlatformSettings: vi.fn(), })); vi.mock('#app/shared/toast', () => ({ @@ -20,7 +20,8 @@ const mockSettings = { baseUrl: 'https://deck.example.com', workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, }; @@ -126,12 +127,12 @@ describe('GeneralTab', () => { fireEvent.submit(form); expect(showToast).toHaveBeenCalledWith('Brand name is required', 'error'); - expect(systemSettingsApi.updateGeneral).not.toHaveBeenCalled(); + expect(platformSettingsApi.updateGeneral).not.toHaveBeenCalled(); }); it('저장 성공 시 성공 토스트를 표시해야 함', async () => { const { showToast } = await import('#app/shared/toast'); - (systemSettingsApi.updateGeneral as Mock).mockResolvedValue({}); + (platformSettingsApi.updateGeneral as Mock).mockResolvedValue({}); render(); @@ -139,7 +140,7 @@ describe('GeneralTab', () => { fireEvent.click(saveButton); await waitFor(() => { - expect(systemSettingsApi.updateGeneral).toHaveBeenCalledWith({ + expect(platformSettingsApi.updateGeneral).toHaveBeenCalledWith({ brandName: 'Test Brand', contactEmail: 'privacy@test.example', }); @@ -148,8 +149,8 @@ describe('GeneralTab', () => { }); it('저장 성공 시 페이지 title을 갱신해야 함', async () => { - (systemSettingsApi.updateGeneral as Mock).mockResolvedValue({}); - document.title = 'Setting - Deck'; + (platformSettingsApi.updateGeneral as Mock).mockResolvedValue({}); + document.title = 'Settings - Deck'; render(); @@ -160,12 +161,12 @@ describe('GeneralTab', () => { fireEvent.click(saveButton); await waitFor(() => { - expect(document.title).toBe('Setting - Deck Enterprise'); + expect(document.title).toBe('Settings - Deck Enterprise'); }); }); it('저장 요청에는 brandName만 포함해야 함', async () => { - (systemSettingsApi.updateGeneral as Mock).mockResolvedValue({}); + (platformSettingsApi.updateGeneral as Mock).mockResolvedValue({}); render(); @@ -173,7 +174,7 @@ describe('GeneralTab', () => { fireEvent.click(saveButton); await waitFor(() => { - expect(systemSettingsApi.updateGeneral).toHaveBeenCalledWith({ + expect(platformSettingsApi.updateGeneral).toHaveBeenCalledWith({ brandName: 'Test Brand', contactEmail: 'privacy@test.example', }); diff --git a/frontend/app/src/pages/account/setting/general-tab.tsx b/frontend/app/src/pages/settings/tabs/general-tab.tsx similarity index 91% rename from frontend/app/src/pages/account/setting/general-tab.tsx rename to frontend/app/src/pages/settings/tabs/general-tab.tsx index df1f18054..bf5e1b473 100644 --- a/frontend/app/src/pages/account/setting/general-tab.tsx +++ b/frontend/app/src/pages/settings/tabs/general-tab.tsx @@ -2,10 +2,10 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Input } from '#app/components/ui/input'; import { - setSystemSettings, - systemSettingsApi, - type SystemSettings, -} from '#app/entities/system-settings'; + setPlatformSettings, + platformSettingsApi, + type PlatformSettings, +} from '#app/entities/platform-settings'; import { SettingsActionGroup, SettingsControl, @@ -17,12 +17,12 @@ import { import { showToast } from '#app/shared/toast'; interface GeneralTabProps { - settings: SystemSettings | null; - onSettingsChange?: (settings: SystemSettings) => void; + settings: PlatformSettings | null; + onSettingsChange?: (settings: PlatformSettings) => void; } function applySettingPageTitle(brandName: string): void { - document.title = `Setting - ${brandName.trim()}`; + document.title = `Settings - ${brandName.trim()}`; } export function GeneralTab({ settings, onSettingsChange }: GeneralTabProps) { @@ -53,17 +53,17 @@ export function GeneralTab({ settings, onSettingsChange }: GeneralTabProps) { setSaving(true); try { - await systemSettingsApi.updateGeneral({ + await platformSettingsApi.updateGeneral({ brandName, contactEmail: contactEmail.trim() || null, }); applySettingPageTitle(brandName); - const nextSettings: SystemSettings = { + const nextSettings: PlatformSettings = { ...(settings ?? { baseUrl: '', workspacePolicy: null }), brandName, contactEmail: contactEmail.trim() || null, }; - setSystemSettings(nextSettings); + setPlatformSettings(nextSettings); onSettingsChange?.(nextSettings); showToast(t('setting.general.savedMessage'), 'success'); } finally { diff --git a/frontend/app/src/pages/settings/tabs/menus-tab.tsx b/frontend/app/src/pages/settings/tabs/menus-tab.tsx new file mode 100644 index 000000000..3f7d50665 --- /dev/null +++ b/frontend/app/src/pages/settings/tabs/menus-tab.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next'; +import { + MenuDetailPanel, + MenusPageActions, + MenusPageFilters, + PanelCard, + useMenusPage, +} from '#app/features/menus/manage-menus'; +import { Splitter } from '#app/shared/splitter'; +import { Spinner } from '#app/shared/spinner'; +import { TreeView } from '#app/shared/tree'; + +export function MenusTab() { + const { t } = useTranslation('system'); + const page = useMenusPage(); + + return ( +
+ + + +
+ {page.loading && ( +
+ +
+ )} + + +
+ +
+ + } + right={ + + + + } + initialPercent={40} + className="md:min-h-content gap-2 md:gap-3" + /> +
+ + {page.confirmDialog} +
+ ); +} + +export default MenusTab; diff --git a/frontend/app/src/pages/account/setting/roles-tab.test.tsx b/frontend/app/src/pages/settings/tabs/roles-tab.test.tsx similarity index 100% rename from frontend/app/src/pages/account/setting/roles-tab.test.tsx rename to frontend/app/src/pages/settings/tabs/roles-tab.test.tsx diff --git a/frontend/app/src/pages/account/setting/roles-tab.tsx b/frontend/app/src/pages/settings/tabs/roles-tab.tsx similarity index 100% rename from frontend/app/src/pages/account/setting/roles-tab.tsx rename to frontend/app/src/pages/settings/tabs/roles-tab.tsx diff --git a/frontend/app/src/pages/account/setting/types.ts b/frontend/app/src/pages/settings/tabs/types.ts similarity index 83% rename from frontend/app/src/pages/account/setting/types.ts rename to frontend/app/src/pages/settings/tabs/types.ts index b3fae83b3..f597c9d42 100644 --- a/frontend/app/src/pages/account/setting/types.ts +++ b/frontend/app/src/pages/settings/tabs/types.ts @@ -2,9 +2,9 @@ export type { LogoType, OAuthProvider, - SystemSettings, + PlatformSettings, AuthResponse, -} from '#app/entities/system-settings'; +} from '#app/entities/platform-settings'; // UI-only types (stay in pages) export interface OAuthProviderState { diff --git a/frontend/app/src/pages/account/setting/workspace-tab.test.tsx b/frontend/app/src/pages/settings/tabs/workspace-tab.test.tsx similarity index 61% rename from frontend/app/src/pages/account/setting/workspace-tab.test.tsx rename to frontend/app/src/pages/settings/tabs/workspace-tab.test.tsx index f170b510d..44f5476a9 100644 --- a/frontend/app/src/pages/account/setting/workspace-tab.test.tsx +++ b/frontend/app/src/pages/settings/tabs/workspace-tab.test.tsx @@ -1,16 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; import { WorkspaceTab } from './workspace-tab'; -import { systemSettingsApi } from '#app/entities/system-settings'; +import { platformSettingsApi } from '#app/entities/platform-settings'; -vi.mock('#app/entities/system-settings', () => ({ - systemSettingsApi: { +vi.mock('#app/entities/platform-settings', () => ({ + platformSettingsApi: { updateWorkspacePolicy: vi.fn(), updateCountryPolicy: vi.fn(), updateCurrencyPolicy: vi.fn(), }, - setSystemSettings: vi.fn(), - updateSystemWorkspacePolicy: vi.fn(), + setPlatformSettings: vi.fn(), + updatePlatformWorkspacePolicy: vi.fn(), })); vi.mock('#app/entities/workspace', () => ({ @@ -26,7 +26,8 @@ const settings = { baseUrl: 'https://deck.test', workspacePolicy: { useUserManaged: true, - useSystemManaged: false, + usePlatformManaged: false, + useExternalSync: false, useSelector: true, }, }; @@ -44,9 +45,10 @@ describe('WorkspaceTab', () => { const { container } = render(); expect(screen.getByLabelText('Use user-managed')).toBeDefined(); - expect(screen.getByLabelText('Use system-managed')).toBeDefined(); + expect(screen.getByLabelText('Use platform-managed')).toBeDefined(); + expect(screen.getByLabelText('Use external sync')).toBeDefined(); expect(screen.getByLabelText('Use selector')).toBeDefined(); - expect(screen.getAllByTestId('settings-row')).toHaveLength(3); + expect(screen.getAllByTestId('settings-row')).toHaveLength(4); expect(screen.getByTestId('settings-page-actions')).toBeDefined(); expect(container.querySelectorAll('.bg-muted.rounded-lg.p-4')).toHaveLength(0); }); @@ -61,6 +63,26 @@ describe('WorkspaceTab', () => { expect(description.previousElementSibling?.textContent).toBe('Use user-managed'); }); + it('platform-managed 설명은 소유/관리 경계를 안내해야 한다', () => { + render(); + + const description = screen.getByText('Allow workspaces that the platform owns and manages.'); + + expect(description.closest('[data-testid="settings-row"]')).not.toBeNull(); + expect(description.previousElementSibling?.textContent).toBe('Use platform-managed'); + }); + + it('external sync 설명은 AIP organization claim 동기화를 안내해야 한다', () => { + render(); + + const description = screen.getByText( + 'Automatically sync external workspaces and memberships from AIP organization claims.' + ); + + expect(description.closest('[data-testid="settings-row"]')).not.toBeNull(); + expect(description.previousElementSibling?.textContent).toBe('Use external sync'); + }); + it('workspace 제목을 클릭하면 해당 토글이 전환되어야 한다', () => { render(); @@ -73,7 +95,7 @@ describe('WorkspaceTab', () => { }); it('두 managed 타입을 모두 끄면 null policy로 저장해야 한다', async () => { - (systemSettingsApi.updateWorkspacePolicy as Mock).mockResolvedValue(settings); + (platformSettingsApi.updateWorkspacePolicy as Mock).mockResolvedValue(settings); render(); @@ -81,7 +103,7 @@ describe('WorkspaceTab', () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => { - expect(systemSettingsApi.updateWorkspacePolicy).toHaveBeenCalledWith({ + expect(platformSettingsApi.updateWorkspacePolicy).toHaveBeenCalledWith({ workspacePolicy: null, }); }); diff --git a/frontend/app/src/pages/account/setting/workspace-tab.tsx b/frontend/app/src/pages/settings/tabs/workspace-tab.tsx similarity index 62% rename from frontend/app/src/pages/account/setting/workspace-tab.tsx rename to frontend/app/src/pages/settings/tabs/workspace-tab.tsx index 33399f839..2ece9d1f2 100644 --- a/frontend/app/src/pages/account/setting/workspace-tab.tsx +++ b/frontend/app/src/pages/settings/tabs/workspace-tab.tsx @@ -8,32 +8,40 @@ import { SettingsToggleRow, } from '#app/shared/settings-list'; import { - setSystemSettings, - systemSettingsApi, - type SystemSettings, + setPlatformSettings, + platformSettingsApi, + type PlatformSettings, type WorkspacePolicy, - updateSystemWorkspacePolicy, -} from '#app/entities/system-settings'; + updatePlatformWorkspacePolicy, +} from '#app/entities/platform-settings'; import { applyWorkspacePolicy } from '#app/entities/workspace'; import { showToast } from '#app/shared/toast'; interface WorkspaceTabProps { - settings: SystemSettings | null; - onSettingsChange?: (settings: SystemSettings) => void; + settings: PlatformSettings | null; + onSettingsChange?: (settings: PlatformSettings) => void; } function normalizeWorkspacePolicy(policy: WorkspacePolicy): WorkspacePolicy | null { - if (!policy.useUserManaged && !policy.useSystemManaged) { + const normalizedPolicy: WorkspacePolicy = { + ...policy, + useExternalSync: policy.usePlatformManaged && policy.useExternalSync, + useSelector: (policy.useUserManaged || policy.usePlatformManaged) && policy.useSelector, + }; + + if (!normalizedPolicy.useUserManaged && !normalizedPolicy.usePlatformManaged) { return null; } - return policy; + + return normalizedPolicy; } export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) { const { t } = useTranslation('account'); const [policy, setPolicy] = useState({ useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }); const [saving, setSaving] = useState(false); @@ -44,7 +52,8 @@ export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) } else { setPolicy({ useUserManaged: false, - useSystemManaged: false, + usePlatformManaged: false, + useExternalSync: false, useSelector: false, }); } @@ -56,11 +65,11 @@ export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) const workspacePolicy = normalizeWorkspacePolicy(policy); setSaving(true); try { - const nextSettings = await systemSettingsApi.updateWorkspacePolicy({ + const nextSettings = await platformSettingsApi.updateWorkspacePolicy({ workspacePolicy, }); - setSystemSettings(nextSettings); - updateSystemWorkspacePolicy(workspacePolicy); + setPlatformSettings(nextSettings); + updatePlatformWorkspacePolicy(workspacePolicy); applyWorkspacePolicy(workspacePolicy); onSettingsChange?.(nextSettings); showToast(t('setting.workspace.savedMessage'), 'success'); @@ -76,9 +85,14 @@ export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) description: t('setting.workspace.useUserManagedDescription'), }, { - key: 'useSystemManaged' as const, - title: t('setting.workspace.useSystemManaged'), - description: t('setting.workspace.useSystemManagedDescription'), + key: 'usePlatformManaged' as const, + title: t('setting.workspace.usePlatformManaged'), + description: t('setting.workspace.usePlatformManagedDescription'), + }, + { + key: 'useExternalSync' as const, + title: t('setting.workspace.useExternalSync'), + description: t('setting.workspace.useExternalSyncDescription'), }, { key: 'useSelector' as const, @@ -100,7 +114,17 @@ export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) checked={policy[card.key]} switchAriaLabel={card.title} onCheckedChange={(checked) => - setPolicy((current) => ({ ...current, [card.key]: checked })) + setPolicy((current) => { + if (card.key === 'usePlatformManaged' && !checked) { + return { + ...current, + usePlatformManaged: false, + useExternalSync: false, + }; + } + + return { ...current, [card.key]: checked }; + }) } /> ))} diff --git a/frontend/app/src/pages/settings/use-settings-page.ts b/frontend/app/src/pages/settings/use-settings-page.ts index 5c39674e9..e737de414 100644 --- a/frontend/app/src/pages/settings/use-settings-page.ts +++ b/frontend/app/src/pages/settings/use-settings-page.ts @@ -2,32 +2,25 @@ import { useMemo } from 'react'; import { user } from '#app/app/state'; import { useTranslation } from 'react-i18next'; import { defaultSettingsPath, settingsNav } from './settings-nav'; - -function splitSettingsPath(pathname: string) { - const [, root, group, leaf] = pathname.split('/'); - - if (root !== 'settings') { - return { group: 'account', leaf: 'profile' }; - } - - return { - group: group || 'account', - leaf: leaf || 'profile', - }; -} +import { parseSettingsPath, resolveSettingsLeafVariant } from './settings-path'; export function useSettingsPage(pathname: string) { const currentUser = user.useStore(); const { t } = useTranslation('account'); const translate = (key: string) => t(key as never) as string; + const isPlatformAdmin = currentUser?.isOwner === true; return useMemo(() => { - const path = splitSettingsPath(pathname); + const path = parseSettingsPath(pathname); + const leafVariant = resolveSettingsLeafVariant(path); + const requestedGroup = settingsNav.find((group) => group.key === path.group) ?? null; + const requestedLeaf = requestedGroup?.leaves.find((leaf) => leaf.key === path.leaf) ?? null; + const requiresUserResolution = requestedLeaf?.platformAdminOnly === true; const visibleGroups = settingsNav .map((group) => ({ ...group, label: translate(group.labelKey), - leaves: group.leaves.filter((leaf) => !leaf.ownerOnly || currentUser?.isOwner), + leaves: group.leaves.filter((leaf) => !leaf.platformAdminOnly || isPlatformAdmin), })) .map((group) => ({ ...group, @@ -42,7 +35,12 @@ export function useSettingsPage(pathname: string) { const activeGroup = visibleGroups.find((group) => group.key === path.group); const activeLeaf = activeGroup?.leaves.find((leaf) => leaf.key === path.leaf); + const isDeniedPath = + (path.hasExplicitLeaf && requestedLeaf != null && activeLeaf == null) || + leafVariant === 'invalid'; + const groupFallbackLeaf = activeGroup?.leaves[0] ?? null; const fallbackLeaf = + groupFallbackLeaf ?? visibleGroups .find((group) => group.key === 'account') ?.leaves.find((leaf) => leaf.key === 'profile') ?? @@ -58,7 +56,10 @@ export function useSettingsPage(pathname: string) { groups: visibleGroups, currentGroup, currentLeaf, + leafVariant, + requiresUserResolution, + isDeniedPath, fallbackPath: fallbackLeaf?.path ?? defaultSettingsPath, }; - }, [currentUser?.isOwner, pathname, translate]); + }, [currentUser, isPlatformAdmin, pathname, translate]); } diff --git a/frontend/app/src/pages/system/email-templates/email-templates.page.tsx b/frontend/app/src/pages/system/email-templates/email-templates.page.tsx index b0654d269..e14177d23 100644 --- a/frontend/app/src/pages/system/email-templates/email-templates.page.tsx +++ b/frontend/app/src/pages/system/email-templates/email-templates.page.tsx @@ -1,7 +1,7 @@ /** * 이메일 템플릿 관리 페이지 * - * @see /system/email-templates/ + * @see /console/email-templates/ */ import { lazy, Suspense } from 'react'; import { emailTemplateApi, type EmailTemplate } from '#app/entities/email-template'; diff --git a/frontend/app/src/pages/system/menus/menus.page.test.tsx b/frontend/app/src/pages/system/menus/menus.page.test.tsx index a4ea54dd0..5052b043e 100644 --- a/frontend/app/src/pages/system/menus/menus.page.test.tsx +++ b/frontend/app/src/pages/system/menus/menus.page.test.tsx @@ -257,17 +257,17 @@ const mockPrograms = [ { code: 'NONE', path: null, permissions: [] }, { code: 'MENU_MANAGEMENT', - path: '/system/menus', + path: '/settings/platform/menus', permissions: ['MENU_MANAGEMENT_READ', 'MENU_MANAGEMENT_WRITE'], }, { code: 'USER_MANAGEMENT', - path: '/system/users', + path: '/console/users', permissions: ['USER_MANAGEMENT_READ', 'USER_MANAGEMENT_WRITE'], }, { code: 'WORKSPACE_MANAGEMENT', - path: '/system/workspaces', + path: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ', 'WORKSPACE_MANAGEMENT_WRITE'], }, ]; diff --git a/frontend/app/src/pages/system/slack-templates/slack-templates.page.tsx b/frontend/app/src/pages/system/slack-templates/slack-templates.page.tsx index ad378b86e..bb3e29ded 100644 --- a/frontend/app/src/pages/system/slack-templates/slack-templates.page.tsx +++ b/frontend/app/src/pages/system/slack-templates/slack-templates.page.tsx @@ -1,7 +1,7 @@ /** * Slack 템플릿 관리 페이지 * - * @see /system/slack-templates/ + * @see /console/slack-templates/ */ import { lazy, Suspense } from 'react'; import { slackTemplateApi, type SlackTemplate } from '#app/entities/slack-template'; diff --git a/frontend/app/src/pages/system/users/users.page.test.tsx b/frontend/app/src/pages/system/users/users.page.test.tsx index 439f4e749..5c9f8963c 100644 --- a/frontend/app/src/pages/system/users/users.page.test.tsx +++ b/frontend/app/src/pages/system/users/users.page.test.tsx @@ -13,13 +13,17 @@ const mockGrid = vi.hoisted(() => ({ props: null as GridProps | null, })); -vi.mock('#app/entities/user', () => ({ - userApi: { - list: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }, -})); +vi.mock('#app/entities/user', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + userApi: { + list: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }, + }; +}); vi.mock('#app/entities/notification-channel', () => ({ notificationChannelApi: { @@ -70,7 +74,7 @@ vi.mock('#app/shared/runtime', () => ({ getTargetWindow: vi.fn(() => window), getSearchParams: vi.fn(() => new URLSearchParams()), getOrigin: vi.fn(() => 'http://localhost:4011'), - getPathname: vi.fn(() => '/system/users/'), + getPathname: vi.fn(() => '/console/users/'), replaceUrl: vi.fn(), })); diff --git a/frontend/app/src/pages/system/workspaces/workspace-detail.page.test.tsx b/frontend/app/src/pages/system/workspaces/workspace-detail.page.test.tsx index ec18e2451..6accd392e 100644 --- a/frontend/app/src/pages/system/workspaces/workspace-detail.page.test.tsx +++ b/frontend/app/src/pages/system/workspaces/workspace-detail.page.test.tsx @@ -31,7 +31,7 @@ vi.mock('#app/widgets/tabbar', () => ({ describe('WorkspaceDetailRoutePage', () => { it('active tab URL에서 workspaceId를 읽어 detail page를 렌더링해야 함', () => { - mockUseActiveTabUrl.mockReturnValue('/system/workspaces/ws-1'); + mockUseActiveTabUrl.mockReturnValue('/console/workspaces/ws-1'); render(); @@ -39,11 +39,11 @@ describe('WorkspaceDetailRoutePage', () => { }); it('breadcrumb back은 목록 URL replace로 돌아가야 함', () => { - mockUseActiveTabUrl.mockReturnValue('/system/workspaces/ws-1'); + mockUseActiveTabUrl.mockReturnValue('/console/workspaces/ws-1'); render(); screen.getByTestId('workspace-detail').click(); - expect(mockUpdateActiveTabUrl).toHaveBeenCalledWith('/system/workspaces/', 'replace'); + expect(mockUpdateActiveTabUrl).toHaveBeenCalledWith('/console/workspaces/', 'replace'); }); }); diff --git a/frontend/app/src/pages/system/workspaces/workspace-detail.page.tsx b/frontend/app/src/pages/system/workspaces/workspace-detail.page.tsx index 2c0558ff3..e6b00ba2c 100644 --- a/frontend/app/src/pages/system/workspaces/workspace-detail.page.tsx +++ b/frontend/app/src/pages/system/workspaces/workspace-detail.page.tsx @@ -8,7 +8,7 @@ export function WorkspaceDetailRoutePage() { if ( descriptor.kind !== 'workspace-detail' || - descriptor.canonicalPath !== '/system/workspaces/' || + descriptor.canonicalPath !== '/console/workspaces/' || !descriptor.workspaceId ) { return null; @@ -17,7 +17,7 @@ export function WorkspaceDetailRoutePage() { return ( updateActiveTabUrl('/system/workspaces/', 'replace')} + onBack={() => updateActiveTabUrl('/console/workspaces/', 'replace')} /> ); } diff --git a/frontend/app/src/pages/system/workspaces/workspaces.page.test.tsx b/frontend/app/src/pages/system/workspaces/workspaces.page.test.tsx index 8fc7f90f7..da6972469 100644 --- a/frontend/app/src/pages/system/workspaces/workspaces.page.test.tsx +++ b/frontend/app/src/pages/system/workspaces/workspaces.page.test.tsx @@ -104,9 +104,10 @@ const mockWorkspaces: Workspace[] = [ }, ], memberCount: 3, - managedType: 'SYSTEM_MANAGED', + managedType: 'PLATFORM_MANAGED', createdAt: '2026-03-14T00:00:00Z', updatedAt: '2026-03-14T00:00:00Z', + externalReference: { source: 'AIP', externalId: 'aip-org-1' }, }, { id: 'ws-2', @@ -120,7 +121,7 @@ const mockWorkspaces: Workspace[] = [ }, ], memberCount: 5, - managedType: 'SYSTEM_MANAGED', + managedType: 'PLATFORM_MANAGED', createdAt: '2026-03-14T00:00:00Z', updatedAt: '2026-03-14T00:00:00Z', }, @@ -173,9 +174,9 @@ describe('WorkspacesPage', () => { expect.objectContaining({ selected: mockWorkspaces[1] }) ); expect(mockGrid.props?.rowId).toBe('id'); - expect(mockGrid.props?.selectedRowIds).toEqual(['ws-1', 'ws-2']); + expect(mockGrid.props?.selectedRowIds).toEqual(['ws-2']); expect(screen.getByTestId('workspaces-page-actions').getAttribute('data-selected-ids')).toBe( - 'ws-1,ws-2' + 'ws-2' ); }); }); @@ -211,6 +212,6 @@ describe('WorkspacesPage', () => { mockGrid.props?.onRowClick?.(mockWorkspaces[0]!); }); - expect(mockUpdateActiveTabUrl).toHaveBeenCalledWith('/system/workspaces/ws-1'); + expect(mockUpdateActiveTabUrl).toHaveBeenCalledWith('/console/workspaces/ws-1'); }); }); diff --git a/frontend/app/src/pages/system/workspaces/workspaces.page.tsx b/frontend/app/src/pages/system/workspaces/workspaces.page.tsx index 3e9bc71c1..4ce30f0c0 100644 --- a/frontend/app/src/pages/system/workspaces/workspaces.page.tsx +++ b/frontend/app/src/pages/system/workspaces/workspaces.page.tsx @@ -83,12 +83,16 @@ export function WorkspacesPage() { selectedRowIds={selectedIds} {...gridProps} selectionMode="multiple" + tabulatorOptions={{ + selectableRowsCheck: (row) => !(row.getData() as Workspace).externalReference, + }} onQueryChange={setQuery} onSelectionChange={(selectedRows) => { - setSelected(selectedRows.at(-1) ?? null); - setSelectedIds(selectedRows.map((row) => row.id)); + const deletableRows = selectedRows.filter((row) => !row.externalReference); + setSelected(deletableRows.at(-1) ?? null); + setSelectedIds(deletableRows.map((row) => row.id)); }} - onRowClick={(ws) => updateActiveTabUrl(`/system/workspaces/${ws.id}`)} + onRowClick={(ws) => updateActiveTabUrl(`/console/workspaces/${ws.id}`)} /> {actions.confirmDialog} diff --git a/frontend/app/src/shared/auth-redirect.test.ts b/frontend/app/src/shared/auth-redirect.test.ts index 2f8b41a6d..530d826af 100644 --- a/frontend/app/src/shared/auth-redirect.test.ts +++ b/frontend/app/src/shared/auth-redirect.test.ts @@ -3,8 +3,8 @@ import { buildLoginUrl, buildPasswordChangeUrl, resolvePostAuthUrl } from './aut describe('auth redirect helpers', () => { it('보호 페이지 접근 시 login next URL을 생성해야 함', () => { - expect(buildLoginUrl('/system/users?page=2#roles')).toBe( - '/login?next=%2Fsystem%2Fusers%3Fpage%3D2%23roles' + expect(buildLoginUrl('/console/users?page=2#roles')).toBe( + '/login?next=%2Fconsole%2Fusers%3Fpage%3D2%23roles' ); }); @@ -17,16 +17,31 @@ describe('auth redirect helpers', () => { it('외부 URL은 복귀 대상으로 허용하지 않아야 함', () => { expect(buildLoginUrl('https://evil.example/steal')).toBe('/login'); expect(resolvePostAuthUrl(new URLSearchParams('next=https://evil.example/steal'))).toBe( - '/oauth2/continue' + '/console/dashboard' ); }); it('유효한 next는 로그인 또는 비밀번호 변경 후 복귀 대상으로 사용해야 함', () => { const params = new URLSearchParams('next=%2Fsystem%2Fusers%3Fpage%3D2%23roles'); - expect(resolvePostAuthUrl(params)).toBe('/system/users?page=2#roles'); + expect(resolvePostAuthUrl(params)).toBe('/console/users?page=2#roles'); expect(buildPasswordChangeUrl(resolvePostAuthUrl(params))).toBe( - '/auth/password-change?next=%2Fsystem%2Fusers%3Fpage%3D2%23roles' + '/auth/password-change?next=%2Fconsole%2Fusers%3Fpage%3D2%23roles' + ); + }); + + it('legacy dashboard 경로는 console dashboard로 정규화해야 함', () => { + const params = new URLSearchParams('next=%2Fdashboard'); + + expect(resolvePostAuthUrl(params)).toBe('/console/dashboard'); + }); + + it('legacy account setting leaf는 대응되는 platform settings leaf로 정규화해야 함', () => { + const params = new URLSearchParams('next=%2Faccount%2Fsetting%2Fauth%3Ftab%3Dsso'); + + expect(resolvePostAuthUrl(params)).toBe('/settings/platform/authentication?tab=sso'); + expect(buildLoginUrl('/account/setting/roles')).toBe( + '/login?next=%2Fsettings%2Fplatform%2Froles' ); }); }); diff --git a/frontend/app/src/shared/auth-redirect.ts b/frontend/app/src/shared/auth-redirect.ts index 0829ce2c8..1589d5718 100644 --- a/frontend/app/src/shared/auth-redirect.ts +++ b/frontend/app/src/shared/auth-redirect.ts @@ -1,13 +1,10 @@ import { getSearchParams } from '#app/shared/runtime'; +import { normalizeLegacyPath } from '#app/shared/router/legacy-path'; const LOGIN_URL = '/login'; const PASSWORD_CHANGE_URL = '/auth/password-change'; const AUTH_ROUTE_PREFIXES = [LOGIN_URL, '/auth/pending', PASSWORD_CHANGE_URL]; -const DEFAULT_POST_AUTH_URL = '/oauth2/continue'; - -function toPathnameSearchHash(url: URL): string { - return `${url.pathname}${url.search}${url.hash}`; -} +const DEFAULT_POST_AUTH_URL = '/console/dashboard'; function safeDecode(value: string): string { try { @@ -42,7 +39,7 @@ export function normalizeRedirectTarget(value: string | null | undefined): strin const url = new URL(decoded, window.location.origin); if (url.origin !== window.location.origin) return null; - const path = toPathnameSearchHash(url); + const path = normalizeLegacyPath(`${url.pathname}${url.search}${url.hash}`); return isAuthRoute(path) ? null : path; } catch { return null; diff --git a/frontend/app/src/shared/branding/branding.test.tsx b/frontend/app/src/shared/branding/branding.test.tsx index 2a1382140..b601fb30e 100644 --- a/frontend/app/src/shared/branding/branding.test.tsx +++ b/frontend/app/src/shared/branding/branding.test.tsx @@ -353,27 +353,27 @@ describe('Branding', () => { const { rerender } = render(); act(() => { - setBrandingUrls({ horizontalUrl: '/api/v1/system-settings/logo/HORIZONTAL' }); + setBrandingUrls({ horizontalUrl: '/api/v1/platform-settings/logo/HORIZONTAL' }); }); rerender(); const img = document.querySelector('img.brand-logo') as HTMLImageElement; - expect(img.src).toContain('/api/v1/system-settings/logo/HORIZONTAL'); + expect(img.src).toContain('/api/v1/platform-settings/logo/HORIZONTAL'); }); it('clearBrandingCache는 BrandLogo의 커스텀 horizontal URL을 다시 계산해야 함', () => { document.documentElement.classList.remove('dark'); - setBrandingUrls({ horizontalUrl: '/api/v1/system-settings/logo/HORIZONTAL?v=1' }); + setBrandingUrls({ horizontalUrl: '/api/v1/platform-settings/logo/HORIZONTAL?v=1' }); render(); const img = document.querySelector('img.brand-logo') as HTMLImageElement; - expect(img.src).toContain('/api/v1/system-settings/logo/HORIZONTAL?v=1'); + expect(img.src).toContain('/api/v1/platform-settings/logo/HORIZONTAL?v=1'); act(() => { clearBrandingCache(); }); - expect(img.src).toContain('/api/v1/system-settings/logo/HORIZONTAL?v=1'); + expect(img.src).toContain('/api/v1/platform-settings/logo/HORIZONTAL?v=1'); }); it('부모 effect에서 dark 클래스가 적용되어도 horizontal dark SVG로 동기화되어야 함', async () => { @@ -411,17 +411,17 @@ describe('Branding', () => { render(); act(() => { - setBrandingUrls({ horizontalUrl: '/api/v1/system-settings/logo/HORIZONTAL' }); + setBrandingUrls({ horizontalUrl: '/api/v1/platform-settings/logo/HORIZONTAL' }); }); expect(screen.getByTestId('url').textContent).toContain( - '/api/v1/system-settings/logo/HORIZONTAL' + '/api/v1/platform-settings/logo/HORIZONTAL' ); }); it('커스텀 URL 초기화 후 static 파일로 폴백해야 함', async () => { document.documentElement.classList.remove('dark'); - setBrandingUrls({ horizontalUrl: '/api/v1/system-settings/logo/HORIZONTAL' }); + setBrandingUrls({ horizontalUrl: '/api/v1/platform-settings/logo/HORIZONTAL' }); render(); act(() => { diff --git a/frontend/app/src/shared/globalization/policy-runtime.ts b/frontend/app/src/shared/globalization/policy-runtime.ts index a9909ccd0..65a93a64b 100644 --- a/frontend/app/src/shared/globalization/policy-runtime.ts +++ b/frontend/app/src/shared/globalization/policy-runtime.ts @@ -1,4 +1,4 @@ -import type { CountryPolicy, CurrencyPolicy } from '#app/entities/system-settings'; +import type { CountryPolicy, CurrencyPolicy } from '#app/entities/platform-settings'; import { normalizeCountryPolicyValue, normalizeCurrencyPolicyValue } from './policy-codecs'; import { STANDARD_CURRENCY_CODES } from './standard-codes'; diff --git a/frontend/app/src/shared/hooks/use-url-param-action.test.ts b/frontend/app/src/shared/hooks/use-url-param-action.test.ts index 7c305d75a..8b6559d8b 100644 --- a/frontend/app/src/shared/hooks/use-url-param-action.test.ts +++ b/frontend/app/src/shared/hooks/use-url-param-action.test.ts @@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'; const mocks = vi.hoisted(() => ({ getSearchParams: vi.fn(), - getPathname: vi.fn(() => '/system/users/'), + getPathname: vi.fn(() => '/console/users/'), replaceUrl: vi.fn(), })); @@ -35,7 +35,7 @@ describe('useUrlParamAction', () => { renderHook(() => useUrlParamAction('userId', action)); await act(async () => {}); - expect(mocks.replaceUrl).toHaveBeenCalledWith('/system/users/'); + expect(mocks.replaceUrl).toHaveBeenCalledWith('/console/users/'); }); it('다른 파라미터는 유지해야 함', async () => { @@ -46,7 +46,7 @@ describe('useUrlParamAction', () => { renderHook(() => useUrlParamAction('userId', action)); await act(async () => {}); - expect(mocks.replaceUrl).toHaveBeenCalledWith('/system/users/?tab=info'); + expect(mocks.replaceUrl).toHaveBeenCalledWith('/console/users/?tab=info'); }); it('파라미터가 없으면 액션을 실행하지 않아야 함', () => { @@ -68,6 +68,6 @@ describe('useUrlParamAction', () => { renderHook(() => useUrlParamAction('userId', action)); await act(async () => {}); - expect(mocks.replaceUrl).toHaveBeenCalledWith('/system/users/'); + expect(mocks.replaceUrl).toHaveBeenCalledWith('/console/users/'); }); }); diff --git a/frontend/app/src/shared/http-client/default-interceptors.test.ts b/frontend/app/src/shared/http-client/default-interceptors.test.ts index 877df7cd0..a16fbb682 100644 --- a/frontend/app/src/shared/http-client/default-interceptors.test.ts +++ b/frontend/app/src/shared/http-client/default-interceptors.test.ts @@ -152,7 +152,7 @@ describe('API Default Interceptors', () => { }); it('403 + PASSWORD_CHANGE_REQUIRED면 비밀번호 변경 페이지로 리다이렉트해야 함', async () => { - window.history.pushState({}, '', '/system/users?page=2#roles'); + window.history.pushState({}, '', '/console/users?page=2#roles'); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( @@ -173,7 +173,7 @@ describe('API Default Interceptors', () => { }); expect(navigate).toHaveBeenCalledWith( - '/auth/password-change?next=%2Fsystem%2Fusers%3Fpage%3D2%23roles', + '/auth/password-change?next=%2Fconsole%2Fusers%3Fpage%3D2%23roles', true ); }); diff --git a/frontend/app/src/shared/i18n/locales/en/account.json b/frontend/app/src/shared/i18n/locales/en/account.json index 11b3e80d3..28e72eebe 100644 --- a/frontend/app/src/shared/i18n/locales/en/account.json +++ b/frontend/app/src/shared/i18n/locales/en/account.json @@ -36,8 +36,10 @@ "workspace": { "useUserManaged": "Use user-managed", "useUserManagedDescription": "Allow workspaces that users create and manage themselves.", - "useSystemManaged": "Use system-managed", - "useSystemManagedDescription": "Allow workspaces that administrators create and assign.", + "usePlatformManaged": "Use platform-managed", + "usePlatformManagedDescription": "Allow workspaces that the platform owns and manages.", + "useExternalSync": "Use external sync", + "useExternalSyncDescription": "Automatically sync external workspaces and memberships from AIP organization claims.", "useSelector": "Use selector", "useSelectorDescription": "Show the workspace selector in the sidebar when a visible workspace exists.", "enabledCountryCodes": "Enabled country codes", @@ -201,7 +203,7 @@ "backToApp": "Back to App", "groups": { "account": "Account", - "system": "System" + "platform": "Platform" }, "leaves": { "profile": { @@ -227,17 +229,12 @@ "general": { "label": "General", "title": "General", - "subtitle": "Manage global system defaults" + "subtitle": "Manage platform defaults" }, - "workspace": { - "label": "Workspace", - "title": "Workspace", - "subtitle": "Manage workspace policy and availability" - }, - "globalization": { - "label": "Globalization", - "title": "Globalization", - "subtitle": "Manage country and currency defaults for backoffice forms" + "workspacePolicy": { + "label": "Workspace Policy", + "title": "Workspace Policy", + "subtitle": "Manage workspace availability and sync defaults" }, "branding": { "label": "Branding", @@ -253,6 +250,11 @@ "label": "Roles", "title": "Roles", "subtitle": "Manage role definitions and ordering" + }, + "menus": { + "label": "Menus", + "title": "Menus", + "subtitle": "Manage platform navigation and program bindings" } } }, diff --git a/frontend/app/src/shared/i18n/locales/en/common.json b/frontend/app/src/shared/i18n/locales/en/common.json index 48a1ae374..7e2c0f2bc 100644 --- a/frontend/app/src/shared/i18n/locales/en/common.json +++ b/frontend/app/src/shared/i18n/locales/en/common.json @@ -291,6 +291,9 @@ "menu": { "menuInfo": "Menu Info", "program": "Program", + "managementType": "Management Type", + "userManaged": "User-managed", + "platformManaged": "Platform-managed", "name": "Name", "icon": "Icon", "permissions": "Permissions", diff --git a/frontend/app/src/shared/i18n/locales/en/dashboard.json b/frontend/app/src/shared/i18n/locales/en/dashboard.json index 5d9dfee0f..7af972b7f 100644 --- a/frontend/app/src/shared/i18n/locales/en/dashboard.json +++ b/frontend/app/src/shared/i18n/locales/en/dashboard.json @@ -22,7 +22,7 @@ "ownerWidgets": { "activeUsers": "Active Users", "pendingInvites": "Pending Invites", - "systemStatus": "System Status", + "platformStatus": "Platform Status", "email": "Email", "slack": "Slack", "activeChannels": "{{count}} active channel(s)", diff --git a/frontend/app/src/shared/i18n/locales/en/system.json b/frontend/app/src/shared/i18n/locales/en/system.json index dc70736ec..3ec310254 100644 --- a/frontend/app/src/shared/i18n/locales/en/system.json +++ b/frontend/app/src/shared/i18n/locales/en/system.json @@ -122,7 +122,7 @@ "roleLabel": "Role", "ownerLabel": "Owner", "grantOwnerPrivileges": "Grant owner privileges", - "ownerDescription": "Owners can access system settings and manage other owners.", + "ownerDescription": "Owners can access platform settings and manage other owners.", "contactFieldConfigUnavailable": "Contact field configuration is unavailable." }, "picker": { @@ -247,14 +247,14 @@ "SESSION_REVOKED": "Session revoked", "SESSION_REVOKED_ALL": "All sessions revoked", "SESSION_REVOKED_ALL_EXCEPT_CURRENT": "All sessions except current revoked", - "SYSTEM_SETTINGS_AUTH_UPDATED": "System auth settings updated", - "SYSTEM_SETTINGS_COUNTRY_POLICY_UPDATED": "System country policy updated", - "SYSTEM_SETTINGS_CURRENCY_POLICY_UPDATED": "System currency policy updated", - "SYSTEM_SETTINGS_GENERAL_UPDATED": "System general settings updated", - "SYSTEM_SETTINGS_GLOBALIZATION_POLICY_UPDATED": "System globalization policy updated", - "SYSTEM_SETTINGS_LOGO_UPLOADED": "System logo uploaded", - "SYSTEM_SETTINGS_LOGO_URL_UPDATED": "System logo URL updated", - "SYSTEM_SETTINGS_WORKSPACE_POLICY_UPDATED": "System workspace policy updated", + "PLATFORM_SETTINGS_AUTH_UPDATED": "Platform auth settings updated", + "PLATFORM_SETTINGS_COUNTRY_POLICY_UPDATED": "Platform country policy updated", + "PLATFORM_SETTINGS_CURRENCY_POLICY_UPDATED": "Platform currency policy updated", + "PLATFORM_SETTINGS_GENERAL_UPDATED": "Platform general settings updated", + "PLATFORM_SETTINGS_GLOBALIZATION_POLICY_UPDATED": "Platform globalization policy updated", + "PLATFORM_SETTINGS_LOGO_UPLOADED": "Platform logo uploaded", + "PLATFORM_SETTINGS_LOGO_URL_UPDATED": "Platform logo URL updated", + "PLATFORM_SETTINGS_WORKSPACE_POLICY_UPDATED": "Platform workspace policy updated", "USER_APPROVED": "User approved", "USER_CREATED": "User created", "USER_DELETED": "User deleted", @@ -290,13 +290,14 @@ }, "actorTypes": { "USER": "User", - "SYSTEM": "System" + "SYSTEM": "Platform" }, "targetTypes": { "SESSION": "Session", "USER": "User", "WORKSPACE": "Workspace", - "WORKSPACE_INVITE": "Workspace invite" + "WORKSPACE_INVITE": "Workspace invite", + "PLATFORM_SETTING": "Platform setting" } }, "errorLogs": { @@ -499,10 +500,10 @@ "namePlaceholder": "Workspace name", "descriptionLabel": "Description", "descriptionPlaceholder": "Description (optional)", - "allowedDomainsLabel": "Allowed domains", - "allowedDomainsPlaceholder": "acme.com\nsubsidiary.co.kr", - "allowedDomainsDescription": "Enter multiple domains separated by commas or new lines.", - "allowedDomainsInvalid": "Invalid domain format: {{domains}}" + "autoJoinDomainsLabel": "Auto-join domains", + "autoJoinDomainsPlaceholder": "acme.com\nsubsidiary.co.kr", + "autoJoinDomainsDescription": "Enter multiple domains separated by commas or new lines.", + "autoJoinDomainsInvalid": "Invalid domain format: {{domains}}" }, "messages": { "deleted": "Deleted", @@ -519,6 +520,7 @@ "ownerGranted": "Owner granted", "ownerRevoked": "Owner revoked", "ownerUpdated": "Owners updated", + "externalLocked": "External workspaces are synced from an external source and cannot be modified in Deck.", "selectItemsToDelete": "Select items to delete", "ownerCannotLeave": "Owner cannot leave the workspace", "leftWorkspace": "Left workspace", diff --git a/frontend/app/src/shared/i18n/locales/ja/account.json b/frontend/app/src/shared/i18n/locales/ja/account.json index d68da5cc1..18c59d05e 100644 --- a/frontend/app/src/shared/i18n/locales/ja/account.json +++ b/frontend/app/src/shared/i18n/locales/ja/account.json @@ -36,8 +36,10 @@ "workspace": { "useUserManaged": "ユーザー管理型を使用", "useUserManagedDescription": "ユーザーが自分で作成・管理する workspace を使用できます。", - "useSystemManaged": "システム管理型を使用", - "useSystemManagedDescription": "管理者が作成して割り当てる workspace を使用できます。", + "usePlatformManaged": "プラットフォーム管理型を使用", + "usePlatformManagedDescription": "プラットフォームが所有・管理する workspace を使用できます。", + "useExternalSync": "外部同期を使用", + "useExternalSyncDescription": "AIP の organization claim から external workspace と membership を自動同期します。", "useSelector": "選択 UI を使用", "useSelectorDescription": "表示可能な workspace がある場合にサイドバーへ selector を表示します。", "enabledCountryCodes": "許可する国コード", @@ -201,7 +203,7 @@ "backToApp": "アプリに戻る", "groups": { "account": "アカウント", - "system": "システム" + "platform": "プラットフォーム" }, "leaves": { "profile": { @@ -227,17 +229,12 @@ "general": { "label": "一般", "title": "一般", - "subtitle": "システムの既定値を管理します" + "subtitle": "プラットフォームの既定値を管理します" }, - "workspace": { - "label": "ワークスペース", - "title": "ワークスペース", - "subtitle": "ワークスペースのポリシーと可用性を管理します" - }, - "globalization": { - "label": "グローバル", - "title": "グローバル", - "subtitle": "バックオフィスの国と通貨の既定値を管理します" + "workspacePolicy": { + "label": "ワークスペースポリシー", + "title": "ワークスペースポリシー", + "subtitle": "ワークスペースの可用性と同期の既定値を管理します" }, "branding": { "label": "ブランディング", @@ -253,6 +250,11 @@ "label": "ロール", "title": "ロール", "subtitle": "ロール定義と表示順序を管理します" + }, + "menus": { + "label": "メニュー", + "title": "メニュー", + "subtitle": "プラットフォームのナビゲーションとプログラム連携を管理します" } } }, diff --git a/frontend/app/src/shared/i18n/locales/ja/common.json b/frontend/app/src/shared/i18n/locales/ja/common.json index 4a5165c11..94fcfe04b 100644 --- a/frontend/app/src/shared/i18n/locales/ja/common.json +++ b/frontend/app/src/shared/i18n/locales/ja/common.json @@ -291,6 +291,9 @@ "menu": { "menuInfo": "メニュー情報", "program": "プログラム", + "managementType": "管理タイプ", + "userManaged": "ユーザー管理型", + "platformManaged": "プラットフォーム管理型", "name": "名前", "icon": "アイコン", "permissions": "権限", diff --git a/frontend/app/src/shared/i18n/locales/ja/dashboard.json b/frontend/app/src/shared/i18n/locales/ja/dashboard.json index 918d897f6..91529ad82 100644 --- a/frontend/app/src/shared/i18n/locales/ja/dashboard.json +++ b/frontend/app/src/shared/i18n/locales/ja/dashboard.json @@ -22,7 +22,7 @@ "ownerWidgets": { "activeUsers": "アクティブユーザー", "pendingInvites": "保留中の招待", - "systemStatus": "システム状態", + "platformStatus": "プラットフォーム状態", "email": "メール", "slack": "Slack", "activeChannels": "有効なチャンネル {{count}}件", diff --git a/frontend/app/src/shared/i18n/locales/ja/system.json b/frontend/app/src/shared/i18n/locales/ja/system.json index 038eba351..b128ff880 100644 --- a/frontend/app/src/shared/i18n/locales/ja/system.json +++ b/frontend/app/src/shared/i18n/locales/ja/system.json @@ -122,7 +122,7 @@ "roleLabel": "ロール", "ownerLabel": "オーナー", "grantOwnerPrivileges": "オーナー権限を付与", - "ownerDescription": "オーナーはシステム設定にアクセスし、他のオーナーを管理できます。", + "ownerDescription": "オーナーはプラットフォーム設定にアクセスし、他のオーナーを管理できます。", "contactFieldConfigUnavailable": "連絡先フィールド設定を読み込めません。" }, "picker": { @@ -247,14 +247,14 @@ "SESSION_REVOKED": "セッション無効化", "SESSION_REVOKED_ALL": "全セッション無効化", "SESSION_REVOKED_ALL_EXCEPT_CURRENT": "現在セッション以外無効化", - "SYSTEM_SETTINGS_AUTH_UPDATED": "認証設定更新", - "SYSTEM_SETTINGS_COUNTRY_POLICY_UPDATED": "国ポリシー更新", - "SYSTEM_SETTINGS_CURRENCY_POLICY_UPDATED": "通貨ポリシー更新", - "SYSTEM_SETTINGS_GENERAL_UPDATED": "一般設定更新", - "SYSTEM_SETTINGS_GLOBALIZATION_POLICY_UPDATED": "国際化ポリシー更新", - "SYSTEM_SETTINGS_LOGO_UPLOADED": "ロゴアップロード", - "SYSTEM_SETTINGS_LOGO_URL_UPDATED": "ロゴURL更新", - "SYSTEM_SETTINGS_WORKSPACE_POLICY_UPDATED": "ワークスペースポリシー更新", + "PLATFORM_SETTINGS_AUTH_UPDATED": "プラットフォーム認証設定更新", + "PLATFORM_SETTINGS_COUNTRY_POLICY_UPDATED": "プラットフォーム国ポリシー更新", + "PLATFORM_SETTINGS_CURRENCY_POLICY_UPDATED": "プラットフォーム通貨ポリシー更新", + "PLATFORM_SETTINGS_GENERAL_UPDATED": "プラットフォーム一般設定更新", + "PLATFORM_SETTINGS_GLOBALIZATION_POLICY_UPDATED": "プラットフォーム国際化ポリシー更新", + "PLATFORM_SETTINGS_LOGO_UPLOADED": "プラットフォームロゴアップロード", + "PLATFORM_SETTINGS_LOGO_URL_UPDATED": "プラットフォームロゴURL更新", + "PLATFORM_SETTINGS_WORKSPACE_POLICY_UPDATED": "プラットフォームワークスペースポリシー更新", "USER_APPROVED": "ユーザー承認", "USER_CREATED": "ユーザー作成", "USER_DELETED": "ユーザー削除", @@ -290,13 +290,14 @@ }, "actorTypes": { "USER": "ユーザー", - "SYSTEM": "システム" + "SYSTEM": "プラットフォーム" }, "targetTypes": { "SESSION": "セッション", "USER": "ユーザー", "WORKSPACE": "ワークスペース", - "WORKSPACE_INVITE": "ワークスペース招待" + "WORKSPACE_INVITE": "ワークスペース招待", + "PLATFORM_SETTING": "プラットフォーム設定" } }, "errorLogs": { @@ -499,10 +500,10 @@ "namePlaceholder": "ワークスペース名", "descriptionLabel": "説明", "descriptionPlaceholder": "説明 (任意)", - "allowedDomainsLabel": "許可ドメイン", - "allowedDomainsPlaceholder": "acme.com\nsubsidiary.co.kr", - "allowedDomainsDescription": "複数のドメインをカンマまたは改行で入力できます。", - "allowedDomainsInvalid": "無効なドメイン形式です: {{domains}}" + "autoJoinDomainsLabel": "自動参加ドメイン", + "autoJoinDomainsPlaceholder": "acme.com\nsubsidiary.co.kr", + "autoJoinDomainsDescription": "複数のドメインをカンマまたは改行で入力できます。", + "autoJoinDomainsInvalid": "無効なドメイン形式です: {{domains}}" }, "messages": { "deleted": "削除しました", @@ -519,6 +520,7 @@ "ownerGranted": "オーナー権限を付与しました", "ownerRevoked": "オーナー権限を解除しました", "ownerUpdated": "オーナー一覧を更新しました", + "externalLocked": "外部ワークスペースは外部ソースと同期されるため、Deck では変更できません。", "selectItemsToDelete": "削除する項目を選択してください", "ownerCannotLeave": "オーナーはワークスペースを脱退できません", "leftWorkspace": "ワークスペースを脱退しました", diff --git a/frontend/app/src/shared/i18n/locales/ko/account.json b/frontend/app/src/shared/i18n/locales/ko/account.json index dbda6f6c7..9100abf20 100644 --- a/frontend/app/src/shared/i18n/locales/ko/account.json +++ b/frontend/app/src/shared/i18n/locales/ko/account.json @@ -36,8 +36,10 @@ "workspace": { "useUserManaged": "사용자 관리형 사용", "useUserManagedDescription": "사용자가 직접 생성하고 관리하는 workspace를 허용합니다.", - "useSystemManaged": "시스템 관리형 사용", - "useSystemManagedDescription": "관리자가 생성하고 배정하는 workspace를 허용합니다.", + "usePlatformManaged": "플랫폼 관리형 사용", + "usePlatformManagedDescription": "플랫폼이 소유하고 관리하는 workspace를 허용합니다.", + "useExternalSync": "외부 동기화 사용", + "useExternalSyncDescription": "AIP 조직 claim을 받아 external workspace와 membership을 자동 동기화합니다.", "useSelector": "선택 UI 사용", "useSelectorDescription": "보이는 workspace가 있을 때 사이드바에 workspace 선택 UI를 표시합니다.", "enabledCountryCodes": "허용 국가 코드", @@ -201,7 +203,7 @@ "backToApp": "앱으로 돌아가기", "groups": { "account": "계정", - "system": "시스템" + "platform": "플랫폼" }, "leaves": { "profile": { @@ -227,17 +229,12 @@ "general": { "label": "일반", "title": "일반", - "subtitle": "시스템 기본값을 관리합니다" + "subtitle": "플랫폼 기본값을 관리합니다" }, - "workspace": { - "label": "워크스페이스", - "title": "워크스페이스", - "subtitle": "워크스페이스 정책과 가용성을 관리합니다" - }, - "globalization": { - "label": "글로벌", - "title": "글로벌", - "subtitle": "백오피스 폼의 국가 및 통화 기본값을 관리합니다" + "workspacePolicy": { + "label": "워크스페이스 정책", + "title": "워크스페이스 정책", + "subtitle": "워크스페이스 가용성과 동기화 기본값을 관리합니다" }, "branding": { "label": "브랜딩", @@ -253,6 +250,11 @@ "label": "역할", "title": "역할", "subtitle": "역할 정의와 정렬 순서를 관리합니다" + }, + "menus": { + "label": "메뉴", + "title": "메뉴", + "subtitle": "플랫폼 내비게이션과 프로그램 연결을 관리합니다" } } }, diff --git a/frontend/app/src/shared/i18n/locales/ko/common.json b/frontend/app/src/shared/i18n/locales/ko/common.json index e0b56b729..a5cf1c134 100644 --- a/frontend/app/src/shared/i18n/locales/ko/common.json +++ b/frontend/app/src/shared/i18n/locales/ko/common.json @@ -291,6 +291,9 @@ "menu": { "menuInfo": "메뉴 정보", "program": "프로그램", + "managementType": "관리 유형", + "userManaged": "사용자 관리형", + "platformManaged": "플랫폼 관리형", "name": "이름", "icon": "아이콘", "permissions": "권한", diff --git a/frontend/app/src/shared/i18n/locales/ko/dashboard.json b/frontend/app/src/shared/i18n/locales/ko/dashboard.json index 205bcf993..2334dc33a 100644 --- a/frontend/app/src/shared/i18n/locales/ko/dashboard.json +++ b/frontend/app/src/shared/i18n/locales/ko/dashboard.json @@ -22,7 +22,7 @@ "ownerWidgets": { "activeUsers": "활성 사용자", "pendingInvites": "대기 중인 초대", - "systemStatus": "시스템 상태", + "platformStatus": "플랫폼 상태", "email": "이메일", "slack": "슬랙", "activeChannels": "활성 채널 {{count}}개", diff --git a/frontend/app/src/shared/i18n/locales/ko/system.json b/frontend/app/src/shared/i18n/locales/ko/system.json index 7e1439909..a80e1149c 100644 --- a/frontend/app/src/shared/i18n/locales/ko/system.json +++ b/frontend/app/src/shared/i18n/locales/ko/system.json @@ -122,7 +122,7 @@ "roleLabel": "역할", "ownerLabel": "오너", "grantOwnerPrivileges": "오너 권한 부여", - "ownerDescription": "오너는 시스템 설정에 접근하고 다른 오너를 관리할 수 있습니다.", + "ownerDescription": "오너는 플랫폼 설정에 접근하고 다른 오너를 관리할 수 있습니다.", "contactFieldConfigUnavailable": "연락처 필드 설정을 불러올 수 없습니다." }, "picker": { @@ -247,14 +247,14 @@ "SESSION_REVOKED": "세션 해제", "SESSION_REVOKED_ALL": "전체 세션 해제", "SESSION_REVOKED_ALL_EXCEPT_CURRENT": "현재 세션 제외 전체 해제", - "SYSTEM_SETTINGS_AUTH_UPDATED": "인증 설정 수정", - "SYSTEM_SETTINGS_COUNTRY_POLICY_UPDATED": "국가 정책 수정", - "SYSTEM_SETTINGS_CURRENCY_POLICY_UPDATED": "통화 정책 수정", - "SYSTEM_SETTINGS_GENERAL_UPDATED": "일반 설정 수정", - "SYSTEM_SETTINGS_GLOBALIZATION_POLICY_UPDATED": "국제화 정책 수정", - "SYSTEM_SETTINGS_LOGO_UPLOADED": "로고 업로드", - "SYSTEM_SETTINGS_LOGO_URL_UPDATED": "로고 URL 수정", - "SYSTEM_SETTINGS_WORKSPACE_POLICY_UPDATED": "워크스페이스 정책 수정", + "PLATFORM_SETTINGS_AUTH_UPDATED": "플랫폼 인증 설정 수정", + "PLATFORM_SETTINGS_COUNTRY_POLICY_UPDATED": "플랫폼 국가 정책 수정", + "PLATFORM_SETTINGS_CURRENCY_POLICY_UPDATED": "플랫폼 통화 정책 수정", + "PLATFORM_SETTINGS_GENERAL_UPDATED": "플랫폼 일반 설정 수정", + "PLATFORM_SETTINGS_GLOBALIZATION_POLICY_UPDATED": "플랫폼 국제화 정책 수정", + "PLATFORM_SETTINGS_LOGO_UPLOADED": "플랫폼 로고 업로드", + "PLATFORM_SETTINGS_LOGO_URL_UPDATED": "플랫폼 로고 URL 수정", + "PLATFORM_SETTINGS_WORKSPACE_POLICY_UPDATED": "플랫폼 워크스페이스 정책 수정", "USER_APPROVED": "사용자 승인", "USER_CREATED": "사용자 생성", "USER_DELETED": "사용자 삭제", @@ -290,13 +290,14 @@ }, "actorTypes": { "USER": "사용자", - "SYSTEM": "시스템" + "SYSTEM": "플랫폼" }, "targetTypes": { "SESSION": "세션", "USER": "사용자", "WORKSPACE": "워크스페이스", - "WORKSPACE_INVITE": "워크스페이스 초대" + "WORKSPACE_INVITE": "워크스페이스 초대", + "PLATFORM_SETTING": "플랫폼 설정" } }, "errorLogs": { @@ -499,10 +500,10 @@ "namePlaceholder": "워크스페이스 이름", "descriptionLabel": "설명", "descriptionPlaceholder": "설명 (선택)", - "allowedDomainsLabel": "허용 도메인", - "allowedDomainsPlaceholder": "acme.com\nsubsidiary.co.kr", - "allowedDomainsDescription": "쉼표 또는 줄바꿈으로 여러 도메인을 입력할 수 있습니다.", - "allowedDomainsInvalid": "유효하지 않은 도메인 형식입니다: {{domains}}" + "autoJoinDomainsLabel": "자동 가입 도메인", + "autoJoinDomainsPlaceholder": "acme.com\nsubsidiary.co.kr", + "autoJoinDomainsDescription": "쉼표 또는 줄바꿈으로 여러 도메인을 입력할 수 있습니다.", + "autoJoinDomainsInvalid": "유효하지 않은 도메인 형식입니다: {{domains}}" }, "messages": { "deleted": "삭제됨", @@ -519,6 +520,7 @@ "ownerGranted": "오너 권한을 부여했습니다", "ownerRevoked": "오너 권한을 해제했습니다", "ownerUpdated": "오너 목록을 업데이트했습니다", + "externalLocked": "외부 워크스페이스는 외부 원본과 동기화되므로 Deck에서 수정할 수 없습니다.", "selectItemsToDelete": "삭제할 항목을 선택하세요", "ownerCannotLeave": "오너는 워크스페이스를 탈퇴할 수 없습니다", "leftWorkspace": "워크스페이스를 탈퇴했습니다", diff --git a/frontend/app/src/shared/router/console-path.test.ts b/frontend/app/src/shared/router/console-path.test.ts new file mode 100644 index 000000000..d9cc2c5ac --- /dev/null +++ b/frontend/app/src/shared/router/console-path.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { toConsolePath } from './console-path'; + +describe('toConsolePath', () => { + it('bare service path를 console canonical path로 변환해야 한다', () => { + expect(toConsolePath('/contacts/')).toBe('/console/contacts/'); + expect(toConsolePath('/booking/profile')).toBe('/console/booking/profile/'); + }); + + it('query와 hash를 보존해야 한다', () => { + expect(toConsolePath('/booking/settings/?tab=profile#intro')).toBe( + '/console/booking/settings/?tab=profile#intro' + ); + }); + + it('이미 canonical shell path면 그대로 유지해야 한다', () => { + expect(toConsolePath('/console/companies/')).toBe('/console/companies/'); + expect(toConsolePath('/settings/platform/menus')).toBe('/settings/platform/menus/'); + }); + + it('service prefix가 다시 포함된 console path는 거부해야 한다', () => { + expect(() => toConsolePath('/console/deskpie/companies/')).toThrow( + 'Console path must not include service prefix: deskpie' + ); + expect(() => toConsolePath('/meetpie/contacts/')).toThrow( + 'Console path must not include service prefix: meetpie' + ); + }); +}); diff --git a/frontend/app/src/shared/router/console-path.ts b/frontend/app/src/shared/router/console-path.ts new file mode 100644 index 000000000..2d8628e77 --- /dev/null +++ b/frontend/app/src/shared/router/console-path.ts @@ -0,0 +1,46 @@ +const URL_PARSE_BASE = 'http://localhost'; +const DISALLOWED_SERVICE_PREFIXES = new Set(['deskpie', 'meetpie']); + +function toPathnameSearchHash(url: URL): string { + return `${url.pathname}${url.search}${url.hash}`; +} + +function normalizePathname(pathname: string): string { + if (pathname === '/') return pathname; + return pathname.endsWith('/') ? pathname : `${pathname}/`; +} + +function assertCanonicalConsolePath(pathname: string) { + if (!pathname.startsWith('/console/')) { + return; + } + + const [firstSegment] = pathname + .slice('/console/'.length) + .split('/') + .filter((segment) => segment.length > 0); + + if (firstSegment && DISALLOWED_SERVICE_PREFIXES.has(firstSegment)) { + throw new Error(`Console path must not include service prefix: ${firstSegment}`); + } +} + +export function toConsolePath(path: string): string { + const url = new URL(path, URL_PARSE_BASE); + + if (url.pathname.startsWith('/settings/')) { + url.pathname = normalizePathname(url.pathname); + return toPathnameSearchHash(url); + } + + if (url.pathname === '/console' || url.pathname.startsWith('/console/')) { + url.pathname = normalizePathname(url.pathname); + assertCanonicalConsolePath(url.pathname); + return toPathnameSearchHash(url); + } + + const normalizedPathname = normalizePathname(url.pathname); + url.pathname = normalizedPathname === '/' ? '/console/' : `/console${normalizedPathname}`; + assertCanonicalConsolePath(url.pathname); + return toPathnameSearchHash(url); +} diff --git a/frontend/app/src/shared/router/index.ts b/frontend/app/src/shared/router/index.ts index 299ae6549..cceac67d6 100644 --- a/frontend/app/src/shared/router/index.ts +++ b/frontend/app/src/shared/router/index.ts @@ -2,7 +2,12 @@ import { createStore } from '#app/shared/store/create-store'; import { useEffect, useMemo } from 'react'; export type { RouteDescriptor } from './route-descriptor'; -export { resolveRouteDescriptor } from './route-descriptor'; +export { toConsolePath } from './console-path'; +export { + resolveMenuLookupPath, + resolveRouteDescriptor, + resolveTabMatchPath, +} from './route-descriptor'; type RoutePattern = string | { path: string; label: string }; diff --git a/frontend/app/src/shared/router/legacy-path.test.ts b/frontend/app/src/shared/router/legacy-path.test.ts new file mode 100644 index 000000000..1352aa7eb --- /dev/null +++ b/frontend/app/src/shared/router/legacy-path.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeLegacyPath } from './legacy-path'; + +describe('legacy-path', () => { + it('legacy console menus 경로는 query/hash를 유지한 채 settings platform menus로 정규화해야 함', () => { + expect(normalizeLegacyPath('/console/menus?from=legacy#section')).toBe( + '/settings/platform/menus?from=legacy#section' + ); + }); + + it('legacy console audit logs 경로는 query/hash를 유지한 채 api audit logs로 정규화해야 함', () => { + expect(normalizeLegacyPath('/console/audit-logs?tab=detail#aud-1')).toBe( + '/console/api-audit-logs?tab=detail#aud-1' + ); + }); + + it('legacy my-workspaces 경로는 query/hash를 유지한 채 console my-workspaces로 정규화해야 함', () => { + expect(normalizeLegacyPath('/my-workspaces?tab=members#invite')).toBe( + '/console/my-workspaces?tab=members#invite' + ); + }); + + it('legacy account setting workspace 경로는 platform workspace policy로 정규화해야 함', () => { + expect(normalizeLegacyPath('/account/setting/workspace')).toBe( + '/settings/platform/workspace-policy' + ); + }); + + it('settings root 경로는 account profile canonical leaf로 정규화해야 함', () => { + expect(normalizeLegacyPath('/settings')).toBe('/settings/account/profile'); + }); + + it('settings platform group 경로는 platform general canonical leaf로 정규화해야 함', () => { + expect(normalizeLegacyPath('/settings/platform')).toBe('/settings/platform/general'); + }); + + it('legacy settings system root 경로는 platform general canonical leaf로 정규화해야 함', () => { + expect(normalizeLegacyPath('/settings/system')).toBe('/settings/platform/general'); + }); +}); diff --git a/frontend/app/src/shared/router/legacy-path.ts b/frontend/app/src/shared/router/legacy-path.ts new file mode 100644 index 000000000..67fb9c202 --- /dev/null +++ b/frontend/app/src/shared/router/legacy-path.ts @@ -0,0 +1,104 @@ +const URL_PARSE_BASE = 'http://localhost'; + +function toPathnameSearchHash(url: URL): string { + return `${url.pathname}${url.search}${url.hash}`; +} + +function normalizePathname(pathname: string): string { + return pathname === '/' ? pathname : pathname.replace(/\/$/, ''); +} + +function resolveLegacyAccountSettingPath(pathname: string): string { + const suffix = pathname.replace(/^\/account\/setting/, '') || ''; + + switch (suffix) { + case '': + case '/': + case '/general': + return '/settings/platform/general'; + case '/workspace': + case '/workspace-policy': + return '/settings/platform/workspace-policy'; + case '/branding': + return '/settings/platform/branding'; + case '/auth': + case '/authentication': + return '/settings/platform/authentication'; + case '/roles': + return '/settings/platform/roles'; + case '/menus': + return '/settings/platform/menus'; + case '/globalization': + return '/settings/platform/general'; + default: + return '/settings/platform/general'; + } +} + +function resolveSettingsGroupPath(pathname: string): string | null { + switch (pathname) { + case '/settings': + case '/settings/account': + return '/settings/account/profile'; + case '/settings/platform': + return '/settings/platform/general'; + default: + return null; + } +} + +export function normalizeLegacyPath(path: string): string { + const url = new URL(path, URL_PARSE_BASE); + const pathname = normalizePathname(url.pathname); + const canonicalSettingsPath = resolveSettingsGroupPath(pathname); + + if (canonicalSettingsPath) { + url.pathname = canonicalSettingsPath; + return toPathnameSearchHash(url); + } + + if (pathname === '/system' || pathname.startsWith('/system/')) { + url.pathname = pathname.replace(/^\/system/, '/console'); + return toPathnameSearchHash(url); + } + + if (pathname === '/settings/system' || pathname.startsWith('/settings/system/')) { + url.pathname = + pathname === '/settings/system' + ? '/settings/platform/general' + : pathname.replace(/^\/settings\/system/, '/settings/platform'); + return toPathnameSearchHash(url); + } + + if (pathname === '/my-workspaces' || pathname.startsWith('/my-workspaces/')) { + url.pathname = pathname.replace(/^\/my-workspaces/, '/console/my-workspaces'); + return toPathnameSearchHash(url); + } + + if (pathname === '/dashboard' || pathname.startsWith('/dashboard/')) { + url.pathname = pathname.replace(/^\/dashboard/, '/console/dashboard'); + return toPathnameSearchHash(url); + } + + if (pathname === '/console/menus' || pathname.startsWith('/console/menus/')) { + url.pathname = '/settings/platform/menus'; + return toPathnameSearchHash(url); + } + + if (pathname === '/console/audit-logs' || pathname.startsWith('/console/audit-logs/')) { + url.pathname = pathname.replace(/^\/console\/audit-logs/, '/console/api-audit-logs'); + return toPathnameSearchHash(url); + } + + if (pathname === '/account/profile' || pathname.startsWith('/account/profile/')) { + url.pathname = pathname.replace(/^\/account\/profile/, '/settings/account/profile'); + return toPathnameSearchHash(url); + } + + if (pathname === '/account/setting' || pathname.startsWith('/account/setting/')) { + url.pathname = resolveLegacyAccountSettingPath(pathname); + return toPathnameSearchHash(url); + } + + return path; +} diff --git a/frontend/app/src/shared/router/route-descriptor.test.ts b/frontend/app/src/shared/router/route-descriptor.test.ts index 47bb13be3..93edd6f09 100644 --- a/frontend/app/src/shared/router/route-descriptor.test.ts +++ b/frontend/app/src/shared/router/route-descriptor.test.ts @@ -1,47 +1,83 @@ import { describe, expect, it } from 'vitest'; -import { resolveRouteDescriptor } from './route-descriptor'; +import { + resolveMenuLookupPath, + resolveRouteDescriptor, + resolveTabMatchPath, +} from './route-descriptor'; describe('route-descriptor', () => { - it('system workspace detail path를 canonical path로 해석해야 함', () => { - expect(resolveRouteDescriptor('/system/workspaces/ws-1')).toEqual({ - actualPath: '/system/workspaces/ws-1', - canonicalPath: '/system/workspaces/', + it('console workspace detail path를 canonical path로 해석해야 함', () => { + expect(resolveRouteDescriptor('/console/workspaces/ws-1')).toEqual({ + actualPath: '/console/workspaces/ws-1', + canonicalPath: '/console/workspaces/', kind: 'workspace-detail', workspaceId: 'ws-1', }); }); it('my workspace detail path에서 query를 보존해야 함', () => { - expect(resolveRouteDescriptor('/my-workspaces/ws-2?tab=members')).toEqual({ - actualPath: '/my-workspaces/ws-2?tab=members', - canonicalPath: '/my-workspaces/', + expect(resolveRouteDescriptor('/console/my-workspaces/ws-2?tab=members')).toEqual({ + actualPath: '/console/my-workspaces/ws-2?tab=members', + canonicalPath: '/console/my-workspaces/', kind: 'workspace-detail', workspaceId: 'ws-2', }); }); it('settings leaf path는 자기 자신의 path를 유지해야 함', () => { - expect(resolveRouteDescriptor('/settings/system/general?foo=bar#section')).toEqual({ - actualPath: '/settings/system/general/?foo=bar#section', - canonicalPath: '/settings/system/general/', + expect(resolveRouteDescriptor('/settings/platform/general?foo=bar#section')).toEqual({ + actualPath: '/settings/platform/general/?foo=bar#section', + canonicalPath: '/settings/platform/general', kind: 'regular', }); }); - it('일반 path는 자기 자신을 canonical path로 유지해야 함', () => { + it('legacy dashboard path는 console dashboard canonical path로 정규화해야 함', () => { expect(resolveRouteDescriptor('/dashboard')).toEqual({ - actualPath: '/dashboard/', - canonicalPath: '/dashboard/', + actualPath: '/console/dashboard/', + canonicalPath: '/console/dashboard/', + kind: 'regular', + }); + }); + + it('legacy system path는 console canonical path로 정규화해야 함', () => { + expect(resolveRouteDescriptor('/system/users?page=2#roles')).toEqual({ + actualPath: '/console/users/?page=2#roles', + canonicalPath: '/console/users/', kind: 'regular', }); }); it('workspace detail path에서도 hash를 보존해야 함', () => { - expect(resolveRouteDescriptor('/system/workspaces/ws-1?tab=security#members')).toEqual({ - actualPath: '/system/workspaces/ws-1?tab=security#members', - canonicalPath: '/system/workspaces/', + expect(resolveRouteDescriptor('/console/workspaces/ws-1?tab=security#members')).toEqual({ + actualPath: '/console/workspaces/ws-1?tab=security#members', + canonicalPath: '/console/workspaces/', kind: 'workspace-detail', workspaceId: 'ws-1', }); }); + + it('workspace_id query param은 global router가 해석하지 않고 그대로 보존해야 함', () => { + expect(resolveRouteDescriptor('/console/companies?workspace_id=ws-1')).toEqual({ + actualPath: '/console/companies/?workspace_id=ws-1', + canonicalPath: '/console/companies/', + kind: 'regular', + }); + }); + + it('settings leaf path는 menu lookup 시 trailing slash를 붙여 정규화해야 함', () => { + expect(resolveMenuLookupPath('/settings/platform/general?foo=bar')).toBe( + '/settings/platform/general/' + ); + }); + + it('legacy path와 canonical path는 같은 tab match path로 해석해야 함', () => { + expect(resolveTabMatchPath('/dashboard')).toBe('/console/dashboard/'); + expect(resolveTabMatchPath('/console/dashboard/')).toBe('/console/dashboard/'); + }); + + it('workspace detail path는 tab match에서 실제 detail path를 유지해야 함', () => { + expect(resolveTabMatchPath('/console/workspaces/ws-1')).toBe('/console/workspaces/ws-1/'); + expect(resolveTabMatchPath('/console/workspaces/ws-2')).toBe('/console/workspaces/ws-2/'); + }); }); diff --git a/frontend/app/src/shared/router/route-descriptor.ts b/frontend/app/src/shared/router/route-descriptor.ts index 5a96faa91..cc011eb03 100644 --- a/frontend/app/src/shared/router/route-descriptor.ts +++ b/frontend/app/src/shared/router/route-descriptor.ts @@ -1,6 +1,22 @@ +import { normalizeLegacyPath } from './legacy-path'; + const URL_PARSE_BASE = 'http://localhost'; -const SYSTEM_WORKSPACE_DETAIL_PATTERN = /^\/system\/workspaces\/([^/]+)\/?$/; -const MY_WORKSPACE_DETAIL_PATTERN = /^\/my-workspaces\/([^/]+)\/?$/; + +interface DetailRouteRule { + canonicalPath: string; + pattern: RegExp; +} + +const DETAIL_ROUTE_RULES: DetailRouteRule[] = [ + { + canonicalPath: '/console/workspaces/', + pattern: /^\/console\/workspaces\/([^/]+)\/?$/, + }, + { + canonicalPath: '/console/my-workspaces/', + pattern: /^\/console\/my-workspaces\/([^/]+)\/?$/, + }, +]; export interface RouteDescriptor { actualPath: string; @@ -16,34 +32,53 @@ function normalizePathname(pathname: string): string { export function resolveRouteDescriptor(url: string): RouteDescriptor { const parsedUrl = new URL(url, URL_PARSE_BASE); + const normalizedLegacyUrl = new URL( + normalizeLegacyPath(`${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`), + URL_PARSE_BASE + ); + const normalizedActualPath = `${normalizePathname(normalizedLegacyUrl.pathname)}${normalizedLegacyUrl.search}${normalizedLegacyUrl.hash}`; const pathnameWithoutTrailingSlash = - parsedUrl.pathname === '/' ? parsedUrl.pathname : parsedUrl.pathname.replace(/\/$/, ''); - const normalizedPath = normalizePathname(parsedUrl.pathname); - const actualPath = `${normalizedPath}${parsedUrl.search}${parsedUrl.hash}`; + normalizedLegacyUrl.pathname === '/' + ? normalizedLegacyUrl.pathname + : normalizedLegacyUrl.pathname.replace(/\/$/, ''); + const detailRoute = DETAIL_ROUTE_RULES.find(({ pattern }) => + pattern.test(pathnameWithoutTrailingSlash) + ); - const systemWorkspaceDetail = pathnameWithoutTrailingSlash.match(SYSTEM_WORKSPACE_DETAIL_PATTERN); - if (systemWorkspaceDetail) { + if (detailRoute) { + const workspaceId = pathnameWithoutTrailingSlash.match(detailRoute.pattern)?.[1]; return { - actualPath: `${pathnameWithoutTrailingSlash}${parsedUrl.search}${parsedUrl.hash}`, - canonicalPath: '/system/workspaces/', + actualPath: `${pathnameWithoutTrailingSlash}${normalizedLegacyUrl.search}${normalizedLegacyUrl.hash}`, + canonicalPath: detailRoute.canonicalPath, kind: 'workspace-detail', - workspaceId: systemWorkspaceDetail[1], + workspaceId, }; } - const myWorkspaceDetail = pathnameWithoutTrailingSlash.match(MY_WORKSPACE_DETAIL_PATTERN); - if (myWorkspaceDetail) { + if (pathnameWithoutTrailingSlash.startsWith('/settings/')) { return { - actualPath: `${pathnameWithoutTrailingSlash}${parsedUrl.search}${parsedUrl.hash}`, - canonicalPath: '/my-workspaces/', - kind: 'workspace-detail', - workspaceId: myWorkspaceDetail[1], + actualPath: normalizedActualPath, + canonicalPath: pathnameWithoutTrailingSlash, + kind: 'regular', }; } return { - actualPath, - canonicalPath: normalizedPath, + actualPath: normalizedActualPath, + canonicalPath: normalizePathname(normalizedLegacyUrl.pathname), kind: 'regular', }; } + +export function resolveMenuLookupPath(url: string): string { + return normalizePathname(resolveRouteDescriptor(url).canonicalPath); +} + +export function resolveTabMatchPath(url: string): string { + const descriptor = resolveRouteDescriptor(url); + if (descriptor.kind === 'workspace-detail') { + return normalizePathname(new URL(descriptor.actualPath, URL_PARSE_BASE).pathname); + } + + return resolveMenuLookupPath(url); +} diff --git a/frontend/app/src/shared/utils/avatar.test.ts b/frontend/app/src/shared/utils/avatar.test.ts index b89bd8cd2..63404a983 100644 --- a/frontend/app/src/shared/utils/avatar.test.ts +++ b/frontend/app/src/shared/utils/avatar.test.ts @@ -110,9 +110,9 @@ describe('avatar', () => { }); it('seed를 기준으로 동일한 thumbs 캐릭터를 유지해야 함', () => { - const url = getThemedAvatarUrl('System Manager'); + const url = getThemedAvatarUrl('Platform Admin'); - expect(url).toContain('seed=System+Manager'); + expect(url).toContain('seed=Platform+Admin'); expect(url).toContain('shapeColor='); }); diff --git a/frontend/app/src/test/auth.test.ts b/frontend/app/src/test/auth.test.ts index 274bfb819..40e9f61b3 100644 --- a/frontend/app/src/test/auth.test.ts +++ b/frontend/app/src/test/auth.test.ts @@ -6,6 +6,12 @@ describe('isSignedInLocation', () => { expect(isSignedInLocation('http://localhost:8022/', 'https://localhost:4022')).toBe(true); }); + it('console dashboard canonical 경로를 로그인 완료 상태로 인정해야 한다', () => { + expect( + isSignedInLocation('https://localhost:4022/console/dashboard/', 'https://localhost:4022') + ).toBe(true); + }); + it('다른 포트 origin은 로그인 완료로 오인하면 안 된다', () => { expect(isSignedInLocation('http://localhost:7022/', 'https://localhost:4022')).toBe(false); }); diff --git a/frontend/app/src/test/test-context-url.test.ts b/frontend/app/src/test/test-context-url.test.ts index 477763616..719cdb559 100644 --- a/frontend/app/src/test/test-context-url.test.ts +++ b/frontend/app/src/test/test-context-url.test.ts @@ -4,20 +4,20 @@ import { resolveTestUrl } from '../../../tests/helpers/test-context-url'; describe('resolveTestUrl', () => { it('CI backend base URL이 있으면 상대 경로를 backend origin으로 해석해야 한다', () => { expect( - resolveTestUrl('/system/notification-channels', { + resolveTestUrl('/console/notification-channels', { envBaseUrl: 'http://backend:8011', isCi: true, }) - ).toBe('http://backend:8011/system/notification-channels'); + ).toBe('http://backend:8011/console/notification-channels'); }); it('명시적 baseUrl override가 있으면 그 origin을 우선 사용해야 한다', () => { expect( - resolveTestUrl('/my-workspaces', { + resolveTestUrl('/console/my-workspaces', { baseUrl: 'https://localhost:4022', envBaseUrl: 'http://backend:8011', isCi: true, }) - ).toBe('https://localhost:4022/my-workspaces'); + ).toBe('https://localhost:4022/console/my-workspaces'); }); }); diff --git a/frontend/app/src/widgets/sidebar/Sidebar.test.tsx b/frontend/app/src/widgets/sidebar/Sidebar.test.tsx index f93edc13c..8b334545d 100644 --- a/frontend/app/src/widgets/sidebar/Sidebar.test.tsx +++ b/frontend/app/src/widgets/sidebar/Sidebar.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, fireEvent, cleanup } from '@testing-library/react'; import { SidebarProvider } from '#app/shared/sidebar'; +import { user } from '#app/app/state'; import { currentWorkspaceId } from '#app/entities/workspace'; -import { setSystemSettings } from '#app/entities/system-settings'; +import { setPlatformSettings } from '#app/entities/platform-settings'; import { Sidebar, menuItems, @@ -12,6 +13,8 @@ import { setPrograms, setCurrentRole, setMenuData, + resolveActiveMenuIdByPath, + syncSidebarState, toggleGroup, setActiveMenu, clearActiveState, @@ -43,7 +46,18 @@ describe('Sidebar', () => { activeMenuId.set(null); sidebarCollapsed.set(false); currentWorkspaceId.set(''); - setSystemSettings(null); + setPlatformSettings(null); + user.set({ + id: 'user-1', + username: 'member', + name: 'Member User', + email: 'member@example.com', + roleIds: ['role-user'], + roles: [{ id: 'role-user', label: 'User' }], + permissions: [], + isOwner: false, + hasPermissions: true, + } as never); sessionStorage.clear(); vi.clearAllMocks(); }); @@ -51,7 +65,8 @@ describe('Sidebar', () => { afterEach(() => { cleanup(); currentWorkspaceId.set(''); - setSystemSettings(null); + setPlatformSettings(null); + user.set(null); sessionStorage.clear(); }); @@ -281,24 +296,26 @@ describe('Sidebar', () => { expect(menuItems.get()[0].url).toBeUndefined(); }); - it('user-managed가 꺼져 있으면 My Workspace 메뉴를 숨겨야 함', () => { + it('platform-managed만 켜져 있어도 My Workspace 메뉴를 보여야 함', () => { setPrograms([ { code: 'MY_WORKSPACE', - path: '/my-workspaces', + path: '/console/my-workspaces', permissions: [], workspace: { required: true, - managedType: 'USER_MANAGED', + selectionRequired: false, + requiredManagedType: null, }, }, ]); - setSystemSettings({ + setPlatformSettings({ brandName: 'Deck', baseUrl: 'http://localhost:8011', workspacePolicy: { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, }); @@ -313,31 +330,33 @@ describe('Sidebar', () => { }, ]); - expect(menuItems.get()).toHaveLength(0); + expect(menuItems.get()).toHaveLength(1); + expect(menuItems.get()[0]?.label).toBe('My Workspace'); }); it('workspacePolicy가 없으면 workspace 메뉴 그룹 전체를 숨겨야 함', () => { setPrograms([ { code: 'MY_WORKSPACE', - path: '/my-workspaces', + path: '/console/my-workspaces', permissions: [], workspace: { required: true, - managedType: 'USER_MANAGED', + selectionRequired: false, + requiredManagedType: null, }, }, { code: 'WORKSPACE_MANAGEMENT', - path: '/system/workspaces', + path: '/console/workspaces', permissions: [], workspace: { required: true, - managedType: 'SYSTEM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }, ]); - setSystemSettings({ + setPlatformSettings({ brandName: 'Deck', baseUrl: 'http://localhost:8011', workspacePolicy: null, @@ -369,6 +388,143 @@ describe('Sidebar', () => { expect(menuItems.get()).toHaveLength(0); }); + + it('workspace_id query가 있으면 현재 선택이 비어 있어도 workspace-required 메뉴를 보여야 함', () => { + window.history.replaceState({}, '', '/console/contacts?workspace_id=ws-query'); + currentWorkspaceId.set(''); + + setPrograms([ + { + code: 'DESKPIE_CONTACTS', + path: '/console/contacts', + permissions: [], + workspace: { + required: true, + selectionRequired: true, + requiredManagedType: null, + }, + }, + ]); + setPlatformSettings({ + brandName: 'Deck', + baseUrl: 'http://localhost:8011', + workspacePolicy: { + useUserManaged: true, + usePlatformManaged: true, + useExternalSync: true, + useSelector: true, + }, + }); + + setMenuData([ + { + id: 'deskpie-contacts', + name: 'Contacts', + program: 'DESKPIE_CONTACTS', + permissions: [], + children: [], + }, + ]); + + expect(menuItems.get()).toHaveLength(1); + expect(menuItems.get()[0]?.label).toBe('Contacts'); + }); + + it('PLATFORM_MANAGED 메뉴는 일반 사용자 runtime navigation에서 숨겨야 함', () => { + setPrograms([{ code: 'ROLE_MANAGEMENT', path: '/settings/platform/roles', permissions: [] }]); + + setMenuData([ + { + id: 'platform-roles', + name: 'Roles', + program: 'ROLE_MANAGEMENT', + managementType: 'PLATFORM_MANAGED', + permissions: [], + children: [], + }, + ]); + + expect(menuItems.get()).toHaveLength(0); + }); + + it('PLATFORM_MANAGED 메뉴는 platform admin runtime navigation에는 보여야 함', () => { + user.set({ + id: 'user-1', + username: 'owner', + name: 'Owner User', + email: 'owner@example.com', + roleIds: ['role-owner'], + roles: [{ id: 'role-owner', label: 'Owner' }], + permissions: [], + isOwner: true, + hasPermissions: true, + } as never); + setPrograms([{ code: 'ROLE_MANAGEMENT', path: '/settings/platform/roles', permissions: [] }]); + + setMenuData([ + { + id: 'platform-roles', + name: 'Roles', + program: 'ROLE_MANAGEMENT', + managementType: 'PLATFORM_MANAGED', + permissions: [], + children: [], + }, + ]); + + expect(menuItems.get()).toHaveLength(1); + expect(menuItems.get()[0]).toMatchObject({ + id: 'platform-roles', + label: 'Roles', + url: '/settings/platform/roles', + }); + }); + + it('platform admin 상태가 런타임 중 바뀌면 PLATFORM_MANAGED 메뉴 가시성도 재계산해야 함', () => { + setPrograms([{ code: 'ROLE_MANAGEMENT', path: '/settings/platform/roles', permissions: [] }]); + + setMenuData([ + { + id: 'platform-roles', + name: 'Roles', + program: 'ROLE_MANAGEMENT', + managementType: 'PLATFORM_MANAGED', + permissions: [], + children: [], + }, + ]); + + expect(menuItems.get()).toHaveLength(0); + + user.set({ + id: 'user-1', + username: 'owner', + name: 'Owner User', + email: 'owner@example.com', + roleIds: ['role-owner'], + roles: [{ id: 'role-owner', label: 'Owner' }], + permissions: [], + isOwner: true, + hasPermissions: true, + } as never); + + expect(menuItems.get()).toHaveLength(1); + expect(menuItems.get()[0]?.label).toBe('Roles'); + + user.set({ + id: 'user-1', + username: 'member', + name: 'Member User', + email: 'member@example.com', + roleIds: ['role-user'], + roles: [{ id: 'role-user', label: 'User' }], + permissions: [], + isOwner: false, + hasPermissions: true, + } as never); + + expect(menuItems.get()).toHaveLength(0); + }); }); describe('toggleGroup', () => { @@ -393,6 +549,76 @@ describe('Sidebar', () => { clearActiveState(); expect(activeMenuId.get()).toBeNull(); }); + + it('settings leaf path도 active menu id로 해석해야 함', () => { + setPrograms([ + { + code: 'PLATFORM_GENERAL_SETTINGS', + path: '/settings/platform/general', + permissions: [], + }, + ]); + setMenuData([ + { + id: 'platform-general', + name: 'General', + icon: 'Settings', + program: 'PLATFORM_GENERAL_SETTINGS', + permissions: [], + children: [], + }, + ]); + + expect(resolveActiveMenuIdByPath('/settings/platform/general?tab=branding')).toBe( + 'platform-general' + ); + }); + + it('URL query만 바뀌어도 popstate로 workspace-required 메뉴와 active state를 다시 계산해야 함', () => { + window.history.replaceState({}, '', '/'); + setPrograms([ + { + code: 'DESKPIE_CONTACTS', + path: '/console/contacts', + permissions: [], + workspace: { + required: true, + selectionRequired: true, + requiredManagedType: null, + }, + }, + ]); + setPlatformSettings({ + workspacePolicy: { + useUserManaged: true, + usePlatformManaged: true, + useExternalSync: true, + useSelector: true, + }, + } as never); + setMenuData([ + { + id: 'deskpie-contacts', + name: 'Contacts', + icon: 'ContactRound', + program: 'DESKPIE_CONTACTS', + permissions: [], + children: [], + }, + ]); + + expect(menuItems.get()).toHaveLength(0); + expect(activeMenuId.get()).toBeNull(); + + window.history.replaceState({}, '', '/console/contacts?workspace_id=ws-query'); + window.dispatchEvent(new PopStateEvent('popstate')); + + expect(menuItems.get()).toHaveLength(1); + expect(activeMenuId.get()).toBe('deskpie-contacts'); + + window.history.replaceState({}, '', '/'); + syncSidebarState('/'); + }); }); describe('toggleSidebar / setSidebarCollapsed', () => { diff --git a/frontend/app/src/widgets/sidebar/Sidebar.tsx b/frontend/app/src/widgets/sidebar/Sidebar.tsx index 5a4207916..258974860 100644 --- a/frontend/app/src/widgets/sidebar/Sidebar.tsx +++ b/frontend/app/src/widgets/sidebar/Sidebar.tsx @@ -7,10 +7,7 @@ * - 활성 메뉴 하이라이팅 */ -import { createStore } from '#app/shared/store/create-store'; import { Icon } from '#app/shared/icon'; -import { currentWorkspaceId } from '#app/entities/workspace'; -import { systemSettings, isProgramAccessible } from '#app/entities/system-settings'; import { SidebarMenu, SidebarMenuItem, @@ -21,9 +18,14 @@ import { SidebarMenuBadge, } from '#app/shared/sidebar'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '#app/shared/collapsible'; -import { resolveRouteDescriptor } from '#app/shared/router'; import type { icons } from 'lucide'; -import type { WorkspaceManagedType } from '#app/entities/system-settings'; +import { createStore } from '#app/shared/store/create-store'; +import { + menuItems, + activeMenuId, + resetMenuRuntime, + type MenuItem, +} from '#app/app/navigation/menu-runtime'; const SIDEBAR_KEY = 'deck-sidebar'; const MENU_EXPANDED_KEY = 'deck-menu-expanded'; @@ -40,34 +42,6 @@ function getSessionItem(key: string): T | null { // Types // ============================================ -export interface MenuItem { - id: string; - label: string; - icon?: string; - url?: string; - badge?: string; - children?: MenuItem[]; -} - -export interface ApiMenu { - id: string; - name: string; - icon?: string; - program?: string; - permissions: string[]; - children: ApiMenu[]; -} - -export interface Program { - code: string; - path: string; - permissions: string[]; - workspace?: { - required: boolean; - managedType: WorkspaceManagedType | null; - } | null; -} - interface MenuItemClickEvent { menuId: string; title: string; @@ -80,14 +54,8 @@ interface MenuItemClickEvent { // Stores (State) // ============================================ -export const menuItems = createStore([]); export const expandedGroups = createStore>(new Set()); -export const activeMenuId = createStore(null); export const sidebarCollapsed = createStore(false); - -// Internal state -export const programs = createStore([]); -const rawApiMenus = createStore([]); const currentRole = createStore(''); // ============================================ @@ -113,119 +81,11 @@ function collectGroupIds(items: MenuItem[]): string[] { return ids; } -function normalizePathname(pathname: string): string { - if (pathname === '/') return pathname; - return pathname.endsWith('/') ? pathname : `${pathname}/`; -} - -function findProgram(programCode?: string): Program | undefined { - if (!programCode) return undefined; - return programs.get().find((program) => program.code === programCode); -} - -function isMenuProgramAccessible(programCode?: string): boolean { - const program = findProgram(programCode); - if (!program) return !programCode; - - return isProgramAccessible( - program, - systemSettings.get()?.workspacePolicy ?? null, - currentWorkspaceId.get() - ); -} - -function convertApiMenuToMenuItem(apiMenu: ApiMenu): MenuItem | null { - const program = findProgram(apiMenu.program); - const url = program?.path || undefined; - const children = apiMenu.children - .map(convertApiMenuToMenuItem) - .filter((item): item is MenuItem => item != null); - - if (apiMenu.children.length > 0) { - if (children.length === 0) { - return null; - } - - return { - id: apiMenu.id, - label: apiMenu.name, - icon: apiMenu.icon, - children, - }; - } - - if (!isMenuProgramAccessible(apiMenu.program)) { - return null; - } - - return { - id: apiMenu.id, - label: apiMenu.name, - icon: apiMenu.icon, - url, - }; -} - -function syncMenuData() { - menuItems.set( - rawApiMenus - .get() - .map(convertApiMenuToMenuItem) - .filter((item): item is MenuItem => item != null) - ); -} - -function findMenuByPath(apiMenus: ApiMenu[], path: string): MenuItem | null { - const normalizedPath = resolveRouteDescriptor(path).canonicalPath; - - for (const apiMenu of apiMenus) { - const program = findProgram(apiMenu.program); - if (program && normalizePathname(program.path) === normalizedPath) { - return { - id: apiMenu.id, - label: apiMenu.name, - icon: apiMenu.icon, - url: normalizedPath, - }; - } - - const child = findMenuByPath(apiMenu.children, normalizedPath); - if (child) { - return child; - } - } - - return null; -} - -// ============================================ -// Actions -// ============================================ - -export function setPrograms(progs: Program[]) { - programs.set(progs); - syncMenuData(); -} - -export function getProgramByPath(path: string): Program | undefined { - const normalizedPath = resolveRouteDescriptor(path).canonicalPath; - return programs.get().find((program) => normalizePathname(program.path) === normalizedPath); -} - export function setCurrentRole(role: string) { currentRole.set(role); expandedGroups.set(new Set()); } -export function setMenuData(apiMenus: ApiMenu[]) { - rawApiMenus.set(apiMenus); - syncMenuData(); -} - -export function findRawMenuByPath(path: string): MenuItem | null { - return findMenuByPath(rawApiMenus.get(), path); -} - export function toggleGroup(groupId: string) { const next = new Set(expandedGroups.get()); if (next.has(groupId)) { @@ -237,14 +97,6 @@ export function toggleGroup(groupId: string) { saveExpandedGroups(); } -export function setActiveMenu(menuId: string) { - activeMenuId.set(menuId); -} - -export function clearActiveState() { - activeMenuId.set(null); -} - export function toggleSidebar() { const next = !sidebarCollapsed.get(); sidebarCollapsed.set(next); @@ -257,18 +109,12 @@ export function setSidebarCollapsed(collapsed: boolean) { } export function resetSidebar() { - menuItems.set([]); expandedGroups.set(new Set()); - activeMenuId.set(null); sidebarCollapsed.set(false); - programs.set([]); - rawApiMenus.set([]); currentRole.set(''); + resetMenuRuntime(); } -systemSettings.subscribe(syncMenuData); -currentWorkspaceId.subscribe(syncMenuData); - // ============================================ // Storage Functions // ============================================ @@ -483,3 +329,18 @@ export function SidebarNav({ onMenuClick }: SidebarNavProps) { // Backward compatibility export { SidebarNav as Sidebar }; export default SidebarNav; +export { + menuItems, + activeMenuId, + programs, + setPrograms, + setActiveMenu, + clearActiveState, + getProgramByPath, + findRawMenuByPath, + refreshMenuData, + resolveActiveMenuIdByPath, + syncSidebarState, + setMenuData, +} from '#app/app/navigation/menu-runtime'; +export type { ApiMenu, MenuItem, Program } from '#app/app/navigation/menu-runtime'; diff --git a/frontend/app/src/widgets/sidebar/index.ts b/frontend/app/src/widgets/sidebar/index.ts index 613f5afff..1e9f39986 100644 --- a/frontend/app/src/widgets/sidebar/index.ts +++ b/frontend/app/src/widgets/sidebar/index.ts @@ -8,13 +8,16 @@ export { SidebarNav, Sidebar, default } from './Sidebar'; // Signals export { menuItems, expandedGroups, activeMenuId, sidebarCollapsed, hasMenuItems } from './Sidebar'; -export { programs } from './Sidebar'; +export { programs } from '#app/app/navigation/menu-runtime'; // Actions export { setPrograms, getProgramByPath, findRawMenuByPath, + refreshMenuData, + resolveActiveMenuIdByPath, + syncSidebarState, setCurrentRole, setMenuData, toggleGroup, @@ -28,4 +31,4 @@ export { } from './Sidebar'; // Types -export type { MenuItem, ApiMenu, Program } from './Sidebar'; +export type { MenuItem, ApiMenu, Program } from '#app/app/navigation/menu-types'; diff --git a/frontend/app/src/widgets/tabbar/tab-bar.test.tsx b/frontend/app/src/widgets/tabbar/tab-bar.test.tsx index 5df262020..e7c8c9b78 100644 --- a/frontend/app/src/widgets/tabbar/tab-bar.test.tsx +++ b/frontend/app/src/widgets/tabbar/tab-bar.test.tsx @@ -220,14 +220,14 @@ describe('TabBar', () => { describe('updateActiveTabUrl', () => { it('detail URL에 trailing slash를 추가하지 않아야 함', () => { - history.replaceState({}, '', '/system/workspaces/'); - tabs.set([{ id: 'tab1', title: 'Workspaces', url: '/system/workspaces/' }]); + history.replaceState({}, '', '/console/workspaces/'); + tabs.set([{ id: 'tab1', title: 'Workspaces', url: '/console/workspaces/' }]); activeTabId.set('tab1'); - updateActiveTabUrl('/system/workspaces/ws-1'); + updateActiveTabUrl('/console/workspaces/ws-1'); - expect(window.location.pathname).toBe('/system/workspaces/ws-1'); - expect(tabs.get()[0]?.url).toBe('/system/workspaces/ws-1'); + expect(window.location.pathname).toBe('/console/workspaces/ws-1'); + expect(tabs.get()[0]?.url).toBe('/console/workspaces/ws-1'); }); it('standalone 모드에서는 현재 창 query를 유지하고 라우터 재평가 이벤트를 발생해야 함', () => { diff --git a/frontend/app/tests/manual/meetpie/namecard.spec.ts b/frontend/app/tests/manual/meetpie/namecard.spec.ts index eded0a347..57a967cae 100644 --- a/frontend/app/tests/manual/meetpie/namecard.spec.ts +++ b/frontend/app/tests/manual/meetpie/namecard.spec.ts @@ -117,7 +117,12 @@ test.describe('명함 매뉴얼 — 내 명함 편집', { tag: '@manual' }, () = await setupTestContext(page, { mode: 'standalone', permissions: PERMISSIONS, - tab: { id: 'tab-namecard', title: 'My Namecard', icon: 'IdCard', url: '/my-namecards/' }, + tab: { + id: 'tab-namecard', + title: 'My Namecard', + icon: 'IdCard', + url: '/console/my-namecards/', + }, programs: [], menuTree: [], }); @@ -144,7 +149,7 @@ test.describe('명함 매뉴얼 — 명함 공개 링크', { tag: '@manual' }, ( id: 'tab-namecard-share', title: 'My Namecard', icon: 'IdCard', - url: '/my-namecards/', + url: '/console/my-namecards/', }, programs: [], menuTree: [], @@ -187,7 +192,7 @@ test.describe('명함 매뉴얼 — 연락처 목록', { tag: '@manual' }, () => await setupTestContext(page, { mode: 'standalone', permissions: PERMISSIONS, - tab: { id: 'tab-contacts', title: 'Contacts', icon: 'Users', url: '/contacts/' }, + tab: { id: 'tab-contacts', title: 'Contacts', icon: 'Users', url: '/console/contacts/' }, programs: [], menuTree: [], }); @@ -208,7 +213,7 @@ test.describe('명함 매뉴얼 — 연락처 편집', { tag: '@manual' }, () => await setupTestContext(page, { mode: 'standalone', permissions: PERMISSIONS, - tab: { id: 'tab-contacts-edit', title: 'Contacts', icon: 'Users', url: '/contacts/' }, + tab: { id: 'tab-contacts-edit', title: 'Contacts', icon: 'Users', url: '/console/contacts/' }, programs: [], menuTree: [], }); diff --git a/frontend/deskpie/src/page-plugins.ts b/frontend/deskpie/src/page-plugins.ts index 18ac815c6..546faae23 100644 --- a/frontend/deskpie/src/page-plugins.ts +++ b/frontend/deskpie/src/page-plugins.ts @@ -1,8 +1,8 @@ import './shared/i18n/setup'; -import { registerPages } from '@deck/app/app/page-registry'; +import { registerConsolePages } from '@deck/app/app/page-registry'; import { registerRoutes } from '@deck/app/app/route-registry'; -registerPages({ +registerConsolePages({ '/companies/': () => import('./pages/companies/companies.page'), '/contracting-parties/': () => import('./pages/contracting-parties/contracting-parties.page'), '/contacts/': () => import('./pages/contacts/contacts.page'), diff --git a/frontend/deskpie/src/pages/companies/companies.page.tsx b/frontend/deskpie/src/pages/companies/companies.page.tsx index c9f58d7f3..6cd2cd19a 100644 --- a/frontend/deskpie/src/pages/companies/companies.page.tsx +++ b/frontend/deskpie/src/pages/companies/companies.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Grid, useGridColumns, type GridProps } from '@deck/app/shared/grid'; import { Button } from '@deck/app/components/ui/button'; import { Tabs, TabsList, TabsTrigger } from '@deck/app/components/ui/tabs'; @@ -90,7 +90,7 @@ export function CompaniesPage() { const [draftRole, setDraftRole] = useState('ALL'); const [filterOpen, setFilterOpen] = useState(false); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_COMPANY_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/contacts/contacts.page.tsx b/frontend/deskpie/src/pages/contacts/contacts.page.tsx index 94b502cee..84705ce80 100644 --- a/frontend/deskpie/src/pages/contacts/contacts.page.tsx +++ b/frontend/deskpie/src/pages/contacts/contacts.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Grid, useGridColumns, type GridProps, type GridQuery } from '@deck/app/shared/grid'; import { Button } from '@deck/app/components/ui/button'; import { Badge } from '@deck/app/shared/badge'; @@ -113,7 +113,7 @@ export function ContactsPage() { const [companyMap, setCompanyMap] = useState>({}); const columns = useGridColumns(() => getContactColumns(t, companyMap), [translate, companyMap]); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_CONTACT_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/contracting-parties/contracting-parties.page.test.tsx b/frontend/deskpie/src/pages/contracting-parties/contracting-parties.page.test.tsx index 2270efa0a..220d3e7c6 100644 --- a/frontend/deskpie/src/pages/contracting-parties/contracting-parties.page.test.tsx +++ b/frontend/deskpie/src/pages/contracting-parties/contracting-parties.page.test.tsx @@ -34,9 +34,7 @@ vi.mock('@deck/app/features/auth', () => ({ })); vi.mock('@deck/app/entities/workspace', () => ({ - currentWorkspaceId: { - useStore: () => 'ws-1', - }, + useWorkspaceContextId: () => 'ws-1', })); vi.mock('@deck/app/shared/overlay', () => ({ diff --git a/frontend/deskpie/src/pages/contracting-parties/contracting-parties.page.tsx b/frontend/deskpie/src/pages/contracting-parties/contracting-parties.page.tsx index 9dd2e5b1f..51dd16dc0 100644 --- a/frontend/deskpie/src/pages/contracting-parties/contracting-parties.page.tsx +++ b/frontend/deskpie/src/pages/contracting-parties/contracting-parties.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Grid, useGridColumns, type GridProps } from '@deck/app/shared/grid'; import { Button } from '@deck/app/components/ui/button'; import { @@ -137,7 +137,7 @@ export function ContractingPartiesPage() { const [companies, setCompanies] = useState([]); const [contacts, setContacts] = useState([]); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_CONTRACTING_PARTY_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/contracts/contracts.page.test.tsx b/frontend/deskpie/src/pages/contracts/contracts.page.test.tsx index 78092549c..e40104f2e 100644 --- a/frontend/deskpie/src/pages/contracts/contracts.page.test.tsx +++ b/frontend/deskpie/src/pages/contracts/contracts.page.test.tsx @@ -25,9 +25,7 @@ vi.mock('@deck/app/features/auth', () => ({ })); vi.mock('@deck/app/entities/workspace', () => ({ - currentWorkspaceId: { - useStore: () => 'workspace-1', - }, + useWorkspaceContextId: () => 'workspace-1', })); vi.mock('@deck/app/shared/i18n', () => ({ diff --git a/frontend/deskpie/src/pages/contracts/contracts.page.tsx b/frontend/deskpie/src/pages/contracts/contracts.page.tsx index a50912735..9b553bfd0 100644 --- a/frontend/deskpie/src/pages/contracts/contracts.page.tsx +++ b/frontend/deskpie/src/pages/contracts/contracts.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, type ComponentProps } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Button } from '@deck/app/components/ui/button'; import { Select, @@ -177,7 +177,7 @@ export function ContractsPage() { const [reloadKey, setReloadKey] = useState(0); const [viewMode, setViewMode] = useCrmViewMode(); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_CONTRACT_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/deals/deal-edit-modal.test.tsx b/frontend/deskpie/src/pages/deals/deal-edit-modal.test.tsx index 0f91a1170..44d1b76a9 100644 --- a/frontend/deskpie/src/pages/deals/deal-edit-modal.test.tsx +++ b/frontend/deskpie/src/pages/deals/deal-edit-modal.test.tsx @@ -4,7 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthorizationProvider } from '@deck/app/shared/authorization'; import { auth } from '@deck/app/features/auth'; -import { setSystemSettings } from '@deck/app/entities/system-settings'; +import { setPlatformSettings } from '@deck/app/entities/platform-settings'; import { DealEditModal } from './deal-edit-modal'; const { listCompanies, listContacts, createDeal, updateDeal, changeStage, listDealContacts, toast } = @@ -55,7 +55,7 @@ describe('DealEditModal', () => { beforeEach(() => { vi.clearAllMocks(); window.HTMLElement.prototype.scrollIntoView = vi.fn(); - setSystemSettings(null); + setPlatformSettings(null); listCompanies.mockResolvedValue({ content: [] }); listContacts.mockResolvedValue({ content: [] }); listDealContacts.mockResolvedValue([]); @@ -76,7 +76,7 @@ describe('DealEditModal', () => { } it('currency policy의 default 통화를 deal form의 기본 선택값으로 반영해야 한다', async () => { - setSystemSettings({ + setPlatformSettings({ brandName: 'Deck', baseUrl: 'https://deck.test', contactEmail: null, diff --git a/frontend/deskpie/src/pages/deals/deal-edit-modal.tsx b/frontend/deskpie/src/pages/deals/deal-edit-modal.tsx index 67c42f42f..2762ddc0b 100644 --- a/frontend/deskpie/src/pages/deals/deal-edit-modal.tsx +++ b/frontend/deskpie/src/pages/deals/deal-edit-modal.tsx @@ -3,7 +3,7 @@ import { Controller, useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Button } from '@deck/app/components/ui/button'; -import { systemSettings } from '@deck/app/entities/system-settings'; +import { platformSettings } from '@deck/app/entities/platform-settings'; import { Select, SelectContent, @@ -73,7 +73,7 @@ export function DealEditModal({ onComplete, }: DealEditModalProps) { const { t: translate } = useTranslation(); - const settings = systemSettings.useStore(); + const settings = platformSettings.useStore(); const defaultCurrencyCode = settings?.currencyPolicy?.defaultCurrencyCode ?? 'KRW'; const t = (key: string, options?: Record) => (translate as any)(key, { ns: 'deal', ...(options ?? {}) }) as string; diff --git a/frontend/deskpie/src/pages/deals/deals.page.tsx b/frontend/deskpie/src/pages/deals/deals.page.tsx index 145e9d7da..d7978f5c2 100644 --- a/frontend/deskpie/src/pages/deals/deals.page.tsx +++ b/frontend/deskpie/src/pages/deals/deals.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Button } from '@deck/app/components/ui/button'; import { Badge } from '@deck/app/shared/badge'; import { @@ -70,7 +70,7 @@ export function DealsPage() { const [reloadKey, setReloadKey] = useState(0); const [viewMode, setViewMode] = useCrmViewMode(); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_DEAL_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/leads/leads.page.tsx b/frontend/deskpie/src/pages/leads/leads.page.tsx index 7ed7f8000..b5f368a60 100644 --- a/frontend/deskpie/src/pages/leads/leads.page.tsx +++ b/frontend/deskpie/src/pages/leads/leads.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, type ComponentProps } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Button } from '@deck/app/components/ui/button'; import { Badge } from '@deck/app/shared/badge'; import { @@ -95,7 +95,7 @@ export function LeadsPage() { const [reloadKey, setReloadKey] = useState(0); const [viewMode, setViewMode] = useCrmViewMode(); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_LEAD_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/license-requests/license-requests.page.tsx b/frontend/deskpie/src/pages/license-requests/license-requests.page.tsx index cf48a0842..30cef829f 100644 --- a/frontend/deskpie/src/pages/license-requests/license-requests.page.tsx +++ b/frontend/deskpie/src/pages/license-requests/license-requests.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Grid, useGridColumns, type GridProps } from '@deck/app/shared/grid'; import { Button } from '@deck/app/components/ui/button'; import { @@ -179,7 +179,7 @@ export function LicenseRequestsPage() { const [contractingParties, setContractingParties] = useState([]); const [contracts, setContracts] = useState([]); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_LICENSE_REQUEST_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/licenses/licenses.page.tsx b/frontend/deskpie/src/pages/licenses/licenses.page.tsx index b28741e77..9188c9656 100644 --- a/frontend/deskpie/src/pages/licenses/licenses.page.tsx +++ b/frontend/deskpie/src/pages/licenses/licenses.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Grid, useGridColumns, type GridProps } from '@deck/app/shared/grid'; import { Button } from '@deck/app/components/ui/button'; import { @@ -202,7 +202,7 @@ export function LicensesPage() { const [contracts, setContracts] = useState([]); const [licenseRequests, setLicenseRequests] = useState([]); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_LICENSE_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/pipelines/pipelines.page.test.tsx b/frontend/deskpie/src/pages/pipelines/pipelines.page.test.tsx index 71231c12d..f1a43884c 100644 --- a/frontend/deskpie/src/pages/pipelines/pipelines.page.test.tsx +++ b/frontend/deskpie/src/pages/pipelines/pipelines.page.test.tsx @@ -44,9 +44,7 @@ vi.mock('@deck/app/features/auth', () => ({ })); vi.mock('@deck/app/entities/workspace', () => ({ - currentWorkspaceId: { - useStore: () => 'ws-1', - }, + useWorkspaceContextId: () => 'ws-1', })); vi.mock('@deck/app/shared/overlay', () => ({ @@ -145,7 +143,7 @@ describe('PipelinesPage', () => { reorderStagesMock.mockResolvedValue(undefined); treeViewMock.mockReset(); sessionStorage.clear(); - window.history.replaceState({}, '', '/pipelines/?objectType=DEAL'); + window.history.replaceState({}, '', '/console/pipelines/?objectType=DEAL'); }); afterEach(() => { @@ -175,7 +173,7 @@ describe('PipelinesPage', () => { }); it('objectType query가 CONTRACT이면 Contract pipeline 설정과 탭을 보여줘야 함', async () => { - window.history.replaceState({}, '', '/pipelines/?objectType=CONTRACT'); + window.history.replaceState({}, '', '/console/pipelines/?objectType=CONTRACT'); render(); diff --git a/frontend/deskpie/src/pages/pipelines/pipelines.page.tsx b/frontend/deskpie/src/pages/pipelines/pipelines.page.tsx index c5b009297..3a452c802 100644 --- a/frontend/deskpie/src/pages/pipelines/pipelines.page.tsx +++ b/frontend/deskpie/src/pages/pipelines/pipelines.page.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Button } from '@deck/app/components/ui/button'; import { Card, @@ -88,7 +88,7 @@ export function PipelinesPage() { (translate as any)(key, { ns: 'pipeline', ...(options ?? {}) }) as string; const tc = (key: string, options?: Record) => (translate as any)(key, { ns: 'common', ...(options ?? {}) }) as string; - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasReadPermission = auth.hasPermission('CRM_PIPELINE_MANAGEMENT_READ'); diff --git a/frontend/deskpie/src/pages/products/products.page.tsx b/frontend/deskpie/src/pages/products/products.page.tsx index 0e9cacb63..7b74c1d8c 100644 --- a/frontend/deskpie/src/pages/products/products.page.tsx +++ b/frontend/deskpie/src/pages/products/products.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Grid, useGridColumns, type GridProps } from '@deck/app/shared/grid'; import { Button } from '@deck/app/components/ui/button'; import { Icon } from '@deck/app/shared/icon'; @@ -64,7 +64,7 @@ export function ProductsPage() { const columns = useGridColumns(() => getProductColumns(t), [translate]); const [selectedProduct, setSelectedProduct] = useState(null); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_PRODUCT_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/quotes/quotes.page.tsx b/frontend/deskpie/src/pages/quotes/quotes.page.tsx index ee173743c..bf4de0b83 100644 --- a/frontend/deskpie/src/pages/quotes/quotes.page.tsx +++ b/frontend/deskpie/src/pages/quotes/quotes.page.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { SystemLayout } from '@deck/app/layouts'; import { auth } from '@deck/app/features/auth'; -import { currentWorkspaceId } from '@deck/app/entities/workspace'; +import { useWorkspaceContextId } from '@deck/app/entities/workspace'; import { Grid, useGridColumns, type GridProps } from '@deck/app/shared/grid'; import { Button } from '@deck/app/components/ui/button'; import { @@ -128,7 +128,7 @@ export function QuotesPage() { const [deals, setDeals] = useState([]); const [products, setProducts] = useState([]); - const workspaceId = currentWorkspaceId.useStore(); + const workspaceId = useWorkspaceContextId(); const toast = useToast(); const modal = useModal(); const hasWritePermission = auth.hasPermission('CRM_QUOTE_MANAGEMENT_WRITE'); diff --git a/frontend/deskpie/src/pages/shared/crm-pipeline-state.test.ts b/frontend/deskpie/src/pages/shared/crm-pipeline-state.test.ts index 2ed6e35bb..df40e5fc9 100644 --- a/frontend/deskpie/src/pages/shared/crm-pipeline-state.test.ts +++ b/frontend/deskpie/src/pages/shared/crm-pipeline-state.test.ts @@ -52,7 +52,7 @@ describe('crm pipeline state helpers', () => { }); it('pipeline settings URL은 objectType query를 포함해야 함', () => { - expect(buildPipelineManagementUrl('LEAD')).toBe('/pipelines/?objectType=LEAD'); + expect(buildPipelineManagementUrl('LEAD')).toBe('/console/pipelines/?objectType=LEAD'); }); it('query param에서 pipeline object type을 읽어야 함', () => { diff --git a/frontend/deskpie/src/pages/shared/crm-pipeline-state.ts b/frontend/deskpie/src/pages/shared/crm-pipeline-state.ts index cf43be23c..0d24d6858 100644 --- a/frontend/deskpie/src/pages/shared/crm-pipeline-state.ts +++ b/frontend/deskpie/src/pages/shared/crm-pipeline-state.ts @@ -1,4 +1,5 @@ import type { Pipeline, PipelineObjectType } from '@deskpie/entities/pipeline'; +import { toConsolePath } from '@deck/app/shared/router'; const PIPELINE_MANAGEMENT_OBJECT_TYPE_KEY = 'deskpie.crm.pipeline-management.object-type'; export const PIPELINE_MANAGEMENT_OBJECT_TYPE_EVENT = 'deskpie:pipeline-management-object-type'; @@ -13,7 +14,7 @@ export function selectBoardPipeline(pipelines: Pipeline[], pipelineId?: string) } export function buildPipelineManagementUrl(objectType: PipelineObjectType) { - return `/pipelines/?objectType=${objectType}`; + return toConsolePath(`/pipelines/?objectType=${objectType}`); } export function parsePipelineManagementObjectType(search: string): PipelineObjectType { diff --git a/frontend/deskpie/src/pages/shared/crm-view-mode.test.ts b/frontend/deskpie/src/pages/shared/crm-view-mode.test.ts index c35792c1f..51be6bd9f 100644 --- a/frontend/deskpie/src/pages/shared/crm-view-mode.test.ts +++ b/frontend/deskpie/src/pages/shared/crm-view-mode.test.ts @@ -7,7 +7,7 @@ import { getCrmViewModeFromSearch, updateCrmViewModeSearch, useCrmViewMode } fro describe('crm view mode query param', () => { beforeEach(() => { - window.history.replaceState({}, '', '/deals'); + window.history.replaceState({}, '', '/console/deals'); tabs.set([]); activeTabId.set(null); }); @@ -31,7 +31,7 @@ describe('crm view mode query param', () => { }); it('hook은 현재 URL에서 view mode를 읽고 URL도 같이 갱신해야 함', () => { - window.history.replaceState({}, '', '/deals?view=list&pipelineId=p-1'); + window.history.replaceState({}, '', '/console/deals?view=list&pipelineId=p-1'); const { result } = renderHook(() => useCrmViewMode()); @@ -46,12 +46,12 @@ describe('crm view mode query param', () => { }); it('브라우저 뒤로가기로 search가 바뀌면 hook 상태도 따라가야 함', () => { - window.history.replaceState({}, '', '/deals?view=list'); + window.history.replaceState({}, '', '/console/deals?view=list'); const { result } = renderHook(() => useCrmViewMode()); act(() => { - window.history.pushState({}, '', '/deals?view=kanban'); + window.history.pushState({}, '', '/console/deals?view=kanban'); window.dispatchEvent(new PopStateEvent('popstate')); }); @@ -59,20 +59,20 @@ describe('crm view mode query param', () => { }); it('외부 replaceState로 search가 바뀌면 hook 상태도 따라가야 함', () => { - window.history.replaceState({}, '', '/deals?view=list'); + window.history.replaceState({}, '', '/console/deals?view=list'); const { result } = renderHook(() => useCrmViewMode()); act(() => { - window.history.replaceState({}, '', '/deals?view=kanban'); + window.history.replaceState({}, '', '/console/deals?view=kanban'); }); expect(result.current[0]).toBe('kanban'); }); it('view mode를 바꾸면 활성 탭 URL도 함께 갱신해야 함', () => { - window.history.replaceState({}, '', '/contracts?view=list'); - tabs.set([{ id: 'contracts', title: 'Contracts', url: '/contracts?view=list' }]); + window.history.replaceState({}, '', '/console/contracts?view=list'); + tabs.set([{ id: 'contracts', title: 'Contracts', url: '/console/contracts?view=list' }]); activeTabId.set('contracts'); const { result } = renderHook(() => useCrmViewMode()); @@ -81,6 +81,6 @@ describe('crm view mode query param', () => { result.current[1]('kanban'); }); - expect(tabs.get()[0]?.url).toBe('/contracts?view=kanban'); + expect(tabs.get()[0]?.url).toBe('/console/contracts?view=kanban'); }); }); diff --git a/frontend/meetpie/src/mcp-apps/calendar/ui/calendar-widget.test.tsx b/frontend/meetpie/src/mcp-apps/calendar/ui/calendar-widget.test.tsx index c60fe1e71..31f910197 100644 --- a/frontend/meetpie/src/mcp-apps/calendar/ui/calendar-widget.test.tsx +++ b/frontend/meetpie/src/mcp-apps/calendar/ui/calendar-widget.test.tsx @@ -49,7 +49,7 @@ describe('CalendarWidget', () => { , diff --git a/frontend/meetpie/src/page-plugins.ts b/frontend/meetpie/src/page-plugins.ts index c0bc564c5..614054aac 100644 --- a/frontend/meetpie/src/page-plugins.ts +++ b/frontend/meetpie/src/page-plugins.ts @@ -1,11 +1,10 @@ -import { registerPages } from '@deck/app/app/page-registry'; +import { registerConsolePages } from '@deck/app/app/page-registry'; import { registerRoutes } from '@deck/app/app/route-registry'; -registerPages({ +registerConsolePages({ '/calendar-integrations/': () => - import('./pages/calendar-integrations/calendar-integrations.page'), - '/calendar-settings/': () => import('./pages/calendar-settings/calendar-settings.page'), - '/calendar-settings/manage/': () => + import('./pages/calendar-settings/calendar-settings.page'), + '/calendar-integrations/manage/': () => import('./pages/calendar-settings/calendar-settings-manage.page'), '/booking/': () => import('./pages/booking-dashboard/booking-dashboard.page'), '/booking/settings/': () => import('./pages/booking-settings/booking-settings.page'), diff --git a/frontend/meetpie/src/pages/booking-dashboard/booking-dashboard.page.tsx b/frontend/meetpie/src/pages/booking-dashboard/booking-dashboard.page.tsx index 92c536def..c09b611a1 100644 --- a/frontend/meetpie/src/pages/booking-dashboard/booking-dashboard.page.tsx +++ b/frontend/meetpie/src/pages/booking-dashboard/booking-dashboard.page.tsx @@ -5,6 +5,7 @@ import { auth } from '@deck/app/features/auth'; import { navigate } from '@deck/app/shared/runtime'; import { Spinner } from '@deck/app/shared/spinner'; import { useToast } from '@deck/app/shared/overlay'; +import { toConsolePath } from '@deck/app/shared/router'; import { useTranslation } from 'react-i18next'; import i18n from '@deck/app/shared/i18n'; import { buildBookingLink, BOOKING_BASE_URL } from '@meetpie/entities/booking'; @@ -84,7 +85,7 @@ export function BookingDashboardPage() { @@ -129,7 +130,11 @@ export function BookingDashboardPage() {

{t('dashboard.upcomingBookings')}

-
diff --git a/frontend/meetpie/src/pages/calendar-settings/calendar-settings.page.test.tsx b/frontend/meetpie/src/pages/calendar-settings/calendar-settings.page.test.tsx index 3a0f9ce76..c4d00461e 100644 --- a/frontend/meetpie/src/pages/calendar-settings/calendar-settings.page.test.tsx +++ b/frontend/meetpie/src/pages/calendar-settings/calendar-settings.page.test.tsx @@ -157,7 +157,7 @@ describe('CalendarSettingsPage', () => { 'calendar-settings', expect.any(String), 'Calendar', - '/calendar-settings/manage/' + '/console/calendar-integrations/manage/' ); }); diff --git a/frontend/meetpie/src/pages/calendar-settings/calendar-settings.page.tsx b/frontend/meetpie/src/pages/calendar-settings/calendar-settings.page.tsx index d2ac0ad3e..4e91a8544 100644 --- a/frontend/meetpie/src/pages/calendar-settings/calendar-settings.page.tsx +++ b/frontend/meetpie/src/pages/calendar-settings/calendar-settings.page.tsx @@ -14,6 +14,7 @@ import { useUrlParamAction } from '@deck/app/shared/hooks'; import { useModal, useOverlayController, useToast } from '@deck/app/shared/overlay'; import { Spinner } from '@deck/app/shared/spinner'; import { Icon } from '@deck/app/shared/icon'; +import { toConsolePath } from '@deck/app/shared/router'; import { IconBrandGoogle, IconBrandWindows } from '@tabler/icons-react'; import { useCalendarSettings } from './use-calendar-settings'; import { buildProviderCards } from './calendar-settings.model'; @@ -85,7 +86,12 @@ export function CalendarSettingsPage() { const openManagePage = useCallback(() => { const currentTabId = activeTabId.get() ?? 'calendar-settings'; - openTab(currentTabId, pageMeta.title, 'Calendar', '/calendar-settings/manage/'); + openTab( + currentTabId, + pageMeta.title, + 'Calendar', + toConsolePath('/calendar-integrations/manage/') + ); }, [pageMeta.title]); return ( @@ -142,6 +148,7 @@ export function CalendarSettingsPage() { return ( {card.label} @@ -157,7 +164,12 @@ export function CalendarSettingsPage() { titleIcon={titleIcon} actions={ card.connectDisabled || !canWrite ? null : ( - diff --git a/frontend/meetpie/src/pages/mcp-apps-dev/mcp-apps-dev.page.tsx b/frontend/meetpie/src/pages/mcp-apps-dev/mcp-apps-dev.page.tsx index fc2f2efbe..ad5e538eb 100644 --- a/frontend/meetpie/src/pages/mcp-apps-dev/mcp-apps-dev.page.tsx +++ b/frontend/meetpie/src/pages/mcp-apps-dev/mcp-apps-dev.page.tsx @@ -1,4 +1,5 @@ import { type PropsWithChildren, useEffect, useState } from 'react'; +import { toConsolePath } from '@deck/app/shared/router'; import { AppShell } from '../../mcp-apps/shared/ui/app-shell'; import { ensureMcpThemeStyles } from '../../mcp-apps/shared/lib/theme'; import { CalendarWidget } from '../../mcp-apps/calendar/ui/calendar-widget'; @@ -29,7 +30,7 @@ const calendarScenarios = { }, needsConnection: { message: '캘린더 연결이 필요합니다.', - connectUrl: '/calendar-integrations/', + connectUrl: toConsolePath('/calendar-integrations/'), providerOptions: ['google', 'microsoft'], }, suggestedSlots: { diff --git a/frontend/meetpie/src/test/booking-constants.ts b/frontend/meetpie/src/test/booking-constants.ts index 50834f361..f1291ef69 100644 --- a/frontend/meetpie/src/test/booking-constants.ts +++ b/frontend/meetpie/src/test/booking-constants.ts @@ -1,2 +1,8 @@ -export const BOOKING_HANDLE = 'deck-admin'; +export const BOOKING_HANDLES = { + admin: 'deck-platform-admin', + manager: 'deck-admin', + user: 'deck-user', +} as const; + +export const BOOKING_HANDLE = BOOKING_HANDLES.user; export const BOOKING_EVENT_TYPE_SLUG = '30min'; diff --git a/frontend/tests/.auth/.gitkeep b/frontend/tests/.auth/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/tests/account/branding-serving.spec.ts b/frontend/tests/account/branding-serving.spec.ts index b900589ee..2f26e7ff2 100644 --- a/frontend/tests/account/branding-serving.spec.ts +++ b/frontend/tests/account/branding-serving.spec.ts @@ -18,12 +18,27 @@ const defaultSettings = { logoPublicUrl: null, }; -const SETTING_URL = '/account/setting'; +const BRANDING_URL = '/settings/platform/branding'; async function setupSettingPage( page: Page, settings: Partial = {} ): Promise { + await page.route('**/api/v1/auth/me', (route) => + route.fulfill( + json({ + username: 'owner', + name: 'Owner User', + email: 'owner@example.com', + isOwner: true, + hasInternalIdentity: true, + roleIds: ['role-admin'], + roles: [{ id: 'role-admin', label: 'Admin' }], + permissions: ['USER_MANAGEMENT_READ'], + }) + ) + ); + await page.route('**/api/v1/account/me', (route) => route.fulfill( json({ @@ -35,7 +50,7 @@ async function setupSettingPage( ) ); - await page.route('**/api/v1/system-settings', async (route) => { + await page.route('**/api/v1/platform-settings', async (route) => { if (route.request().method() === 'GET') { await route.fulfill(json({ ...defaultSettings, ...settings })); return; @@ -46,25 +61,13 @@ async function setupSettingPage( } test.describe('브랜딩 로고 서빙 시나리오', { tag: '@account' }, () => { - test('업로드 후 backend serving URL로 미리보기가 표시되고 공용 로고가 다시 로드된다', async ({ - page, - }) => { + test('업로드 후 backend serving URL로 미리보기가 표시된다', async ({ page }) => { let uploadPayload: Record = {}; - let logoLightRequestCount = 0; let uploadedLogoRequestCount = 0; await setupSettingPage(page); - await page.route('**/logo-light.svg*', async (route) => { - logoLightRequestCount += 1; - await route.fulfill({ - status: 200, - contentType: 'image/svg+xml', - body: '', - }); - }); - - await page.route('**/api/v1/system-settings/logo/HORIZONTAL', async (route) => { + await page.route('**/api/v1/platform-settings/logo/HORIZONTAL', async (route) => { uploadedLogoRequestCount += 1; await route.fulfill({ status: 200, @@ -73,7 +76,7 @@ test.describe('브랜딩 로고 서빙 시나리오', { tag: '@account' }, () => }); }); - await page.route('**/api/v1/system-settings/logo/HORIZONTAL/upload', async (route) => { + await page.route('**/api/v1/platform-settings/logo/HORIZONTAL/upload', async (route) => { if (route.request().method() !== 'POST') { await route.continue(); return; @@ -82,19 +85,13 @@ test.describe('브랜딩 로고 서빙 시나리오', { tag: '@account' }, () => uploadPayload = route.request().postDataJSON() as Record; await route.fulfill( json({ - logoHorizontalUrl: '/api/v1/system-settings/logo/HORIZONTAL', + logoHorizontalUrl: '/api/v1/platform-settings/logo/HORIZONTAL', }) ); }); - await page.goto(SETTING_URL); - await page.getByRole('tab', { name: 'Branding' }).click(); - - const footerLogo = page.locator('img.brand-logo').first(); - await expect(footerLogo).toHaveAttribute('src', /\/logo-light\.svg/); - const beforeCacheBustedReload = logoLightRequestCount; + await page.goto(BRANDING_URL); - await page.getByRole('combobox').first().selectOption('upload'); await page .locator('input[type="file"]') .first() @@ -110,12 +107,9 @@ test.describe('브랜딩 로고 서빙 시나리오', { tag: '@account' }, () => await expect(page.getByText('Logo uploaded successfully')).toBeVisible(); await expect(page.getByAltText('Logo (Light Theme)')).toHaveAttribute( 'src', - /\/api\/v1\/system-settings\/logo\/HORIZONTAL/ + /\/api\/v1\/platform-settings\/logo\/HORIZONTAL/ ); await expect.poll(() => uploadedLogoRequestCount).toBeGreaterThan(0); - - await expect(footerLogo).toHaveAttribute('src', /\/logo-light\.svg\?v=\d+/); - await expect.poll(() => logoLightRequestCount).toBeGreaterThan(beforeCacheBustedReload); }); test('URL 등록 모드에서는 backend 응답 URL을 그대로 사용한다', async ({ page }) => { @@ -123,7 +117,7 @@ test.describe('브랜딩 로고 서빙 시나리오', { tag: '@account' }, () => await setupSettingPage(page); - await page.route('**/api/v1/system-settings/logo/HORIZONTAL/url', async (route) => { + await page.route('**/api/v1/platform-settings/logo/HORIZONTAL/url', async (route) => { if (route.request().method() !== 'PUT') { await route.continue(); return; @@ -137,12 +131,17 @@ test.describe('브랜딩 로고 서빙 시나리오', { tag: '@account' }, () => ); }); - await page.goto(SETTING_URL); - await page.getByRole('tab', { name: 'Branding' }).click(); + await page.goto(BRANDING_URL); - const lightLogoUrlInput = page.locator('input[type="url"]').first(); - await lightLogoUrlInput.fill('https://cdn.example.com/logo-light-updated.svg'); - await page.getByRole('button', { name: 'Set' }).first().click(); + const lightLogoSection = page.locator('section').filter({ + has: page.getByRole('heading', { name: 'Logo (Light Theme)' }), + }); + await lightLogoSection.getByRole('combobox').click(); + await page.getByRole('option', { name: 'External URL' }).click(); + await lightLogoSection + .getByPlaceholder('https://example.com/logo-light.svg') + .fill('https://cdn.example.com/logo-light-updated.svg'); + await lightLogoSection.getByRole('button', { name: 'Set' }).click(); await expect .poll(() => setUrlPayload.url) diff --git a/frontend/tests/account/setting.spec.ts b/frontend/tests/account/setting.spec.ts index 4cbb929bf..e7810824b 100644 --- a/frontend/tests/account/setting.spec.ts +++ b/frontend/tests/account/setting.spec.ts @@ -47,6 +47,21 @@ interface SetupSettingPageOptions { async function setupSettingPage(page: Page, options: SetupSettingPageOptions = {}) { const { isOwner = true, settings = defaultSettings, onUpdateSettings } = options; + await page.route('**/api/v1/auth/me', (route) => + route.fulfill( + json({ + username: 'owner', + name: 'Owner User', + email: 'owner@example.com', + isOwner, + hasInternalIdentity: true, + roleIds: ['role-admin'], + roles: [{ id: 'role-admin', label: 'Admin' }], + permissions: ['USER_MANAGEMENT_READ'], + }) + ) + ); + await page.route('**/api/v1/account/me', (route) => route.fulfill( json({ @@ -58,20 +73,18 @@ async function setupSettingPage(page: Page, options: SetupSettingPageOptions = { ) ); - await page.route('**/api/v1/system-settings', async (route) => { - const method = route.request().method(); - if (method === 'GET') { - await route.fulfill(json(settings)); - return; - } + await page.route('**/api/v1/platform-settings', async (route) => { + await route.fulfill(json(settings)); + }); - if (method === 'PUT') { - onUpdateSettings?.(route.request().postDataJSON() as Record); - await route.fulfill(json({ success: true })); + await page.route('**/api/v1/platform-settings/general', async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); return; } - await route.continue(); + onUpdateSettings?.(route.request().postDataJSON() as Record); + await route.fulfill(json({ success: true })); }); } @@ -90,7 +103,7 @@ async function setupAuthRoutes(page: Page, options: SetupAuthRoutesOptions = {}) onUpdateOAuthProviders, } = options; - await page.route('**/api/v1/system-settings/auth', async (route) => { + await page.route('**/api/v1/platform-settings/auth', async (route) => { const method = route.request().method(); if (method === 'GET') { await route.fulfill(json(authSettings)); @@ -106,7 +119,7 @@ async function setupAuthRoutes(page: Page, options: SetupAuthRoutesOptions = {}) await route.continue(); }); - await page.route('**/api/v1/oauth-providers', async (route) => { + await page.route('**/api/v1/platform-settings/auth/providers', async (route) => { const method = route.request().method(); if (method === 'GET') { await route.fulfill(json(oauthProviders)); @@ -124,11 +137,23 @@ async function setupAuthRoutes(page: Page, options: SetupAuthRoutesOptions = {}) } async function gotoSettingPage(page: Page) { - await page.goto('/account/setting'); + await page.goto('/settings/platform/general'); } async function gotoTab(page: Page, tabName: 'General' | 'Branding' | 'Auth') { - await page.getByRole('tab', { name: tabName }).click(); + const leafPath = + tabName === 'General' + ? '/settings/platform/general' + : tabName === 'Branding' + ? '/settings/platform/branding' + : '/settings/platform/authentication'; + await page.goto(leafPath); +} + +function getSettingsToggleRow(page: Page, title: string) { + return page.getByTestId('settings-row').filter({ + has: page.getByText(title, { exact: true }), + }); } // ───────────────────────────────────────────── @@ -136,14 +161,14 @@ async function gotoTab(page: Page, tabName: 'General' | 'Branding' | 'Auth') { // ───────────────────────────────────────────── test.describe('계정 설정 핵심 시나리오', { tag: '@account' }, () => { - test('Owner가 아니면 Setting 페이지 접근이 차단된다', async ({ page }) => { + test('Owner가 아니면 Platform 그룹이 숨겨지고 Account profile로 fallback 된다', async ({ page }) => { await setupSettingPage(page, { isOwner: false }); await gotoSettingPage(page); - await expect(page.getByText('Access Denied')).toBeVisible(); - await expect(page.getByText('Only the Owner can access this page.')).toBeVisible(); - await expect(page.getByRole('tab', { name: 'General' })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible(); + await expect(page.getByText('Platform')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'General' })).toHaveCount(0); }); test('General 탭에서 브랜드명을 수정하고 저장하면 설정 API가 호출된다', async ({ page }) => { @@ -163,7 +188,7 @@ test.describe('계정 설정 핵심 시나리오', { tag: '@account' }, () => { await expect.poll(() => updateBody.brandName).toBe('Deck Enterprise'); - expect(updateBody).toEqual({ + expect(updateBody).toMatchObject({ brandName: 'Deck Enterprise', }); await expect(page.getByText('Saved')).toBeVisible(); @@ -186,8 +211,7 @@ test.describe('계정 설정 핵심 시나리오', { tag: '@account' }, () => { await gotoSettingPage(page); await gotoTab(page, 'Auth'); - const internalLoginToggle = page.getByText('Internal Login').locator('..').getByRole('switch'); - await internalLoginToggle.uncheck(); + await getSettingsToggleRow(page, 'Internal Login').getByRole('switch').click(); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('At least one login method must be enabled')).toBeVisible(); @@ -212,7 +236,7 @@ test.describe('계정 설정 핵심 시나리오', { tag: '@account' }, () => { await gotoSettingPage(page); await gotoTab(page, 'Auth'); - await page.getByText('Google').locator('..').getByRole('switch').check(); + await getSettingsToggleRow(page, 'Google').getByRole('switch').click(); await page.getByPlaceholder('Client ID').fill('google-client-id'); await page.getByPlaceholder('Enter client secret').fill('google-client-secret'); await page.getByRole('button', { name: 'Save' }).click(); @@ -251,11 +275,10 @@ test.describe('계정 설정 핵심 시나리오', { tag: '@account' }, () => { await gotoSettingPage(page); await gotoTab(page, 'Auth'); - const aipToggle = page.getByLabel('AIP').getByRole('switch'); - await aipToggle.check(); + await getSettingsToggleRow(page, 'AIP').getByRole('switch').click(); await page.getByPlaceholder('https://your-duplo-server.com').fill('https://duplo.example.com'); - await page.getByPlaceholder('Client ID').last().fill('deck-client-id'); - await page.getByPlaceholder('Enter client secret').last().fill('deck-client-secret'); + await page.getByPlaceholder('Client ID').fill('deck-client-id'); + await page.getByPlaceholder('Enter client secret').fill('deck-client-secret'); await page.getByRole('button', { name: 'Save' }).click(); await expect.poll(() => Boolean(updateOAuthBody.providers)).toBe(true); @@ -279,7 +302,7 @@ test.describe('계정 설정 핵심 시나리오', { tag: '@account' }, () => { await setupSettingPage(page); - await page.route('**/api/v1/system-settings/logo/HORIZONTAL/url', async (route) => { + await page.route('**/api/v1/platform-settings/logo/HORIZONTAL/url', async (route) => { if (route.request().method() !== 'PUT') { await route.continue(); return; @@ -296,9 +319,16 @@ test.describe('계정 설정 핵심 시나리오', { tag: '@account' }, () => { await gotoSettingPage(page); await gotoTab(page, 'Branding'); - const lightLogoUrlInput = page.locator('input[type="url"]').first(); - await lightLogoUrlInput.fill('https://cdn.example.com/logo-light-updated.svg'); - await page.getByRole('button', { name: 'Set' }).first().click(); + const lightLogoSection = page.locator('section').filter({ + has: page.getByRole('heading', { name: 'Logo (Light Theme)' }), + }); + + await lightLogoSection.getByRole('combobox').click(); + await page.getByRole('option', { name: 'External URL' }).click(); + await lightLogoSection + .getByPlaceholder('https://example.com/logo-light.svg') + .fill('https://cdn.example.com/logo-light-updated.svg'); + await lightLogoSection.getByRole('button', { name: 'Set' }).click(); await expect.poll(() => logoPayload.url).toBe('https://cdn.example.com/logo-light-updated.svg'); diff --git a/frontend/tests/booking/booking-bookings.spec.ts b/frontend/tests/booking/booking-bookings.spec.ts index 6133d518f..33c65fc6c 100644 --- a/frontend/tests/booking/booking-bookings.spec.ts +++ b/frontend/tests/booking/booking-bookings.spec.ts @@ -55,7 +55,7 @@ async function setupBookingsPage(page: Page) { id: "tab-booking-bookings", title: "Bookings", icon: "Calendar", - url: "/booking/bookings/", + url: "/console/booking/bookings/", }, programs: [], menuTree: [], diff --git a/frontend/tests/booking/booking-event-types.spec.ts b/frontend/tests/booking/booking-event-types.spec.ts index 314043108..06f3efef2 100644 --- a/frontend/tests/booking/booking-event-types.spec.ts +++ b/frontend/tests/booking/booking-event-types.spec.ts @@ -65,7 +65,7 @@ async function setupEventTypesPage(page: Page) { id: "tab-booking-event-types", title: "Event Types", icon: "Calendar", - url: "/booking/event-types/", + url: "/console/booking/event-types/", }, programs: [], menuTree: [], diff --git a/frontend/tests/booking/booking-profile.spec.ts b/frontend/tests/booking/booking-profile.spec.ts index c8a838de7..4ba72c88d 100644 --- a/frontend/tests/booking/booking-profile.spec.ts +++ b/frontend/tests/booking/booking-profile.spec.ts @@ -22,7 +22,7 @@ async function setupProfilePage(page: Page) { id: "tab-booking-profile", title: "Booking Profile", icon: "Calendar", - url: "/booking/profile/", + url: "/console/booking/profile/", }, programs: [], menuTree: [], diff --git a/frontend/tests/booking/booking-schedules.spec.ts b/frontend/tests/booking/booking-schedules.spec.ts index 5e8e42de9..e58ce253c 100644 --- a/frontend/tests/booking/booking-schedules.spec.ts +++ b/frontend/tests/booking/booking-schedules.spec.ts @@ -56,7 +56,7 @@ async function setupSchedulesPage(page: Page) { id: "tab-booking-schedules", title: "Schedules", icon: "Clock", - url: "/booking/schedules/", + url: "/console/booking/schedules/", }, programs: [], menuTree: [], diff --git a/frontend/tests/deskpie/accessibility-smoke.spec.ts b/frontend/tests/deskpie/accessibility-smoke.spec.ts index ee89f740e..4248568fb 100644 --- a/frontend/tests/deskpie/accessibility-smoke.spec.ts +++ b/frontend/tests/deskpie/accessibility-smoke.spec.ts @@ -4,7 +4,7 @@ import { runServiceA11ySmoke, type A11ySmokeRoute } from "../helpers/axe"; const deskpieRoutes: A11ySmokeRoute[] = [ { label: "companies", - path: "/companies/", + path: "/console/companies/", ready: async (page) => { await expect( page.getByRole("button", { name: /filters|필터|フィルター/i }), @@ -13,7 +13,7 @@ const deskpieRoutes: A11ySmokeRoute[] = [ }, { label: "contracts", - path: "/contracts/", + path: "/console/contracts/", ready: async (page) => { await expect( page.getByRole("button", { name: /filters|필터|フィルター/i }), diff --git a/frontend/tests/deskpie/crm-country-aware.spec.ts b/frontend/tests/deskpie/crm-country-aware.spec.ts index a89741df6..781200fd0 100644 --- a/frontend/tests/deskpie/crm-country-aware.spec.ts +++ b/frontend/tests/deskpie/crm-country-aware.spec.ts @@ -178,7 +178,7 @@ test.describe('deskpie crm country-aware regression', { tag: '@deskpie' }, () => return request.method() === 'POST' && request.url().includes('/api/v1/crm/companies'); }); - const dialog = await openCreateModal(page, '/companies/'); + const dialog = await openCreateModal(page, '/console/companies/'); await expect(dialog.getByRole('combobox', { name: countryPattern() })).toBeVisible(); await selectCountry(page, dialog, /United States|미국|アメリカ/i); @@ -216,7 +216,7 @@ test.describe('deskpie crm country-aware regression', { tag: '@deskpie' }, () => return request.method() === 'POST' && request.url().includes('/api/v1/crm/contacts'); }); - const dialog = await openCreateModal(page, '/contacts/'); + const dialog = await openCreateModal(page, '/console/contacts/'); await expect(dialog.getByRole('combobox', { name: countryPattern() })).toBeVisible(); await expect(dialog.getByTestId('contact-postcode-search')).toBeVisible(); @@ -249,7 +249,7 @@ test.describe('deskpie crm country-aware regression', { tag: '@deskpie' }, () => }) => { await mockContactFieldConfig(page); - const dialog = await openCreateModal(page, '/contracting-parties/'); + const dialog = await openCreateModal(page, '/console/contracting-parties/'); await expect(dialog.getByRole('combobox', { name: countryPattern() })).toBeVisible(); const selectedCompanyName = await selectFirstActualCompany(page, dialog); diff --git a/frontend/tests/deskpie/crm-filters-smoke.spec.ts b/frontend/tests/deskpie/crm-filters-smoke.spec.ts index f4ce74296..8026b1da1 100644 --- a/frontend/tests/deskpie/crm-filters-smoke.spec.ts +++ b/frontend/tests/deskpie/crm-filters-smoke.spec.ts @@ -9,37 +9,37 @@ type FilterSmokeCase = { }; const filterSmokeCases: FilterSmokeCase[] = [ - { label: "Companies", path: "/companies/", categories: ["Role"] }, + { label: "Companies", path: "/console/companies/", categories: ["Role"] }, { label: "Contracting Parties", - path: "/contracting-parties/", + path: "/console/contracting-parties/", categories: ["Company", "Type"], }, - { label: "Contacts", path: "/contacts/", categories: ["Company"] }, - { label: "Contracts", path: "/contracts/", categories: ["Customer Company"] }, + { label: "Contacts", path: "/console/contacts/", categories: ["Company"] }, + { label: "Contracts", path: "/console/contracts/", categories: ["Customer Company"] }, { label: "Deals", - path: "/deals/", + path: "/console/deals/", categories: ["Company", "All Stages", "All Priorities"], viewMode: true, }, { label: "Leads", - path: "/leads/", + path: "/console/leads/", categories: ["Company", "All Stages", "All Sources"], viewMode: true, }, { label: "License Requests", - path: "/license-requests/", + path: "/console/license-requests/", categories: ["Contracting Party", "Customer Company", "Status"], }, { label: "Licenses", - path: "/licenses/", + path: "/console/licenses/", categories: ["Contracting Party", "Customer Company"], }, - { label: "Quotes", path: "/quotes/", categories: ["Deal"] }, + { label: "Quotes", path: "/console/quotes/", categories: ["Deal"] }, ]; function shouldIgnoreConsoleError(text: string) { diff --git a/frontend/tests/features/command-palette.spec.ts b/frontend/tests/features/command-palette.spec.ts index 4360169d5..b6bf56db3 100644 --- a/frontend/tests/features/command-palette.spec.ts +++ b/frontend/tests/features/command-palette.spec.ts @@ -12,7 +12,7 @@ const tab: Tab = { id: "notification-channels", title: "Channels", icon: "Bell", - url: "/system/notification-channels", + url: "/console/notification-channels", }; const programs = [ diff --git a/frontend/tests/helpers/auth-state.ts b/frontend/tests/helpers/auth-state.ts index 29e3d57a3..aee310f9c 100644 --- a/frontend/tests/helpers/auth-state.ts +++ b/frontend/tests/helpers/auth-state.ts @@ -1,35 +1,40 @@ +export const SIGNED_IN_PATHS = [ + "/", + "/dashboard/", + "/console/dashboard/", + "/console/my-workspaces/", +] as const; + +const SIGNED_IN_PATH_SET = new Set(SIGNED_IN_PATHS); + export function normalizePath(path: string): string { return path.endsWith("/") ? path : `${path}/`; } export function isAllowedSignedInPath(pathname: string): boolean { - return ( - pathname === "/" || - pathname === "/dashboard/" || - pathname === "/my-workspaces/" - ); + return SIGNED_IN_PATH_SET.has(normalizePath(pathname)); } -export function isExpectedSignedInOrigin( - url: URL, - expectedOrigin: string, -): boolean { +export function getExpectedSignedInOrigins(expectedOrigin: string): string[] { const expected = new URL(expectedOrigin); - if (url.origin === expected.origin) { - return true; - } - + const origins = [expected.origin]; const backendPort = expected.port.length === 4 && expected.port.startsWith("4") ? `8${expected.port.slice(1)}` : null; - return ( - backendPort !== null && - url.hostname === expected.hostname && - url.port === backendPort && - url.protocol === "http:" - ); + if (backendPort !== null) { + origins.push(`http://${expected.hostname}:${backendPort}`); + } + + return origins; +} + +export function isExpectedSignedInOrigin( + url: URL, + expectedOrigin: string, +): boolean { + return getExpectedSignedInOrigins(expectedOrigin).includes(url.origin); } export function isSignedInLocation( diff --git a/frontend/tests/helpers/auth.ts b/frontend/tests/helpers/auth.ts index b68bf13f7..d99b34f90 100644 --- a/frontend/tests/helpers/auth.ts +++ b/frontend/tests/helpers/auth.ts @@ -1,5 +1,9 @@ import type { Page } from "@playwright/test"; -import { isSignedInLocation } from "./auth-state"; +import { + getExpectedSignedInOrigins, + isSignedInLocation, + SIGNED_IN_PATHS, +} from "./auth-state"; const DEFAULT_SEED_PASSWORD = process.env.MANUAL_SEED_PASSWORD ?? "Deck@dm1n!"; @@ -28,31 +32,19 @@ async function fillLabeledField( async function waitForSignedIn(page: Page, baseURL: string): Promise { await page.waitForFunction( - ({ expectedOrigin }) => { + ({ expectedOrigins, allowedPaths }) => { const normalizePath = (path: string) => path.endsWith("/") ? path : `${path}/`; const url = new URL(window.location.href); - const pathname = normalizePath(url.pathname); - const expected = new URL(expectedOrigin); - const backendPort = - expected.port.length === 4 && expected.port.startsWith("4") - ? `8${expected.port.slice(1)}` - : null; - const originMatched = - url.origin === expected.origin || - (backendPort !== null && - url.hostname === expected.hostname && - url.port === backendPort && - url.protocol === "http:"); - return ( - originMatched && - (pathname === "/" || - pathname === "/dashboard/" || - pathname === "/my-workspaces/") + expectedOrigins.includes(url.origin) && + allowedPaths.includes(normalizePath(url.pathname)) ); }, - { expectedOrigin: new URL(baseURL).origin }, + { + expectedOrigins: getExpectedSignedInOrigins(new URL(baseURL).origin), + allowedPaths: [...SIGNED_IN_PATHS], + }, { timeout: 30_000 }, ); } diff --git a/frontend/tests/helpers/manual-global-setup.ts b/frontend/tests/helpers/manual-global-setup.ts index cd745a0ca..cc95f0745 100644 --- a/frontend/tests/helpers/manual-global-setup.ts +++ b/frontend/tests/helpers/manual-global-setup.ts @@ -13,6 +13,7 @@ import { type APIRequestContext, type FullConfig, } from "@playwright/test"; +import { BOOKING_HANDLES } from "../../meetpie/src/test/booking-constants"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -25,19 +26,17 @@ const DESKPIE_URL = const SEED_PASSWORD = "DeckSeed!45"; -export type Account = { role: "manager" | "user"; username: string; password: string }; +export type ManualRole = "admin" | "manager" | "user"; + +export type Account = { role: ManualRole; username: string; password: string }; export const ACCOUNTS: Account[] = [ + { role: "admin", username: "admin", password: "Deck@dm1n!" }, { role: "manager", username: "manager", password: SEED_PASSWORD }, { role: "user", username: "user", password: SEED_PASSWORD }, ]; const BOOKING_EVENT_TYPE_SLUG = "30min"; -const BOOKING_HANDLES: Record = { - manager: "deck-admin", - user: "deck-user", -}; - const AUTH_DIR = path.join(__dirname, "..", ".auth"); // ───────────────────────────────────────────── @@ -46,7 +45,7 @@ const AUTH_DIR = path.join(__dirname, "..", ".auth"); export function authPath( service: "app" | "meetpie" | "deskpie", - role: "manager" | "user", + role: ManualRole, ): string { return path.join(AUTH_DIR, `${service}-${role}.json`); } @@ -249,135 +248,69 @@ async function seedMeetpieData(request: APIRequestContext) { } // ───────────────────────────────────────────── -// Meetpie 예약 프로필 핸들 UI 기반 설정 +// Meetpie 예약 프로필 핸들 API 기반 설정 // ───────────────────────────────────────────── +type BookingProfileSummary = { + handle: string; + handleChangedAt: string | null; +}; + async function ensureBookingProfileHandle( - authFilePath: string, + request: APIRequestContext, handle: string, ) { - const browser = await chromium.launch(); - const context = await browser.newContext({ - ignoreHTTPSErrors: true, - storageState: authFilePath, - }); - const page = await context.newPage(); - const bookingProfileUrl = `${MEETPIE_URL}/booking/settings/?tab=profile`; - const expectedBookingLink = `${MEETPIE_URL}/book/${handle}/`; - - page.on("response", async (response) => { - if (response.url().includes("/booking-profile")) { - const body = await response.text().catch(() => ""); - console.log( - `[manual-setup] API ${response.url()} → ${response.status()}: ${body.slice(0, 200)}`, - ); - } - }); - - try { - await page.goto(bookingProfileUrl, { waitUntil: "load", timeout: 15000 }); - const changeBtn = page.getByRole("button", { name: "Change Handle" }); - const handleInput = page - .locator( - 'input[placeholder="yourhandle"], input[placeholder="new-handle"]', - ) - .first(); - - await Promise.any([ - changeBtn.waitFor({ state: "visible", timeout: 8000 }), - handleInput.waitFor({ state: "visible", timeout: 8000 }), - page - .locator(`text=${expectedBookingLink}`) - .first() - .waitFor({ state: "visible", timeout: 8000 }), - ]).catch(() => {}); - - const pageText = await page.locator("body").innerText(); - if (pageText.includes(expectedBookingLink)) { - console.log( - `[manual-setup] Booking profile handle already ${handle}`, - ); - return; - } - - const hasChangeButton = await changeBtn - .isVisible({ timeout: 2000 }) - .catch(() => false); - if (hasChangeButton) { - // 이미 원하는 handle이 아닌 다른 handle이 설정된 경우, 변경을 시도한다. - await changeBtn.click(); - await page.waitForTimeout(500); - } - - await handleInput.waitFor({ timeout: 5000 }); - await handleInput.click({ clickCount: 3 }); - await handleInput.pressSequentially(handle); - await page.waitForTimeout(300); - - const saveButton = page - .locator('button:has-text("Save"), button:has-text("Confirm")') - .last(); - await saveButton.waitFor({ state: "visible", timeout: 3000 }); - await saveButton.click(); - await page.waitForTimeout(2000); - - await page.reload({ waitUntil: "load", timeout: 15000 }); - const reloadedHandle = page.locator("p.font-medium").first(); - const reloadedHandleVisible = await reloadedHandle - .isVisible({ timeout: 3000 }) - .catch(() => false); - if (reloadedHandleVisible) { - const savedHandle = await reloadedHandle.textContent().catch(() => ""); - console.log( - `[manual-setup] Booking profile handle confirmed: "${savedHandle?.trim()}"`, - ); - return; - } - const failedInput = page - .locator( - 'input[placeholder="yourhandle"], input[placeholder="new-handle"]', - ) - .first(); - const failedVisible = await failedInput - .isVisible({ timeout: 1000 }) - .catch(() => false); - if (failedVisible) { + const profileResponse = await request.get( + `${MEETPIE_URL}/api/v1/booking-profile`, + ); + if (profileResponse.status() === 204) { + const created = await request.post( + `${MEETPIE_URL}/api/v1/booking-profile`, + { + data: { handle }, + }, + ); + if (!created.ok()) { throw new Error( - `[manual-setup] Booking profile handle save failed: page still shows create form after save`, + `[manual-setup] Failed to create booking profile handle ${handle}: ${created.status()}`, ); } + console.log(`[manual-setup] Booking profile handle created: ${handle}`); + return; + } + + if (!profileResponse.ok()) { throw new Error( - `[manual-setup] Booking profile handle: unexpected page state after save`, + `[manual-setup] Failed to load booking profile: ${profileResponse.status()}`, ); - } finally { - await browser.close(); } -} -// ───────────────────────────────────────────── -// 앱 초기화 (localStorage 워크스페이스 설정) -// ───────────────────────────────────────────── + const profile = (await profileResponse.json()) as BookingProfileSummary; + if (profile.handle === handle) { + console.log(`[manual-setup] Booking profile handle already ${handle}`); + return; + } -async function initAppWorkspace(serviceUrl: string, authFilePath: string) { - const browser = await chromium.launch(); - const context = await browser.newContext({ - ignoreHTTPSErrors: true, - storageState: authFilePath, - }); - const page = await context.newPage(); - try { - await page.goto(serviceUrl, { waitUntil: "load", timeout: 15000 }); - await context.storageState({ path: authFilePath }); - console.log(`[manual-setup] App workspace initialized for ${serviceUrl}`); - } catch (e) { - console.log( - `[manual-setup] App workspace init failed for ${serviceUrl}: ${e}`, + const changed = await request.patch( + `${MEETPIE_URL}/api/v1/booking-profile/handle`, + { + data: { handle }, + }, + ); + if (!changed.ok()) { + throw new Error( + `[manual-setup] Failed to change booking profile handle to ${handle}: ${changed.status()}`, ); - } finally { - await browser.close(); } + + console.log( + `[manual-setup] Booking profile handle changed: ${profile.handle} -> ${handle}`, + ); } +// ───────────────────────────────────────────── +// 앱 초기화 +// ───────────────────────────────────────────── // ───────────────────────────────────────────── // 서비스별 셋업 // ───────────────────────────────────────────── @@ -386,29 +319,33 @@ function ensureAuthDir() { fs.mkdirSync(AUTH_DIR, { recursive: true }); } -export async function setupAppAuth(role: "manager" | "user") { +export async function setupAppAuth(role: ManualRole) { ensureAuthDir(); const account = ACCOUNTS.find((a) => a.role === role)!; await loginToService(APP_URL, authPath("app", role), account); } -export async function setupMeetpieAuth(role: "manager" | "user") { +export async function setupMeetpieAuth(role: ManualRole) { ensureAuthDir(); const account = ACCOUNTS.find((a) => a.role === role)!; const meetpieAuth = authPath("meetpie", role); - const request = await loginToService(MEETPIE_URL, meetpieAuth, account, 30000); + const request = await loginToService( + MEETPIE_URL, + meetpieAuth, + account, + 30000, + ); // seed 데이터 생성 (booking profile, schedule, event type 등) await seedMeetpieData(request); - await ensureBookingProfileHandle(meetpieAuth, BOOKING_HANDLES[role]); + await ensureBookingProfileHandle(request, BOOKING_HANDLES[role]); } -export async function setupDeskpieAuth(role: "manager" | "user") { +export async function setupDeskpieAuth(role: ManualRole) { ensureAuthDir(); const account = ACCOUNTS.find((a) => a.role === role)!; const deskpieAuth = authPath("deskpie", role); await loginToService(DESKPIE_URL, deskpieAuth, account, 30000); - await initAppWorkspace(DESKPIE_URL, deskpieAuth); } // ───────────────────────────────────────────── diff --git a/frontend/tests/helpers/menu-smoke.ts b/frontend/tests/helpers/menu-smoke.ts index e0f79feba..3b0b20968 100644 --- a/frontend/tests/helpers/menu-smoke.ts +++ b/frontend/tests/helpers/menu-smoke.ts @@ -2,9 +2,9 @@ import { expect, test, type Page } from "@playwright/test"; import type { Program } from "../../app/src/entities/menu/types"; import type { WorkspacePolicy, - SystemSettings, -} from "../../app/src/entities/system-settings"; -import { isWorkspaceAccessible } from "../../app/src/entities/system-settings/workspace-access"; + PlatformSettings, +} from "../../app/src/entities/platform-settings"; +import { isWorkspaceAccessible } from "../../app/src/entities/platform-settings/workspace-access"; import { hasAnyPermissionFrom } from "../../app/src/features/auth/permissions"; import type { CurrentUser } from "../../app/src/entities/user/types"; import type { MyWorkspace } from "../../app/src/entities/workspace/types"; @@ -20,6 +20,7 @@ type ApiMenu = { name: string; icon?: string; program?: string | null; + managementType?: "USER_MANAGED" | "PLATFORM_MANAGED"; children: ApiMenu[]; }; @@ -167,10 +168,10 @@ async function loadMenuCases(page: Page, baseURL: string) { baseURL, "/api/v1/menus/programs", ); - const systemSettings = await getJson( + const platformSettings = await getJson( page, baseURL, - "/api/v1/system-settings", + "/api/v1/platform-settings", ); const workspaces = (await getJsonOrNull( @@ -183,7 +184,7 @@ async function loadMenuCases(page: Page, baseURL: string) { programs.map((program) => [program.code, program]), ); const currentPermissions = currentUser.permissions; - const workspacePolicy = systemSettings.workspacePolicy ?? null; + const workspacePolicy = platformSettings.workspacePolicy ?? null; const savedWorkspaceId = await readCurrentWorkspaceId(page, baseURL); const visibleWorkspaces = filterWorkspacesByPolicy( workspaces, diff --git a/frontend/tests/helpers/overlays.ts b/frontend/tests/helpers/overlays.ts new file mode 100644 index 000000000..20fd4c87f --- /dev/null +++ b/frontend/tests/helpers/overlays.ts @@ -0,0 +1,16 @@ +import type { Locator, Page } from '@playwright/test'; + +export function dialogByName(page: Page, name: string | RegExp): Locator { + return page + .locator('[role="dialog"], [role="alertdialog"]') + .filter({ has: page.getByRole('heading', { name }) }) + .last(); +} + +export function dialogWithText(page: Page, text: string | RegExp): Locator { + return page.locator('[role="dialog"], [role="alertdialog"]').filter({ hasText: text }).last(); +} + +export function toastLocator(page: Page): Locator { + return page.locator('[data-sonner-toast]').last(); +} diff --git a/frontend/tests/helpers/test-context.ts b/frontend/tests/helpers/test-context.ts index ecaf097e1..9b2ae4607 100644 --- a/frontend/tests/helpers/test-context.ts +++ b/frontend/tests/helpers/test-context.ts @@ -31,7 +31,7 @@ interface Program { permissions: string[]; workspace?: { required: boolean; - managedType: "USER_MANAGED" | "SYSTEM_MANAGED" | null; + managedType: "USER_MANAGED" | "PLATFORM_MANAGED" | null; } | null; } @@ -57,7 +57,7 @@ export interface TestContextOptions { type BootstrapMockOptions = { baseUrl?: string; - systemSettings?: Record; + platformSettings?: Record; myWorkspaces?: unknown[]; }; @@ -77,13 +77,13 @@ export async function mockAppBootstrapApis( page: Page, opts: BootstrapMockOptions = {}, ) { - await page.route("**/api/v1/system-settings", (route) => + await page.route("**/api/v1/platform-settings", (route) => route.fulfill( json({ brandName: "Deck", baseUrl: opts.baseUrl ?? "https://localhost:4011", workspacePolicy: null, - ...opts.systemSettings, + ...opts.platformSettings, }), ), ); diff --git a/frontend/tests/manual/app/account.spec.ts b/frontend/tests/manual/app/account.spec.ts index 355cf7e44..616903097 100644 --- a/frontend/tests/manual/app/account.spec.ts +++ b/frontend/tests/manual/app/account.spec.ts @@ -17,7 +17,7 @@ async function goToAccountSettings( } test.beforeEach(async ({ page }) => { - await loginWithRetry(page); + await loginWithRetry(page, "user"); }); // ───────────────────────────────────────────── diff --git a/frontend/tests/manual/app/chunk-recovery.spec.ts b/frontend/tests/manual/app/chunk-recovery.spec.ts index 145bd3274..294b8243d 100644 --- a/frontend/tests/manual/app/chunk-recovery.spec.ts +++ b/frontend/tests/manual/app/chunk-recovery.spec.ts @@ -1,18 +1,12 @@ import { test, expect } from '@playwright/test' -import { ACCOUNTS } from '../../helpers/manual-global-setup' +import { loginWithRetry } from './helpers/ensure-signed-in' -const manager = ACCOUNTS.find((a) => a.role === 'manager')! - -test.describe('채널 매뉴얼 — stale chunk recovery', { tag: ['@manual', '@manager'] }, () => { +test.describe('채널 매뉴얼 — stale chunk recovery', { tag: ['@manual', '@admin'] }, () => { test('채널 페이지가 열리고 preload error 시 새로고침으로 복구된다', async ({ page }) => { await page.context().clearCookies() - await page.goto('/login') - await page.getByLabel('Username').fill(manager.username) - await page.locator('#password').fill(manager.password) - await page.getByRole('button', { name: 'Login' }).click() - await page.waitForURL('**/') + await loginWithRetry(page, 'admin') - await page.goto('/system/notification-channels/') + await page.goto('/console/notification-channels/') await expect(page.getByRole('heading', { name: /Channels/i })).toBeVisible() await Promise.all([ diff --git a/frontend/tests/manual/app/helpers/ensure-signed-in.ts b/frontend/tests/manual/app/helpers/ensure-signed-in.ts index 9bae01d23..e99497b20 100644 --- a/frontend/tests/manual/app/helpers/ensure-signed-in.ts +++ b/frontend/tests/manual/app/helpers/ensure-signed-in.ts @@ -1,19 +1,28 @@ import type { Page } from "@playwright/test"; import { login } from "../../../helpers/auth"; -import { ACCOUNTS } from "../../../helpers/manual-global-setup"; +import { ACCOUNTS, type ManualRole } from "../../../helpers/manual-global-setup"; -const manager = ACCOUNTS.find((a) => a.role === "manager")!; const APP_BASE_URL = process.env.BASE_URL ?? process.env.MANUAL_BASE_URL ?? "https://deck-app-dev.tpm.querypie.io"; -export async function loginWithRetry(page: Page) { +function getAccount(role: ManualRole) { + const account = ACCOUNTS.find((candidate) => candidate.role === role); + if (!account) { + throw new Error(`[manual-auth] Unknown role: ${role}`); + } + + return account; +} + +export async function loginWithRetry(page: Page, role: ManualRole = "manager") { let lastError: unknown; + const account = getAccount(role); for (let attempt = 0; attempt < 3; attempt += 1) { try { - await login(page, APP_BASE_URL, manager.username, manager.password); + await login(page, APP_BASE_URL, account.username, account.password); return; } catch (error) { lastError = error; @@ -32,7 +41,11 @@ export async function loginWithRetry(page: Page) { throw lastError; } -export async function ensureSignedIn(page: Page, protectedPath: string) { - await loginWithRetry(page); +export async function ensureSignedIn( + page: Page, + protectedPath: string, + role: ManualRole = "manager", +) { + await loginWithRetry(page, role); await page.goto(protectedPath); } diff --git a/frontend/tests/manual/app/logs-pagination.spec.ts b/frontend/tests/manual/app/logs-pagination.spec.ts index 92d7df6c0..fd4f63ba9 100644 --- a/frontend/tests/manual/app/logs-pagination.spec.ts +++ b/frontend/tests/manual/app/logs-pagination.spec.ts @@ -1,15 +1,9 @@ import { test, expect, type Page } from '@playwright/test' -import { ACCOUNTS } from '../../helpers/manual-global-setup' - -const manager = ACCOUNTS.find((a) => a.role === 'manager')! +import { loginWithRetry } from './helpers/ensure-signed-in' async function login(page: Page) { await page.context().clearCookies() - await page.goto('/login') - await page.getByLabel('Username').fill(manager.username) - await page.getByLabel('Password', { exact: true }).fill(manager.password) - await page.getByRole('button', { name: 'Login' }).click() - await page.waitForURL((url) => !url.pathname.includes('/login') && !url.pathname.includes('password-change')) + await loginWithRetry(page, 'admin') } async function verifyPagination(page: Page, path: string, heading: string) { @@ -29,12 +23,12 @@ async function verifyPagination(page: Page, path: string, heading: string) { .toContain('21 - 40') } -test.describe('로그 매뉴얼 — 페이지네이션', { tag: ['@manual', '@manager'] }, () => { +test.describe('로그 매뉴얼 — 페이지네이션', { tag: ['@manual', '@admin'] }, () => { test.beforeEach(async ({ page }) => { await login(page) }) test('API Audits가 2페이지로 이동되어야 한다', async ({ page }) => { - await verifyPagination(page, '/system/api-audit-logs/', 'API Audits') + await verifyPagination(page, '/console/api-audit-logs/', 'API Audits') }) }) diff --git a/frontend/tests/manual/app/settings-visibility.spec.ts b/frontend/tests/manual/app/settings-visibility.spec.ts new file mode 100644 index 000000000..70fafd094 --- /dev/null +++ b/frontend/tests/manual/app/settings-visibility.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test' +import { expectPathname } from '../helpers/navigation' +import { capture } from '../helpers/screenshot' +import { loginWithRetry } from './helpers/ensure-signed-in' + +test.describe('설정 매뉴얼 — Platform Admin Visibility', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + + test('admin은 Account와 Platform 그룹을 함께 본다', async ({ page }) => { + await page.goto('/settings/account/profile/') + await expectPathname(page, '/settings/account/profile/') + await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible({ timeout: 15000 }) + await expect(page.getByRole('link', { name: 'General' })).toBeVisible({ timeout: 15000 }) + await expect(page.getByRole('link', { name: 'Menus' })).toBeVisible({ timeout: 15000 }) + await capture(page, 'app/settings-admin-visibility') + }) +}) + +test.describe('설정 매뉴얼 — Manager Visibility', { tag: ['@manual', '@manager'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'manager') + }) + + test('manager는 Platform 그룹 없이 Account 그룹만 본다', async ({ page }) => { + await page.goto('/settings/account/profile/') + await expectPathname(page, '/settings/account/profile/') + await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible({ timeout: 15000 }) + await expect(page.getByText('Account').first()).toBeVisible({ timeout: 15000 }) + await expect(page.getByText('Platform')).toHaveCount(0) + await expect(page.getByRole('link', { name: 'General' })).toHaveCount(0) + await expect(page.getByRole('link', { name: 'Menus' })).toHaveCount(0) + await capture(page, 'app/settings-manager-visibility') + }) + + test('manager가 platform settings로 직접 접근하면 404를 본다', async ({ page }) => { + await page.goto('/settings/platform/menus/') + await expectPathname(page, '/settings/platform/menus/') + await expect(page.getByText('Page Not Found')).toBeVisible({ timeout: 15000 }) + await capture(page, 'app/settings-manager-platform-denied') + }) +}) + +test.describe('설정 매뉴얼 — User Visibility', { tag: ['@manual', '@user'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'user') + }) + + test('user가 platform settings로 직접 접근하면 404를 본다', async ({ page }) => { + await page.goto('/settings/platform/roles/') + await expectPathname(page, '/settings/platform/roles/') + await expect(page.getByText('Page Not Found')).toBeVisible({ timeout: 15000 }) + await capture(page, 'app/settings-user-platform-denied') + }) +}) diff --git a/frontend/tests/manual/app/shared-registry-contracts.spec.ts b/frontend/tests/manual/app/shared-registry-contracts.spec.ts index 6a83c3037..6ae17f5c9 100644 --- a/frontend/tests/manual/app/shared-registry-contracts.spec.ts +++ b/frontend/tests/manual/app/shared-registry-contracts.spec.ts @@ -31,12 +31,12 @@ function waitForApiOk(page: Page, path: string) { test.describe( "앱 매뉴얼 — shared registry contracts smoke", - { tag: ["@manual", "@smoke", "@manager"] }, + { tag: ["@manual", "@smoke", "@admin"] }, () => { test("알림/활동 로그 화면이 shared registry contract를 통해 정상 로드된다", async ({ page, }) => { - await ensureSignedIn(page, "/system/notification-channels/"); + await ensureSignedIn(page, "/console/notification-channels/", "admin"); const channelsResponse = waitForApiOk( page, @@ -62,7 +62,7 @@ test.describe( page, "/api/v1/notification-rules/event-types", ); - await page.goto("/system/notification-rules/"); + await page.goto("/console/notification-rules/"); await expect( page.getByRole("heading", { name: RULES_HEADING }), ).toBeVisible({ @@ -104,7 +104,7 @@ test.describe( page, "/api/v1/email-templates", ); - await page.goto("/system/email-templates/"); + await page.goto("/console/email-templates/"); await expect( page.getByRole("heading", { name: EMAIL_HEADING }), ).toBeVisible({ @@ -117,7 +117,7 @@ test.describe( page, "/api/v1/slack-templates", ); - await page.goto("/system/slack-templates/"); + await page.goto("/console/slack-templates/"); await expect( page.getByRole("heading", { name: SLACK_HEADING }), ).toBeVisible({ @@ -127,7 +127,7 @@ test.describe( await expect(page.locator(".tabulator-row").first()).toBeVisible(); const activityLogsResponse = waitForApiOk(page, "/api/v1/activity-logs"); - await page.goto("/system/activity-logs/"); + await page.goto("/console/activity-logs/"); await expect( page.getByRole("heading", { name: ACTIVITY_LOGS_HEADING }), ).toBeVisible({ timeout: 15000 }); diff --git a/frontend/tests/manual/app/system.spec.ts b/frontend/tests/manual/app/system.spec.ts index c3d187d12..48e893d8b 100644 --- a/frontend/tests/manual/app/system.spec.ts +++ b/frontend/tests/manual/app/system.spec.ts @@ -1,19 +1,26 @@ import { test, expect } from '@playwright/test' +import { expectPathname } from '../helpers/navigation' import { capture } from '../helpers/screenshot' +import { + ensureExternalWorkspaceFixture, + ensureExternalWorkspaceInviteFixture, + EXTERNAL_WORKSPACE_NAME, +} from '../helpers/external-workspace' +import { resolveWorkspaceIdByName } from '../helpers/workspace' import { loginWithRetry } from './helpers/ensure-signed-in' -test.beforeEach(async ({ page }) => { - await loginWithRetry(page) -}) - // ───────────────────────────────────────────── // 1. Dashboard // ───────────────────────────────────────────── test.describe('시스템 매뉴얼 — Dashboard', { tag: ['@manual', '@smoke', '@manager'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'manager') + }) + test('대시보드 페이지가 표시된다', async ({ page }) => { - await page.goto('/dashboard/') - await expect(page).toHaveURL(/dashboard/, { timeout: 15000 }) + await page.goto('/console/dashboard/') + await expectPathname(page, '/console/dashboard/') await capture(page, 'app/dashboard') }) }) @@ -22,12 +29,16 @@ test.describe('시스템 매뉴얼 — Dashboard', { tag: ['@manual', '@smoke', // 2. Users // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Users', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Users', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('사용자 목록이 표시된다', async ({ page }) => { - await page.goto('/system/users/') - await expect(page).toHaveURL(/system\/users/, { timeout: 15000 }) + await page.goto('/console/users/') + await expectPathname(page, '/console/users/') await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-users') + await capture(page, 'app/console-users') }) }) @@ -35,12 +46,16 @@ test.describe('시스템 매뉴얼 — Users', { tag: ['@manual', '@manager'] }, // 3. Menus // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Menus', { tag: ['@manual', '@manager'] }, () => { +test.describe('설정 매뉴얼 — Menus', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('메뉴 관리 페이지가 표시된다', async ({ page }) => { - await page.goto('/system/menus/') - await expect(page).toHaveURL(/system\/menus/, { timeout: 15000 }) + await page.goto('/settings/platform/menus/') + await expectPathname(page, '/settings/platform/menus/') await expect(page.getByRole('heading', { name: /Menus/i })).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-menus') + await capture(page, 'app/platform-menus') }) }) @@ -48,12 +63,16 @@ test.describe('시스템 매뉴얼 — Menus', { tag: ['@manual', '@manager'] }, // 4. Activity Logs // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Activity Logs', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Activity Logs', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('활동 로그 목록이 표시된다', async ({ page }) => { - await page.goto('/system/activity-logs/') - await expect(page).toHaveURL(/system\/activity-logs/, { timeout: 15000 }) + await page.goto('/console/activity-logs/') + await expectPathname(page, '/console/activity-logs/') await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-activity-logs') + await capture(page, 'app/console-activity-logs') }) }) @@ -61,12 +80,16 @@ test.describe('시스템 매뉴얼 — Activity Logs', { tag: ['@manual', '@mana // 5. Audit Logs // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Audit Logs', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Audit Logs', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('감사 로그 목록이 표시된다', async ({ page }) => { - await page.goto('/system/audit-logs/') - await expect(page).toHaveURL(/system\/audit-logs/, { timeout: 15000 }) + await page.goto('/console/api-audit-logs/') + await expectPathname(page, '/console/api-audit-logs/') await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-audit-logs') + await capture(page, 'app/console-audit-logs') }) }) @@ -74,12 +97,16 @@ test.describe('시스템 매뉴얼 — Audit Logs', { tag: ['@manual', '@manager // 6. Error Logs // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Error Logs', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Error Logs', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('에러 로그 목록이 표시된다', async ({ page }) => { - await page.goto('/system/error-logs/') - await expect(page).toHaveURL(/system\/error-logs/, { timeout: 15000 }) + await page.goto('/console/error-logs/') + await expectPathname(page, '/console/error-logs/') await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-error-logs') + await capture(page, 'app/console-error-logs') }) }) @@ -87,12 +114,16 @@ test.describe('시스템 매뉴얼 — Error Logs', { tag: ['@manual', '@manager // 7. Login History // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Login History', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Login History', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('로그인 이력 목록이 표시된다', async ({ page }) => { - await page.goto('/system/login-history/') - await expect(page).toHaveURL(/system\/login-history/, { timeout: 15000 }) + await page.goto('/console/login-history/') + await expectPathname(page, '/console/login-history/') await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-login-history') + await capture(page, 'app/console-login-history') }) }) @@ -100,12 +131,16 @@ test.describe('시스템 매뉴얼 — Login History', { tag: ['@manual', '@mana // 8. Email Templates // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Email Templates', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Email Templates', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('이메일 템플릿 목록이 표시된다', async ({ page }) => { - await page.goto('/system/email-templates/') - await expect(page).toHaveURL(/system\/email-templates/, { timeout: 15000 }) + await page.goto('/console/email-templates/') + await expectPathname(page, '/console/email-templates/') await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-email-templates') + await capture(page, 'app/console-email-templates') }) }) @@ -113,12 +148,16 @@ test.describe('시스템 매뉴얼 — Email Templates', { tag: ['@manual', '@ma // 9. Slack Templates // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Slack Templates', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Slack Templates', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('슬랙 템플릿 목록이 표시된다', async ({ page }) => { - await page.goto('/system/slack-templates/') - await expect(page).toHaveURL(/system\/slack-templates/, { timeout: 15000 }) + await page.goto('/console/slack-templates/') + await expectPathname(page, '/console/slack-templates/') await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-slack-templates') + await capture(page, 'app/console-slack-templates') }) }) @@ -126,12 +165,16 @@ test.describe('시스템 매뉴얼 — Slack Templates', { tag: ['@manual', '@ma // 10. Notification Channels // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Notification Channels', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Notification Channels', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('알림 채널 목록이 표시된다', async ({ page }) => { - await page.goto('/system/notification-channels/') - await expect(page).toHaveURL(/system\/notification-channels/, { timeout: 15000 }) + await page.goto('/console/notification-channels/') + await expectPathname(page, '/console/notification-channels/') await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-notification-channels') + await capture(page, 'app/console-notification-channels') }) }) @@ -139,12 +182,16 @@ test.describe('시스템 매뉴얼 — Notification Channels', { tag: ['@manual' // 11. Notification Rules // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Notification Rules', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Notification Rules', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('알림 규칙 목록이 표시된다', async ({ page }) => { - await page.goto('/system/notification-rules/') - await expect(page).toHaveURL(/system\/notification-rules/, { timeout: 15000 }) + await page.goto('/console/notification-rules/') + await expectPathname(page, '/console/notification-rules/') await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-notification-rules') + await capture(page, 'app/console-notification-rules') }) }) @@ -152,12 +199,18 @@ test.describe('시스템 매뉴얼 — Notification Rules', { tag: ['@manual', ' // 12. Workspaces Admin // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Workspaces Admin', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Workspaces Admin', { tag: ['@manual', '@admin'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'admin') + }) + test('워크스페이스 관리 목록이 표시된다', async ({ page }) => { - await page.goto('/system/workspaces/') - await expect(page).toHaveURL(/system\/workspaces/, { timeout: 15000 }) - await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-workspaces') + await page.goto('/console/workspaces/') + await expectPathname(page, '/console/workspaces/') + await expect( + page.locator('.tabulator-row').first().or(page.getByText('No data').first()) + ).toBeVisible({ timeout: 15000 }) + await capture(page, 'app/console-workspaces') }) }) @@ -166,10 +219,38 @@ test.describe('시스템 매뉴얼 — Workspaces Admin', { tag: ['@manual', '@m // ───────────────────────────────────────────── test.describe('시스템 매뉴얼 — My Workspaces', { tag: ['@manual', '@manager'] }, () => { + test.beforeEach(async ({ page }) => { + await loginWithRetry(page, 'manager') + }) + test('내 워크스페이스 목록이 표시된다', async ({ page }) => { - await page.goto('/my-workspaces/') - await expect(page).toHaveURL(/my-workspaces/, { timeout: 15000 }) - await expect(page.getByRole('heading', { name: /Workspaces/i })).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/my-workspaces') + await page.goto('/console/my-workspaces/') + await expectPathname(page, '/console/my-workspaces/') + await expect(page.getByRole('heading', { name: 'My Workspace' })).toBeVisible({ timeout: 15000 }) + await capture(page, 'app/console-my-workspaces') + }) + + test('external workspace detail은 read-only로 표시되고 invite public route는 invalid로 차단된다', async ({ + page, + }) => { + ensureExternalWorkspaceFixture() + const workspaceId = await resolveWorkspaceIdByName(page, EXTERNAL_WORKSPACE_NAME) + + await page.goto(`/console/my-workspaces/${workspaceId}`) + await expectPathname(page, `/console/my-workspaces/${workspaceId}`) + await expect( + page.getByText('External workspaces are synced from an external source and cannot be modified in Deck.') + ).toBeVisible({ timeout: 15000 }) + await expect(page.getByRole('button', { name: 'Save' })).toHaveCount(0) + await expect(page.locator('[data-testid="my-workspace-member-actions"]')).toHaveCount(0) + await capture(page, 'app/console-my-workspace-external-readonly') + + const externalInviteToken = ensureExternalWorkspaceInviteFixture() + await page.goto(`/workspace-invite/?token=${externalInviteToken}`) + await expectPathname(page, '/workspace-invite/') + await expect(page.getByRole('heading', { name: 'Invalid invitation' })).toBeVisible({ + timeout: 15000, + }) + await capture(page, 'app/workspace-invite-external-invalid') }) }) diff --git a/frontend/tests/manual/app/users-side-effects.spec.ts b/frontend/tests/manual/app/users-side-effects.spec.ts index 3149ea8a2..29a80bc6f 100644 --- a/frontend/tests/manual/app/users-side-effects.spec.ts +++ b/frontend/tests/manual/app/users-side-effects.spec.ts @@ -82,7 +82,7 @@ async function waitForActivity(context: BrowserContext, activityType: string, us } async function openCreateModal(page: Page) { - await page.goto('/system/users/', { waitUntil: 'networkidle' }); + await page.goto('/console/users/', { waitUntil: 'networkidle' }); await expect(page.getByRole('button', { name: 'Create' })).toBeVisible({ timeout: 15000 }); await page.getByRole('button', { name: 'Create' }).click(); const dialog = page.getByRole('dialog'); @@ -93,7 +93,7 @@ async function openCreateModal(page: Page) { } async function openEditModal(page: Page, userId: string) { - await page.goto(`/system/users/?userId=${userId}`, { waitUntil: 'networkidle' }); + await page.goto(`/console/users/?userId=${userId}`, { waitUntil: 'networkidle' }); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 15000 }); await expect(dialog.getByRole('heading', { name: 'Edit User', exact: true })).toBeVisible({ @@ -107,7 +107,7 @@ test.describe('사용자 매뉴얼 — country-aware user side effects', { tag: }); test('내 계정 프로필 화면은 contact 필드를 노출하지 않아야 한다', async ({ page }) => { - await page.goto('/account/profile', { waitUntil: 'networkidle' }); + await page.goto('/settings/account/profile', { waitUntil: 'networkidle' }); await expect(page.getByTestId('info-tab')).toBeVisible(); await expect(page.getByText('Phone', { exact: true })).toHaveCount(0); diff --git a/frontend/tests/manual/deskpie/crm.spec.ts b/frontend/tests/manual/deskpie/crm.spec.ts index 2922eb544..6667f5e61 100644 --- a/frontend/tests/manual/deskpie/crm.spec.ts +++ b/frontend/tests/manual/deskpie/crm.spec.ts @@ -1,76 +1,145 @@ -import { test, expect } from '@playwright/test' -import { capture } from '../helpers/screenshot' +import { test, expect } from "@playwright/test"; +import { expectLocation } from "../helpers/navigation"; +import { capture } from "../helpers/screenshot"; +import { + resolveWorkspaceIdByName, + withWorkspaceScope, +} from "../helpers/workspace"; + +const SEEDED_WORKSPACE_NAME = "매니저의 워크스페이스"; // ───────────────────────────────────────────── // 1. 회사 목록 // ───────────────────────────────────────────── -test.describe('CRM 매뉴얼 — 회사 목록', { tag: ['@manual', '@smoke', '@manager'] }, () => { - test('회사 목록이 표시된다', async ({ page }) => { - await page.goto('/companies/') - await expect(page).toHaveURL(/\/companies/, { timeout: 15000 }) - // DeskPieDevDataSeeder가 Acme Corp 등을 seed - await expect(page.getByText('Acme Corp')).toBeVisible({ timeout: 15000 }) - await capture(page, 'deskpie/companies-list') - }) -}) +test.describe( + "CRM 매뉴얼 — 회사 목록", + { tag: ["@manual", "@smoke", "@manager"] }, + () => { + test("회사 목록이 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/companies/", workspaceId)); + await expectLocation(page, { + pathname: "/console/companies/", + search: `?workspace_id=${workspaceId}`, + }); + // DeskPieDevDataSeeder가 Acme Corp 등을 seed + await expect(page.getByText("Acme Corp")).toBeVisible({ timeout: 15000 }); + await capture(page, "deskpie/companies-list"); + }); + }, +); // ───────────────────────────────────────────── // 2. 연락처 목록 // ───────────────────────────────────────────── -test.describe('CRM 매뉴얼 — 연락처 목록', { tag: ['@manual', '@manager'] }, () => { - test('연락처 목록이 표시된다', async ({ page }) => { - await page.goto('/contacts/') - await expect(page).toHaveURL(/\/contacts/, { timeout: 15000 }) - await capture(page, 'deskpie/contacts-list') - }) -}) +test.describe( + "CRM 매뉴얼 — 연락처 목록", + { tag: ["@manual", "@manager"] }, + () => { + test("연락처 목록이 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/contacts/", workspaceId)); + await expectLocation(page, { + pathname: "/console/contacts/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/contacts-list"); + }); + }, +); // ───────────────────────────────────────────── // 3. 리드 목록 // ───────────────────────────────────────────── -test.describe('CRM 매뉴얼 — 리드 목록', { tag: ['@manual', '@manager'] }, () => { - test('리드 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/leads/') - await expect(page).toHaveURL(/\/leads/, { timeout: 15000 }) - await capture(page, 'deskpie/leads-list') - }) -}) +test.describe( + "CRM 매뉴얼 — 리드 목록", + { tag: ["@manual", "@manager"] }, + () => { + test("리드 목록 페이지가 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/leads/", workspaceId)); + await expectLocation(page, { + pathname: "/console/leads/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/leads-list"); + }); + }, +); // ───────────────────────────────────────────── // 4. 딜 목록 // ───────────────────────────────────────────── -test.describe('CRM 매뉴얼 — 딜 목록', { tag: ['@manual', '@manager'] }, () => { - test('딜 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/deals/') - await expect(page).toHaveURL(/\/deals/, { timeout: 15000 }) - await capture(page, 'deskpie/deals-list') - }) -}) +test.describe("CRM 매뉴얼 — 딜 목록", { tag: ["@manual", "@manager"] }, () => { + test("딜 목록 페이지가 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/deals/", workspaceId)); + await expectLocation(page, { + pathname: "/console/deals/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/deals-list"); + }); +}); // ───────────────────────────────────────────── // 5. 견적 목록 // ───────────────────────────────────────────── -test.describe('CRM 매뉴얼 — 견적 목록', { tag: ['@manual', '@manager'] }, () => { - test('견적 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/quotes/') - await expect(page).toHaveURL(/\/quotes/, { timeout: 15000 }) - await capture(page, 'deskpie/quotes-list') - }) -}) +test.describe( + "CRM 매뉴얼 — 견적 목록", + { tag: ["@manual", "@manager"] }, + () => { + test("견적 목록 페이지가 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/quotes/", workspaceId)); + await expectLocation(page, { + pathname: "/console/quotes/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/quotes-list"); + }); + }, +); // ───────────────────────────────────────────── // 6. 파이프라인 목록 // ───────────────────────────────────────────── -test.describe('CRM 매뉴얼 — 파이프라인 목록', { tag: ['@manual', '@manager'] }, () => { - test('파이프라인 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/pipelines/') - await expect(page).toHaveURL(/\/pipelines/, { timeout: 15000 }) - await capture(page, 'deskpie/pipelines-list') - }) -}) +test.describe( + "CRM 매뉴얼 — 파이프라인 목록", + { tag: ["@manual", "@manager"] }, + () => { + test("파이프라인 목록 페이지가 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/pipelines/", workspaceId)); + await expectLocation(page, { + pathname: "/console/pipelines/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/pipelines-list"); + }); + }, +); diff --git a/frontend/tests/manual/deskpie/licenses.spec.ts b/frontend/tests/manual/deskpie/licenses.spec.ts index 016c7ef2f..55c389843 100644 --- a/frontend/tests/manual/deskpie/licenses.spec.ts +++ b/frontend/tests/manual/deskpie/licenses.spec.ts @@ -1,82 +1,159 @@ -import { test, expect } from '@playwright/test' -import { capture } from '../helpers/screenshot' +import { test, expect } from "@playwright/test"; +import { expectLocation } from "../helpers/navigation"; +import { capture } from "../helpers/screenshot"; +import { + resolveWorkspaceIdByName, + withWorkspaceScope, +} from "../helpers/workspace"; + +const SEEDED_WORKSPACE_NAME = "매니저의 워크스페이스"; // ───────────────────────────────────────────── // 1. 라이선스 목록 // ───────────────────────────────────────────── -test.describe('라이선스 매뉴얼 — 라이선스 목록', { tag: ['@manual', '@smoke', '@manager'] }, () => { - test('라이선스 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/licenses/') - await expect(page).toHaveURL(/\/licenses/, { timeout: 15000 }) - await capture(page, 'deskpie/licenses-list') - }) -}) +test.describe( + "라이선스 매뉴얼 — 라이선스 목록", + { tag: ["@manual", "@smoke", "@manager"] }, + () => { + test("라이선스 목록 페이지가 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/licenses/", workspaceId)); + await expectLocation(page, { + pathname: "/console/licenses/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/licenses-list"); + }); + }, +); // ───────────────────────────────────────────── // 2. 라이선스 편집 모달 // ───────────────────────────────────────────── -test.describe('라이선스 매뉴얼 — 라이선스 편집', { tag: ['@manual', '@manager'] }, () => { - test('라이선스가 있을 때 편집 모달이 열린다', async ({ page }) => { - await page.goto('/licenses/') - await expect(page).toHaveURL(/\/licenses/, { timeout: 15000 }) +test.describe( + "라이선스 매뉴얼 — 라이선스 편집", + { tag: ["@manual", "@manager"] }, + () => { + test("라이선스가 있을 때 편집 모달이 열린다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/licenses/", workspaceId)); + await expectLocation(page, { + pathname: "/console/licenses/", + search: `?workspace_id=${workspaceId}`, + }); - // 라이선스 행이 있으면 더블클릭으로 편집 모달 열기 - const firstRow = page.locator('.tabulator-row').first() - const rowCount = await firstRow.count() - if (rowCount > 0) { - await firstRow.dblclick() - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }) - } - await capture(page, 'deskpie/licenses-edit') - }) -}) + // 라이선스 행이 있으면 더블클릭으로 편집 모달 열기 + const firstRow = page.locator(".tabulator-row").first(); + const rowCount = await firstRow.count(); + if (rowCount > 0) { + await firstRow.dblclick(); + await expect(page.getByRole("dialog")).toBeVisible({ timeout: 5000 }); + } + await capture(page, "deskpie/licenses-edit"); + }); + }, +); // ───────────────────────────────────────────── // 3. 계약 목록 // ───────────────────────────────────────────── -test.describe('라이선스 매뉴얼 — 계약 목록', { tag: ['@manual', '@manager'] }, () => { - test('계약 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/contracts/') - await expect(page).toHaveURL(/\/contracts/, { timeout: 15000 }) - await capture(page, 'deskpie/contracts-list') - }) -}) +test.describe( + "라이선스 매뉴얼 — 계약 목록", + { tag: ["@manual", "@manager"] }, + () => { + test("계약 목록 페이지가 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/contracts/", workspaceId)); + await expectLocation(page, { + pathname: "/console/contracts/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/contracts-list"); + }); + }, +); // ───────────────────────────────────────────── // 4. 제품 목록 // ───────────────────────────────────────────── -test.describe('라이선스 매뉴얼 — 제품 목록', { tag: ['@manual', '@manager'] }, () => { - test('제품 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/products/') - await expect(page).toHaveURL(/\/products/, { timeout: 15000 }) - await capture(page, 'deskpie/products-list') - }) -}) +test.describe( + "라이선스 매뉴얼 — 제품 목록", + { tag: ["@manual", "@manager"] }, + () => { + test("제품 목록 페이지가 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto(withWorkspaceScope("/console/products/", workspaceId)); + await expectLocation(page, { + pathname: "/console/products/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/products-list"); + }); + }, +); // ───────────────────────────────────────────── // 5. 계약 당사자 목록 // ───────────────────────────────────────────── -test.describe('라이선스 매뉴얼 — 계약 당사자 목록', { tag: ['@manual', '@manager'] }, () => { - test('계약 당사자 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/contracting-parties/') - await expect(page).toHaveURL(/\/contracting-parties/, { timeout: 15000 }) - await capture(page, 'deskpie/contracting-parties-list') - }) -}) +test.describe( + "라이선스 매뉴얼 — 계약 당사자 목록", + { tag: ["@manual", "@manager"] }, + () => { + test("계약 당사자 목록 페이지가 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto( + withWorkspaceScope("/console/contracting-parties/", workspaceId), + ); + await expectLocation(page, { + pathname: "/console/contracting-parties/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/contracting-parties-list"); + }); + }, +); // ───────────────────────────────────────────── // 6. 라이선스 요청 목록 // ───────────────────────────────────────────── -test.describe('라이선스 매뉴얼 — 라이선스 요청 목록', { tag: ['@manual', '@manager'] }, () => { - test('라이선스 요청 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/license-requests/') - await expect(page).toHaveURL(/\/license-requests/, { timeout: 15000 }) - await capture(page, 'deskpie/license-requests-list') - }) -}) +test.describe( + "라이선스 매뉴얼 — 라이선스 요청 목록", + { tag: ["@manual", "@manager"] }, + () => { + test("라이선스 요청 목록 페이지가 표시된다", async ({ page }) => { + const workspaceId = await resolveWorkspaceIdByName( + page, + SEEDED_WORKSPACE_NAME, + ); + await page.goto( + withWorkspaceScope("/console/license-requests/", workspaceId), + ); + await expectLocation(page, { + pathname: "/console/license-requests/", + search: `?workspace_id=${workspaceId}`, + }); + await capture(page, "deskpie/license-requests-list"); + }); + }, +); diff --git a/frontend/tests/manual/helpers/external-workspace.ts b/frontend/tests/manual/helpers/external-workspace.ts new file mode 100644 index 000000000..0fbc06f42 --- /dev/null +++ b/frontend/tests/manual/helpers/external-workspace.ts @@ -0,0 +1,151 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; + +const DB_HOST = process.env.MANUAL_DB_HOST ?? "localhost"; +const DB_PORT = process.env.MANUAL_DB_PORT ?? "5011"; +const DB_NAME = process.env.MANUAL_DB_NAME ?? "deck"; +const DB_USER = process.env.MANUAL_DB_USER ?? "deck"; +const DB_PASSWORD = process.env.MANUAL_DB_PASSWORD ?? "deck"; + +const MANAGER_EMAIL = "manager@deck.io"; +export const EXTERNAL_WORKSPACE_NAME = "AIP Synced Workspace"; +const EXTERNAL_WORKSPACE_DESCRIPTION = "Manual smoke fixture for external workspace"; +const EXTERNAL_WORKSPACE_ID = "manual-aip-org-1"; +const EXTERNAL_INVITE_EMAIL = "manual-invitee@deck.io"; +const EXTERNAL_INVITE_TOKEN = "manual-external-workspace-invite-token"; + +export function ensureExternalWorkspaceFixture() { + runSql(` +WITH manager_user AS ( + SELECT id + FROM users + WHERE email = '${MANAGER_EMAIL}' +), +workspace_row AS ( + INSERT INTO workspaces ( + name, + description, + managed_type, + external_source, + external_id, + created_by, + updated_by + ) + SELECT + '${EXTERNAL_WORKSPACE_NAME}', + '${EXTERNAL_WORKSPACE_DESCRIPTION}', + 'PLATFORM_MANAGED', + 'AIP', + '${EXTERNAL_WORKSPACE_ID}', + manager_user.id, + manager_user.id + FROM manager_user + ON CONFLICT (external_source, external_id) + WHERE external_source IS NOT NULL AND external_id IS NOT NULL AND deleted_at IS NULL + DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + managed_type = 'PLATFORM_MANAGED', + deleted_at = NULL, + deleted_by = NULL, + updated_at = NOW() + RETURNING id +) +INSERT INTO workspace_members ( + workspace_id, + user_id, + is_owner, + created_by, + updated_by +) +SELECT + workspace_row.id, + manager_user.id, + FALSE, + manager_user.id, + manager_user.id +FROM workspace_row, manager_user +ON CONFLICT (workspace_id, user_id) DO NOTHING; +`); +} + +export function ensureExternalWorkspaceInviteFixture(): string { + ensureExternalWorkspaceFixture(); + const tokenHash = createHash("sha3-256").update(EXTERNAL_INVITE_TOKEN).digest("hex"); + + runSql(` +WITH manager_user AS ( + SELECT id + FROM users + WHERE email = '${MANAGER_EMAIL}' +), +workspace_row AS ( + SELECT id + FROM workspaces + WHERE external_source = 'AIP' + AND external_id = '${EXTERNAL_WORKSPACE_ID}' + AND deleted_at IS NULL +) +INSERT INTO workspace_invites ( + workspace_id, + email, + token_hash, + status, + expires_at, + message, + inviter_id, + created_by, + updated_by +) +SELECT + workspace_row.id, + '${EXTERNAL_INVITE_EMAIL}', + '${tokenHash}', + 'PENDING', + NOW() + INTERVAL '7 days', + 'Manual external invite fixture', + manager_user.id, + manager_user.id, + manager_user.id +FROM workspace_row, manager_user +ON CONFLICT (workspace_id, email) +WHERE status = 'PENDING' AND deleted_at IS NULL +DO UPDATE SET + token_hash = EXCLUDED.token_hash, + expires_at = EXCLUDED.expires_at, + inviter_id = EXCLUDED.inviter_id, + updated_by = EXCLUDED.updated_by, + deleted_at = NULL, + deleted_by = NULL; +`); + + return EXTERNAL_INVITE_TOKEN; +} + +function runSql(sql: string) { + execFileSync( + "psql", + [ + "-h", + DB_HOST, + "-p", + DB_PORT, + "-U", + DB_USER, + "-d", + DB_NAME, + "-v", + "ON_ERROR_STOP=1", + "-q", + "-c", + sql, + ], + { + env: { + ...process.env, + PGPASSWORD: DB_PASSWORD, + }, + stdio: "pipe", + }, + ); +} diff --git a/frontend/tests/manual/helpers/navigation.ts b/frontend/tests/manual/helpers/navigation.ts new file mode 100644 index 000000000..b2d2b12a4 --- /dev/null +++ b/frontend/tests/manual/helpers/navigation.ts @@ -0,0 +1,57 @@ +import { expect, type Page } from '@playwright/test' + +export async function expectPathname(page: Page, pathname: string): Promise { + await expect + .poll( + () => new URL(page.url()).pathname, + { + message: `expected current pathname to be ${pathname}`, + timeout: 15_000, + }, + ) + .toBe(pathname) +} + +export async function expectLocation( + page: Page, + expected: { + pathname: string + search?: string + }, +): Promise { + if (expected.search == null) { + await expectPathname(page, expected.pathname) + return + } + + await expect + .poll( + () => { + const url = new URL(page.url()) + return { + pathname: url.pathname, + search: url.search, + } + }, + { + message: `expected current location to be ${expected.pathname}${expected.search ?? ''}`, + timeout: 15_000, + }, + ) + .toEqual({ + pathname: expected.pathname, + search: expected.search, + }) +} + +export async function readCurrentWorkspaceId(page: Page, storageKey: string): Promise { + const workspaceId = await page.evaluate( + (key) => window.localStorage.getItem(key), + storageKey, + ) + expect( + workspaceId, + `expected localStorage key ${storageKey} to contain workspace selection`, + ).toBeTruthy() + return workspaceId! +} diff --git a/frontend/tests/manual/helpers/screenshot.ts b/frontend/tests/manual/helpers/screenshot.ts index 4c03ebc68..ca78a74e9 100644 --- a/frontend/tests/manual/helpers/screenshot.ts +++ b/frontend/tests/manual/helpers/screenshot.ts @@ -12,7 +12,7 @@ export async function capture(page: Page, name: string): Promise { // 렌더링 완료 대기: 네트워크 유휴 + 로딩 인디케이터 소멸 await page.waitForLoadState('networkidle') const loading = page.locator( - '.animate-spin, .animate-pulse, [data-sidebar="menu-skeleton"]', + '[data-testid$="-loading"]:visible, [data-sidebar="menu-skeleton"]:visible, .animate-spin:visible', ) await expect(loading).toHaveCount(0, { timeout: 15_000 }) diff --git a/frontend/tests/manual/helpers/workspace.ts b/frontend/tests/manual/helpers/workspace.ts new file mode 100644 index 000000000..2092eb49a --- /dev/null +++ b/frontend/tests/manual/helpers/workspace.ts @@ -0,0 +1,33 @@ +import { expect, type Page } from "@playwright/test"; + +type MyWorkspaceSummary = { + id: string; + name: string; +}; + +export async function resolveWorkspaceIdByName( + page: Page, + workspaceName: string, +): Promise { + const response = await page.request.get("/api/v1/my-workspaces"); + expect(response.ok(), "/api/v1/my-workspaces should return 2xx").toBe(true); + + const workspaces = (await response.json()) as MyWorkspaceSummary[]; + const workspace = + workspaces.find((candidate) => candidate.name === workspaceName) ?? null; + + expect( + workspace, + `expected workspace ${workspaceName} to exist for manual verification`, + ).toBeTruthy(); + return workspace!.id; +} + +export function withWorkspaceScope( + pathname: string, + workspaceId: string, +): string { + const url = new URL(pathname, "http://localhost"); + url.searchParams.set("workspace_id", workspaceId); + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/frontend/tests/manual/meetpie/booking.spec.ts b/frontend/tests/manual/meetpie/booking.spec.ts index bbb36f993..f78a9fce7 100644 --- a/frontend/tests/manual/meetpie/booking.spec.ts +++ b/frontend/tests/manual/meetpie/booking.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from "@playwright/test"; +import { expectLocation, expectPathname } from "../helpers/navigation"; import { capture } from "../helpers/screenshot"; import { BOOKING_EVENT_TYPE_SLUG, @@ -64,41 +65,6 @@ async function ensureBookingData(page: Page): Promise { return profile.handle; } -// IntersectionObserver가 헤드리스 Chromium에서 즉시 발화하지 않는 문제를 해결하기 위해 -// 실제 IntersectionObserver를 즉시 발화하는 버전으로 교체하는 헬퍼 -async function setupInstantIntersectionObserver(page: Page) { - await page.addInitScript(() => { - const OriginalIO = window.IntersectionObserver; - window.IntersectionObserver = class extends OriginalIO { - constructor( - callback: IntersectionObserverCallback, - options?: IntersectionObserverInit, - ) { - super(callback, options); - const self = this as unknown as { - _callback: IntersectionObserverCallback; - }; - self._callback = callback; - } - observe(target: Element) { - super.observe(target); - const entry = { - isIntersecting: true, - target, - boundingClientRect: target.getBoundingClientRect(), - intersectionRatio: 1, - intersectionRect: target.getBoundingClientRect(), - rootBounds: null, - time: performance.now(), - } as IntersectionObserverEntry; - ( - this as unknown as { _callback: IntersectionObserverCallback } - )._callback([entry], this); - } - } as typeof IntersectionObserver; - }); -} - // ───────────────────────────────────────────── // 1. 예약 프로필 // ───────────────────────────────────────────── @@ -110,7 +76,11 @@ test.describe( test("handle이 있을 때 프로필 정보가 표시된다", async ({ page }) => { const handle = await ensureBookingData(page); - await page.goto("/booking/settings/?tab=profile"); + await page.goto("/console/booking/settings/?tab=profile"); + await expectLocation(page, { + pathname: "/console/booking/settings/", + search: "?tab=profile", + }); await expect(page.getByText("Booking settings")).toBeVisible({ timeout: 5000, }); @@ -135,7 +105,11 @@ test.describe( test("스케줄 목록이 표시된다", async ({ page }) => { await ensureBookingData(page); - await page.goto("/booking/settings/?tab=schedules"); + await page.goto("/console/booking/settings/?tab=schedules"); + await expectLocation(page, { + pathname: "/console/booking/settings/", + search: "?tab=schedules", + }); await expect(page.getByText("기본 스케줄")).toBeVisible({ timeout: 15000, }); @@ -150,7 +124,11 @@ test.describe( test.describe("예약 매뉴얼 — 이벤트 타입 목록", { tag: ["@manual", "@user"] }, () => { test("이벤트 타입 목록이 표시된다", async ({ page }) => { - await page.goto("/booking/settings/?tab=event-types"); + await page.goto("/console/booking/settings/?tab=event-types"); + await expectLocation(page, { + pathname: "/console/booking/settings/", + search: "?tab=event-types", + }); await expect(page.getByText("30분 미팅")).toBeVisible({ timeout: 15000 }); await capture(page, "meetpie/booking-event-types"); }); @@ -174,7 +152,6 @@ test.describe("예약 매뉴얼 — 공개 프로필 리스팅", { tag: ["@manua test.describe("예약 매뉴얼 — 공개 예약 슬롯", { tag: ["@manual", "@user"] }, () => { test("공개 예약 페이지에 시간 슬롯이 표시된다", async ({ page }) => { - await setupInstantIntersectionObserver(page); await page.goto(`/book/${BOOKING_HANDLE}/${BOOKING_EVENT_TYPE_SLUG}`); const firstSlot = page @@ -192,7 +169,6 @@ test.describe("예약 매뉴얼 — 공개 예약 슬롯", { tag: ["@manual", "@ test.describe("예약 매뉴얼 — 공개 예약 폼", { tag: ["@manual", "@user"] }, () => { test("슬롯 선택 시 예약 폼 시트가 표시된다", async ({ page }) => { - await setupInstantIntersectionObserver(page); await page.goto(`/book/${BOOKING_HANDLE}/${BOOKING_EVENT_TYPE_SLUG}`); const firstSlot = page @@ -213,7 +189,6 @@ test.describe("예약 매뉴얼 — 공개 예약 폼", { tag: ["@manual", "@use test.describe("예약 매뉴얼 — 공개 예약 결과", { tag: ["@manual", "@user"] }, () => { test("예약 요청 후 결과 피드백이 표시된다", async ({ page }) => { - await setupInstantIntersectionObserver(page); await page.goto(`/book/${BOOKING_HANDLE}/${BOOKING_EVENT_TYPE_SLUG}`); const firstSlot = page @@ -248,8 +223,8 @@ test.describe("예약 매뉴얼 — 공개 예약 결과", { tag: ["@manual", "@ test.describe("예약 매뉴얼 — 예약 목록", { tag: ["@manual", "@user"] }, () => { test("예약 목록 페이지가 표시된다", async ({ page }) => { - await page.goto("/booking/bookings/"); - await expect(page).toHaveURL(/\/booking\/bookings/, { timeout: 15000 }); + await page.goto("/console/booking/bookings/"); + await expectPathname(page, "/console/booking/bookings/"); await capture(page, "meetpie/booking-bookings"); }); }); @@ -260,7 +235,7 @@ test.describe("예약 매뉴얼 — 예약 목록", { tag: ["@manual", "@user"] test.describe("예약 매뉴얼 — 예약 대시보드", { tag: ["@manual", "@user"] }, () => { test("예약 대시보드가 표시된다", async ({ page }) => { - await page.goto("/booking/"); + await page.goto("/console/booking/"); await expect(page.getByText("30분 미팅")).toBeVisible({ timeout: 15000 }); await capture(page, "meetpie/booking-dashboard"); }); diff --git a/frontend/tests/manual/meetpie/calendar.spec.ts b/frontend/tests/manual/meetpie/calendar.spec.ts index adb33ed59..b7627aded 100644 --- a/frontend/tests/manual/meetpie/calendar.spec.ts +++ b/frontend/tests/manual/meetpie/calendar.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' +import { expectPathname } from '../helpers/navigation' import { capture } from '../helpers/screenshot' // ───────────────────────────────────────────── @@ -7,8 +8,8 @@ import { capture } from '../helpers/screenshot' test.describe('캘린더 매뉴얼 — 설정 페이지', { tag: ['@manual', '@user'] }, () => { test('캘린더 설정 페이지가 표시된다', async ({ page }) => { - await page.goto('/calendar-settings/') - await expect(page).toHaveURL(/\/calendar-settings/, { timeout: 15000 }) + await page.goto('/console/calendar-integrations/') + await expectPathname(page, '/console/calendar-integrations/') await capture(page, 'meetpie/calendar-settings') }) }) @@ -19,16 +20,11 @@ test.describe('캘린더 매뉴얼 — 설정 페이지', { tag: ['@manual', '@u test.describe('캘린더 매뉴얼 — CalDAV 연동 폼', { tag: ['@manual', '@user'] }, () => { test('CalDAV 섹션의 Add 버튼을 클릭하면 연동 폼이 표시된다', async ({ page }) => { - await page.goto('/calendar-settings/') - await expect(page).toHaveURL(/\/calendar-settings/, { timeout: 15000 }) - - // CalDAV 섹션에서 Add 버튼 클릭 (Add 버튼들 중 마지막 — Google, Microsoft, CalDAV 순) - const addButtons = page.getByRole('button', { name: 'Add' }) - const count = await addButtons.count() - if (count > 0) { - await addButtons.last().click() - await expect(page.getByRole('textbox', { name: /^Name/i }).first()).toBeVisible({ timeout: 5000 }) - } + await page.goto('/console/calendar-integrations/') + await expectPathname(page, '/console/calendar-integrations/') + + await page.getByTestId('calendar-provider-connect-caldav').click() + await expect(page.getByRole('textbox', { name: /^Name/i }).first()).toBeVisible({ timeout: 5000 }) await capture(page, 'meetpie/calendar-caldav-form') }) }) @@ -39,8 +35,8 @@ test.describe('캘린더 매뉴얼 — CalDAV 연동 폼', { tag: ['@manual', '@ test.describe('캘린더 매뉴얼 — 관리 페이지', { tag: ['@manual', '@user'] }, () => { test('캘린더 관리 페이지가 표시된다', async ({ page }) => { - await page.goto('/calendar-settings/manage/') - await expect(page).toHaveURL(/\/calendar-settings\/manage/, { timeout: 15000 }) + await page.goto('/console/calendar-integrations/manage/') + await expectPathname(page, '/console/calendar-integrations/manage/') await capture(page, 'meetpie/calendar-manage') }) }) @@ -51,8 +47,8 @@ test.describe('캘린더 매뉴얼 — 관리 페이지', { tag: ['@manual', '@u test.describe('캘린더 매뉴얼 — 휴일 구독', { tag: ['@manual', '@user'] }, () => { test('휴일 구독 페이지가 표시된다', async ({ page }) => { - await page.goto('/calendar-settings/manage/') - await expect(page).toHaveURL(/\/calendar-settings\/manage/, { timeout: 15000 }) + await page.goto('/console/calendar-integrations/manage/') + await expectPathname(page, '/console/calendar-integrations/manage/') await capture(page, 'meetpie/calendar-holidays') }) }) diff --git a/frontend/tests/manual/meetpie/namecard.spec.ts b/frontend/tests/manual/meetpie/namecard.spec.ts index 5b3b52d40..66f5105c6 100644 --- a/frontend/tests/manual/meetpie/namecard.spec.ts +++ b/frontend/tests/manual/meetpie/namecard.spec.ts @@ -1,86 +1,121 @@ -import { test, expect } from '@playwright/test' -import { capture } from '../helpers/screenshot' +import { test, expect } from "@playwright/test"; +import { expectPathname } from "../helpers/navigation"; +import { capture } from "../helpers/screenshot"; // ───────────────────────────────────────────── // 1. 내 명함 편집 // ───────────────────────────────────────────── -test.describe('명함 매뉴얼 — 내 명함 편집', { tag: ['@manual', '@user'] }, () => { - test('내 명함 편집기가 표시된다', async ({ page }) => { - await page.goto('/my-namecards/') - // Excalidraw 에디터 헤더 액션 버튼 대기 - await expect(page.getByRole('button', { name: 'Share' })).toBeVisible({ timeout: 15000 }) - // ExcalidrawCanvas 로딩 완료 대기 ('Loading...' 오버레이 사라질 때까지) - await expect(page.getByText('Loading...')).not.toBeVisible({ timeout: 20000 }) - await capture(page, 'meetpie/namecard-editor') - }) -}) +test.describe( + "명함 매뉴얼 — 내 명함 편집", + { tag: ["@manual", "@user"] }, + () => { + test("내 명함 편집기가 표시된다", async ({ page }) => { + await page.goto("/console/my-namecards/"); + // Excalidraw 에디터 헤더 액션 버튼 대기 + await expect(page.getByRole("button", { name: "Share" })).toBeVisible({ + timeout: 15000, + }); + // ExcalidrawCanvas 로딩 완료 대기 ('Loading...' 오버레이 사라질 때까지) + await expect(page.getByText("Loading...")).not.toBeVisible({ + timeout: 20000, + }); + await capture(page, "meetpie/namecard-editor"); + }); + }, +); // ───────────────────────────────────────────── // 2. 명함 공개 링크 활성화 // ───────────────────────────────────────────── -test.describe('명함 매뉴얼 — 명함 공개 링크', { tag: ['@manual', '@user'] }, () => { - test('Share 모달에서 공유 링크 복사 버튼이 표시된다', async ({ page }) => { - await page.goto('/my-namecards/') - await expect(page.getByRole('button', { name: 'Share' })).toBeVisible({ timeout: 15000 }) - await page.getByRole('button', { name: 'Share' }).click() +test.describe( + "명함 매뉴얼 — 명함 공개 링크", + { tag: ["@manual", "@user"] }, + () => { + test("Share 모달에서 공유 링크 복사 버튼이 표시된다", async ({ page }) => { + await page.goto("/console/my-namecards/"); + await expect(page.getByRole("button", { name: "Share" })).toBeVisible({ + timeout: 15000, + }); + await page.getByRole("button", { name: "Share" }).click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }) - await expect( - page.getByRole('dialog').getByRole('button', { name: 'Copy' }) - ).toBeVisible({ timeout: 5000 }) - await capture(page, 'meetpie/namecard-share') - }) -}) + await expect(page.getByRole("dialog")).toBeVisible({ timeout: 5000 }); + await expect( + page.getByRole("dialog").getByRole("button", { name: "Copy" }), + ).toBeVisible({ timeout: 5000 }); + await capture(page, "meetpie/namecard-share"); + }); + }, +); // ───────────────────────────────────────────── // 3. 공개 명함 페이지 // ───────────────────────────────────────────── -test.describe('명함 매뉴얼 — 공개 명함 페이지', { tag: ['@manual', '@user'] }, () => { - test('공개 명함 페이지가 표시된다', async ({ page }) => { - await page.goto('/my-namecards/') - await expect(page.getByRole('button', { name: 'Share' })).toBeVisible({ timeout: 15000 }) - await page.getByRole('button', { name: 'Share' }).click() - const shareUrlInput = page.locator('[data-testid="share-public-link-row"] input') - await expect(shareUrlInput).toHaveValue(/\/namecards\//, { timeout: 5000 }) - const shareUrl = await shareUrlInput.inputValue() +test.describe( + "명함 매뉴얼 — 공개 명함 페이지", + { tag: ["@manual", "@user"] }, + () => { + test("공개 명함 페이지가 표시된다", async ({ page }) => { + await page.goto("/console/my-namecards/"); + await expect(page.getByRole("button", { name: "Share" })).toBeVisible({ + timeout: 15000, + }); + await page.getByRole("button", { name: "Share" }).click(); + const shareUrlInput = page.locator( + '[data-testid="share-public-link-row"] input', + ); + await expect(shareUrlInput).toHaveValue(/\/namecards\//, { + timeout: 5000, + }); + const shareUrl = await shareUrlInput.inputValue(); - await page.goto(shareUrl) - await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible({ timeout: 5000 }) - await capture(page, 'meetpie/namecard-public') - }) -}) + await page.goto(shareUrl); + await expect(page.getByRole("button", { name: "Copy link" })).toBeVisible( + { timeout: 5000 }, + ); + await capture(page, "meetpie/namecard-public"); + }); + }, +); // ───────────────────────────────────────────── // 4. 연락처 목록 // ───────────────────────────────────────────── -test.describe('명함 매뉴얼 — 연락처 목록', { tag: ['@manual', '@user'] }, () => { - test('연락처 목록 페이지가 표시된다', async ({ page }) => { - await page.goto('/contacts/') - await expect(page).toHaveURL(/\/contacts/, { timeout: 15000 }) - await capture(page, 'meetpie/contacts-list') - }) -}) +test.describe( + "명함 매뉴얼 — 연락처 목록", + { tag: ["@manual", "@user"] }, + () => { + test("연락처 목록 페이지가 표시된다", async ({ page }) => { + await page.goto("/console/contacts/"); + await expectPathname(page, "/console/contacts/"); + await capture(page, "meetpie/contacts-list"); + }); + }, +); // ───────────────────────────────────────────── // 5. 연락처 편집 모달 // ───────────────────────────────────────────── -test.describe('명함 매뉴얼 — 연락처 편집', { tag: ['@manual', '@user'] }, () => { - test('연락처가 있을 때 편집 모달이 열린다', async ({ page }) => { - await page.goto('/contacts/') - await expect(page).toHaveURL(/\/contacts/, { timeout: 15000 }) +test.describe( + "명함 매뉴얼 — 연락처 편집", + { tag: ["@manual", "@user"] }, + () => { + test("연락처가 있을 때 편집 모달이 열린다", async ({ page }) => { + await page.goto("/console/contacts/"); + await expectPathname(page, "/console/contacts/"); - // 연락처 행이 있으면 더블클릭으로 편집 모달 열기 - const firstRow = page.locator('table tbody tr, .tabulator-row').first() - const rowCount = await firstRow.count() - if (rowCount > 0) { - await firstRow.dblclick() - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }) - } - await capture(page, 'meetpie/contacts-edit') - }) -}) + // 연락처 행이 있으면 더블클릭으로 편집 모달 열기 + const firstRow = page.locator("table tbody tr, .tabulator-row").first(); + const rowCount = await firstRow.count(); + if (rowCount > 0) { + await firstRow.dblclick(); + await expect(page.getByRole("dialog")).toBeVisible({ timeout: 5000 }); + } + await capture(page, "meetpie/contacts-edit"); + }); + }, +); diff --git a/frontend/tests/manual/meetpie/widget-preview.spec.ts b/frontend/tests/manual/meetpie/widget-preview.spec.ts index 0bf2994eb..6bedcf304 100644 --- a/frontend/tests/manual/meetpie/widget-preview.spec.ts +++ b/frontend/tests/manual/meetpie/widget-preview.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' +import { expectPathname } from '../helpers/navigation' import { capture } from '../helpers/screenshot' // ───────────────────────────────────────────── @@ -8,7 +9,7 @@ import { capture } from '../helpers/screenshot' test.describe('Widget Preview 매뉴얼 — 미리보기', { tag: ['@manual', '@user'] }, () => { test('Widget Preview 페이지가 표시된다', async ({ page }) => { await page.goto('/widget-previews/') - await expect(page).toHaveURL(/\/widget-previews/, { timeout: 15000 }) + await expectPathname(page, '/widget-previews/') await capture(page, 'meetpie/widget-preview') }) }) diff --git a/frontend/tests/manual/setup/app-admin.setup.ts b/frontend/tests/manual/setup/app-admin.setup.ts new file mode 100644 index 000000000..c9c1447c1 --- /dev/null +++ b/frontend/tests/manual/setup/app-admin.setup.ts @@ -0,0 +1,6 @@ +import { test } from "@playwright/test"; +import { setupAppAuth } from "../../helpers/manual-global-setup"; + +test("manual app admin auth를 준비한다", async () => { + await setupAppAuth("admin"); +}); diff --git a/frontend/tests/playwright.manual.config.ts b/frontend/tests/playwright.manual.config.ts index a929e8e7b..cd76d10e8 100644 --- a/frontend/tests/playwright.manual.config.ts +++ b/frontend/tests/playwright.manual.config.ts @@ -45,6 +45,10 @@ export default defineConfig({ }, projects: [ // ── Setup ── + { + name: "app-setup-admin", + testMatch: ["**/manual/setup/app-admin.setup.ts"], + }, { name: "app-setup-manager", testMatch: ["**/manual/setup/app-manager.setup.ts"], @@ -70,6 +74,20 @@ export default defineConfig({ testMatch: ["**/manual/setup/deskpie-user.setup.ts"], }, + // ── App: admin (@admin tag) ── + { + name: "app-admin", + testMatch: ["**/manual/app/**/*.spec.ts"], + testIgnore: ["**/manual/app/login.spec.ts"], + grep: /@admin/, + dependencies: ["app-setup-admin"], + use: { + ...devices["Desktop Chrome"], + baseURL: APP_URL, + storageState: authPath("app", "admin"), + }, + }, + // ── App: manager (@manager tag) ── { name: "app-manager", diff --git a/frontend/tests/profile.spec.ts b/frontend/tests/profile.spec.ts index 0acd6bc26..19a48ae30 100644 --- a/frontend/tests/profile.spec.ts +++ b/frontend/tests/profile.spec.ts @@ -82,7 +82,7 @@ test.describe('프로필 시나리오', { tag: '@account' }, () => { route.fulfill(json({ success: true })) ); - await page.goto('/account/profile'); + await page.goto('/settings/account/profile'); // Info 탭이 기본 선택 await expect(page.getByTestId('info-tab')).toBeVisible(); @@ -206,7 +206,7 @@ test.describe('프로필 시나리오', { tag: '@account' }, () => { route.fulfill(json({ success: true })) ); - await page.goto('/account/profile'); + await page.goto('/settings/account/profile'); // Delete Account → 모달 await page.getByTestId('delete-account-btn').click(); diff --git a/frontend/tests/shared/splitter-layout.spec.ts b/frontend/tests/shared/splitter-layout.spec.ts index cc8bb1fcd..41e3953fa 100644 --- a/frontend/tests/shared/splitter-layout.spec.ts +++ b/frontend/tests/shared/splitter-layout.spec.ts @@ -10,7 +10,7 @@ import { roleTreeRoutePattern } from "../helpers/session"; /** * Splitter 레이아웃 E2E 테스트 * - * 메뉴 관리 페이지(/system/menus)를 사용하여 Splitter의 반응형 레이아웃을 검증한다. + * 메뉴 관리 페이지(/settings/platform/menus)를 사용하여 Splitter의 반응형 레이아웃을 검증한다. * - PC: 좌/우 2단 + 높이 동기화 + 핸들 표시 * - Mobile: 1단 세로 스택 + content-fit 높이 + 핸들 숨김 */ @@ -20,7 +20,7 @@ const PERMISSIONS = ["MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE"]; const programs = [ { code: "SYSTEM_MENUS", - path: "/system/menus", + path: "/settings/platform/menus", permissions: ["MENU_MANAGEMENT_READ"], }, ]; @@ -28,7 +28,7 @@ const programs = [ const menuTree = [ { id: "system", - name: "System", + name: "Platform", icon: "Settings", program: null, permissions: [], @@ -64,7 +64,7 @@ const menusTab: Tab = { id: "menus", title: "Menus", icon: "Menu", - url: "/system/menus", + url: "/settings/platform/menus", }; async function setupMenusPage(page: import("@playwright/test").Page) { diff --git a/frontend/tests/shared/system-layout-sticky.spec.ts b/frontend/tests/shared/system-layout-sticky.spec.ts index 971c8100f..deaba3869 100644 --- a/frontend/tests/shared/system-layout-sticky.spec.ts +++ b/frontend/tests/shared/system-layout-sticky.spec.ts @@ -5,13 +5,13 @@ const tab: Tab = { id: 'notification-channels', title: 'Channels', icon: 'Bell', - url: '/system/notification-channels', + url: '/console/notification-channels', }; const programs = [ { code: 'NOTIFICATION_CHANNELS', - path: '/system/notification-channels', + path: '/console/notification-channels', permissions: ['NOTIFICATION_CHANNEL_READ'], }, ]; diff --git a/frontend/tests/system/accessibility-smoke.spec.ts b/frontend/tests/system/accessibility-smoke.spec.ts index d05ef180c..fd8c6fef8 100644 --- a/frontend/tests/system/accessibility-smoke.spec.ts +++ b/frontend/tests/system/accessibility-smoke.spec.ts @@ -4,14 +4,14 @@ import { runServiceA11ySmoke, type A11ySmokeRoute } from "../helpers/axe"; const appRoutes: A11ySmokeRoute[] = [ { label: "dashboard", - path: "/dashboard/", + path: "/console/dashboard/", ready: async (page) => { await expect(page.getByRole("main")).toContainText(/dashboard|대시보드|ダッシュボード/i); }, }, { label: "account settings profile", - path: "/settings/account/profile/", + path: "/settings/account/profile", ready: async (page) => { await expect( page.getByRole("heading", { name: /profile|프로필|プロフィール/i }), diff --git a/frontend/tests/system/logs.spec.ts b/frontend/tests/system/logs.spec.ts index 7b793ba17..744e0cdb3 100644 --- a/frontend/tests/system/logs.spec.ts +++ b/frontend/tests/system/logs.spec.ts @@ -1,14 +1,21 @@ import { test, expect } from '@playwright/test'; -import { setupTestContext, json, MODES, type Tab } from '../helpers/test-context'; +import { + mockAppBootstrapApis, + setupTestContext, + json, + MODES, + type Tab, +} from '../helpers/test-context'; +import { dialogByName, dialogWithText, toastLocator } from '../helpers/overlays'; // ───────────────────────────────────────────── // Mock Data // ───────────────────────────────────────────── const programs = [ - { code: 'ERROR_LOGS', path: '/system/error-logs', permissions: ['ERROR_LOG_READ'] }, - { code: 'AUDIT_LOGS', path: '/system/audit-logs', permissions: ['AUDIT_LOG_READ'] }, - { code: 'LOGIN_HISTORY', path: '/system/login-history', permissions: ['LOGIN_HISTORY_READ'] }, + { code: 'ERROR_LOGS', path: '/console/error-logs', permissions: ['ERROR_LOG_READ'] }, + { code: 'API_AUDIT_LOG', path: '/console/api-audit-logs', permissions: ['API_AUDIT_LOG_READ'] }, + { code: 'LOGIN_HISTORY', path: '/console/login-history', permissions: ['LOGIN_HISTORY_READ'] }, ]; const menuTree = [ @@ -31,8 +38,8 @@ const menuTree = [ id: 'audit-logs', name: 'Audit Logs', icon: 'FileText', - program: 'AUDIT_LOGS', - permissions: ['AUDIT_LOG_READ'], + program: 'API_AUDIT_LOG', + permissions: ['API_AUDIT_LOG_READ'], children: [], }, { @@ -138,9 +145,13 @@ const errorLogTab: Tab = { id: 'error-logs', title: 'Error Logs', icon: 'AlertCircle', - url: '/system/error-logs', + url: '/console/error-logs', }; +test.beforeEach(async ({ page }) => { + await mockAppBootstrapApis(page); +}); + for (const mode of MODES) { test.describe('에러 로그', { tag: '@system' }, () => { test('행 선택 → Detail → 상세 다이얼로그 → Delete → 삭제 성공', async ({ page }) => { @@ -169,7 +180,7 @@ for (const mode of MODES) { }); await page.route('**/api/v1/error-logs/err-1/audit', async (route) => { - await route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }); + await route.fulfill({ status: 200, contentType: 'application/json', body: 'null' }); }); const ctx = await setupTestContext(page, { @@ -188,12 +199,10 @@ for (const mode of MODES) { await ctx.grid.getByRole('button', { name: 'Detail' }).click(); // 상세 다이얼로그 확인 - const detailModal = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Error Log Detail' }); + const detailModal = dialogByName(ctx.dialog, 'Error Log Detail'); await expect(detailModal).toBeVisible({ timeout: 5000 }); await expect(detailModal.getByText('Stack Trace')).toBeVisible(); - await expect(detailModal.getByText('NullPointerException', { exact: true })).toBeVisible(); + await expect(detailModal.getByText(/java\.lang\.NullPointerException/)).toBeVisible(); // 다이얼로그 닫기 await detailModal.getByRole('button', { name: 'Close' }).click(); @@ -201,12 +210,12 @@ for (const mode of MODES) { // Delete 클릭 → confirm → 삭제 await ctx.grid.getByRole('button', { name: 'Delete', exact: true }).click(); - const confirmDialog = ctx.dialog.locator('dialog.modal').filter({ hasText: 'Delete' }).last(); + const confirmDialog = dialogWithText(ctx.dialog, 'Delete'); await expect(confirmDialog).toBeVisible(); await confirmDialog.getByRole('button', { name: 'Delete', exact: true }).click(); await expect.poll(() => deleteCalled).toBe(true); - await expect(ctx.dialog.locator('#toast-container')).toContainText('Deleted'); + await expect(toastLocator(ctx.dialog)).toContainText('Deleted'); }); }); } @@ -219,7 +228,7 @@ const auditLogTab: Tab = { id: 'audit-logs', title: 'Audit Logs', icon: 'FileText', - url: '/system/audit-logs', + url: '/console/api-audit-logs', }; for (const mode of MODES) { @@ -228,7 +237,7 @@ for (const mode of MODES) { const auditLog = makeAuditLog(); let deleteAllCalled = false; - await page.route('**/api/v1/audit-logs?**', async (route) => { + await page.route('**/api/v1/api-audit-logs?**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', @@ -236,7 +245,7 @@ for (const mode of MODES) { }); }); - await page.route('**/api/v1/audit-logs/aud-1', async (route) => { + await page.route('**/api/v1/api-audit-logs/aud-1', async (route) => { if (route.request().method() !== 'GET') return route.continue(); await route.fulfill({ status: 200, @@ -245,7 +254,7 @@ for (const mode of MODES) { }); }); - await page.route('**/api/v1/audit-logs/aud-1/errors', async (route) => { + await page.route('**/api/v1/api-audit-logs/aud-1/errors', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', @@ -261,7 +270,7 @@ for (const mode of MODES) { }); }); - await page.route('**/api/v1/audit-logs', async (route) => { + await page.route('**/api/v1/api-audit-logs', async (route) => { if (route.request().method() !== 'DELETE') return route.continue(); deleteAllCalled = true; await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); @@ -269,7 +278,7 @@ for (const mode of MODES) { const ctx = await setupTestContext(page, { mode, - permissions: ['AUDIT_LOG_READ', 'AUDIT_LOG_WRITE'], + permissions: ['API_AUDIT_LOG_READ', 'API_AUDIT_LOG_WRITE'], tab: auditLogTab, programs, menuTree, @@ -281,9 +290,7 @@ for (const mode of MODES) { // 더블클릭 → 상세 다이얼로그 await ctx.grid.locator('.tabulator-row').first().dblclick(); - const detailModal = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Audit Log Detail' }); + const detailModal = dialogByName(ctx.dialog, 'Audit Log Detail'); await expect(detailModal).toBeVisible({ timeout: 5000 }); await expect(detailModal.getByText('Request Params')).toBeVisible(); await expect(detailModal.getByText('Related Errors (1)')).toBeVisible(); @@ -294,12 +301,12 @@ for (const mode of MODES) { // Delete All → confirm → 삭제 await ctx.grid.getByRole('button', { name: 'Delete All' }).click(); - const confirmDialog = ctx.dialog.locator('dialog.modal').filter({ hasText: 'Delete All' }); + const confirmDialog = dialogWithText(ctx.dialog, 'Delete All'); await expect(confirmDialog).toBeVisible(); await confirmDialog.getByRole('button', { name: 'Delete All' }).click(); await expect.poll(() => deleteAllCalled).toBe(true); - await expect(ctx.dialog.locator('#toast-container')).toContainText('All logs deleted'); + await expect(toastLocator(ctx.dialog)).toContainText('All logs deleted'); }); }); } @@ -312,7 +319,7 @@ const loginHistoryTab: Tab = { id: 'login-history', title: 'Login History', icon: 'LogIn', - url: '/system/login-history', + url: '/console/login-history', }; for (const mode of MODES) { @@ -342,9 +349,7 @@ for (const mode of MODES) { // 더블클릭 → 상세 다이얼로그 await ctx.grid.locator('.tabulator-row').first().dblclick(); - const detailModal = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Login History Detail' }); + const detailModal = dialogByName(ctx.dialog, 'Login History Detail'); await expect(detailModal).toBeVisible({ timeout: 5000 }); // Username, IP, Fail Reason 확인 @@ -353,7 +358,7 @@ for (const mode of MODES) { await expect(detailModal.getByText('Wrong Password')).toBeVisible(); // 다이얼로그 닫기 - await detailModal.getByRole('button', { name: 'Close' }).click(); + await detailModal.getByRole('button', { name: 'OK' }).click(); await expect(detailModal).not.toBeVisible(); // Delete 버튼 없음 확인 @@ -371,14 +376,14 @@ test.describe('팝업 자동 닫힘', { tag: '@system' }, () => { test('감사 로그 상세 → 연관 에러 팝업 열기 → 모달 닫기 → 팝업도 닫혀야 함', async ({ page }) => { const auditLog = makeAuditLog(); - await page.route('**/api/v1/audit-logs?**', (route) => + await page.route('**/api/v1/api-audit-logs?**', (route) => route.fulfill(json(pageResponse([auditLog]))) ); - await page.route('**/api/v1/audit-logs/aud-1', (route) => { + await page.route('**/api/v1/api-audit-logs/aud-1', (route) => { if (route.request().method() !== 'GET') return route.continue(); return route.fulfill(json(auditLog)); }); - await page.route('**/api/v1/audit-logs/aud-1/errors', (route) => + await page.route('**/api/v1/api-audit-logs/aud-1/errors', (route) => route.fulfill( json([ { @@ -397,7 +402,7 @@ test.describe('팝업 자동 닫힘', { tag: '@system' }, () => { const ctx = await setupTestContext(page, { mode: 'standalone', - permissions: ['AUDIT_LOG_READ'], + permissions: ['API_AUDIT_LOG_READ'], tab: auditLogTab, programs, menuTree, @@ -407,7 +412,7 @@ test.describe('팝업 자동 닫힘', { tag: '@system' }, () => { // 더블클릭 → 상세 모달 await ctx.grid.locator('.tabulator-row').first().dblclick(); - const detailModal = ctx.dialog.locator('dialog.modal').filter({ hasText: 'Audit Log Detail' }); + const detailModal = dialogByName(ctx.dialog, 'Audit Log Detail'); await expect(detailModal).toBeVisible({ timeout: 5000 }); await expect(detailModal.getByText('Related Errors (1)')).toBeVisible(); diff --git a/frontend/tests/system/menu-runtime-smoke.spec.ts b/frontend/tests/system/menu-runtime-smoke.spec.ts index 881fe6bff..34cc29a66 100644 --- a/frontend/tests/system/menu-runtime-smoke.spec.ts +++ b/frontend/tests/system/menu-runtime-smoke.spec.ts @@ -46,7 +46,7 @@ const detailCases: DetailCase[] = [ } return { - path: `/system/workspaces/${workspace.id}`, + path: `/console/workspaces/${workspace.id}`, headingText: workspace.name, }; }, @@ -66,7 +66,7 @@ const detailCases: DetailCase[] = [ } return { - path: `/my-workspaces/${workspace.id}`, + path: `/console/my-workspaces/${workspace.id}`, headingText: workspace.name, }; }, diff --git a/frontend/tests/system/menus-icon-picker.spec.ts b/frontend/tests/system/menus-icon-picker.spec.ts index e24e18013..3f7ded257 100644 --- a/frontend/tests/system/menus-icon-picker.spec.ts +++ b/frontend/tests/system/menus-icon-picker.spec.ts @@ -16,12 +16,12 @@ const MENU_WRITE_PERMISSIONS = [ const menuPrograms = [ { code: "SYSTEM_MENUS", - path: "/system/menus", + path: "/settings/platform/menus", permissions: ["MENU_MANAGEMENT_READ"], }, { code: "MENU_MANAGEMENT", - path: "/system/menus", + path: "/settings/platform/menus", permissions: ["MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE"], }, ]; @@ -37,6 +37,7 @@ const roleMenuTree = [ name: "System Menu", icon: "Settings", program: "MENU_MANAGEMENT", + managementType: "PLATFORM_MANAGED", permissions: ["MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE"], children: [], }, @@ -46,7 +47,7 @@ const menusTab: Tab = { id: "menus", title: "Menus", icon: "Menu", - url: "/system/menus", + url: "/settings/platform/menus", }; async function selectAdminRole(page: Page) { @@ -99,6 +100,12 @@ for (const mode of MODES) { .last(); await expect(createModal).toBeVisible(); + const managementTypeSelect = createModal.getByRole("combobox", { + name: "Management Type", + }); + await expect(managementTypeSelect).toBeVisible(); + await expect(managementTypeSelect).toContainText("User-managed"); + const iconTrigger = createModal.getByRole("button", { name: "Open icon picker", }); diff --git a/frontend/tests/system/menus.spec.ts b/frontend/tests/system/menus.spec.ts index bfb902af4..6e4f689b8 100644 --- a/frontend/tests/system/menus.spec.ts +++ b/frontend/tests/system/menus.spec.ts @@ -7,6 +7,7 @@ import { type Tab, } from "../helpers/test-context"; import { roleTreeRoutePattern } from "../helpers/session"; +import { dialogWithText, toastLocator } from "../helpers/overlays"; const MENU_WRITE_PERMISSIONS = [ "MENU_MANAGEMENT_READ", @@ -16,17 +17,17 @@ const MENU_WRITE_PERMISSIONS = [ const menuPrograms = [ { code: "SYSTEM_MENUS", - path: "/system/menus", + path: "/settings/platform/menus", permissions: ["MENU_MANAGEMENT_READ"], }, { code: "MENU_MANAGEMENT", - path: "/system/menus", + path: "/settings/platform/menus", permissions: ["MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE"], }, { code: "USER_MANAGEMENT", - path: "/system/users", + path: "/console/users", permissions: ["USER_MANAGEMENT_READ", "USER_MANAGEMENT_WRITE"], }, ]; @@ -34,9 +35,10 @@ const menuPrograms = [ const shellMenuTree = [ { id: "system", - name: "System", + name: "Platform", icon: "Settings", program: null, + managementType: "PLATFORM_MANAGED", permissions: [], children: [ { @@ -44,6 +46,7 @@ const shellMenuTree = [ name: "Menus", icon: "Menu", program: "SYSTEM_MENUS", + managementType: "PLATFORM_MANAGED", permissions: ["MENU_MANAGEMENT_READ"], children: [], }, @@ -62,6 +65,7 @@ const roleMenuTree = [ name: "System Menu", icon: "Settings", program: "MENU_MANAGEMENT", + managementType: "PLATFORM_MANAGED", permissions: ["MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE"], children: [], }, @@ -71,7 +75,7 @@ const menusTab: Tab = { id: "menus", title: "Menus", icon: "Menu", - url: "/system/menus", + url: "/settings/platform/menus", }; async function selectAdminRole(page: Page) { @@ -175,20 +179,24 @@ for (const mode of MODES) { await expect(nameInput).toHaveValue("System Menu"); await nameInput.fill("System Menu Updated"); + const managementTypeSelect = ctx.grid.getByRole("combobox", { + name: "Management Type", + }); + await expect(managementTypeSelect).toContainText("Platform-managed"); + await ctx.grid.getByRole("button", { name: "Save" }).click(); await expect.poll(() => updateCalled).toBe(true); expect(updateBody).toMatchObject({ name: "System Menu Updated", program: "MENU_MANAGEMENT", + managementType: "PLATFORM_MANAGED", permissions: expect.arrayContaining([ "MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE", ]), }); - await expect(ctx.dialog.locator("#toast-container")).toContainText( - "Saved", - ); + await expect(toastLocator(ctx.dialog)).toContainText("Saved"); }); test("관리자가 다른 역할 메뉴를 복사한다", async ({ page }) => { @@ -229,12 +237,9 @@ for (const mode of MODES) { await expect(ctx.grid.getByText("Copy from...")).toBeVisible(); await ctx.grid.getByText("Copy from...").click(); - await ctx.grid.getByRole("button", { name: "Manager" }).click(); + await page.getByRole("menuitem", { name: "Manager" }).click(); - const confirmDialog = ctx.dialog - .locator("dialog.modal") - .filter({ hasText: "Copy Menus" }) - .last(); + const confirmDialog = dialogWithText(ctx.dialog, "Copy Menus"); await expect(confirmDialog).toBeVisible(); await confirmDialog.getByRole("button", { name: "Copy" }).click(); @@ -243,9 +248,7 @@ for (const mode of MODES) { sourceRoleId: "role-manager", targetRoleId: "role-admin", }); - await expect(ctx.dialog.locator("#toast-container")).toContainText( - "Menus copied", - ); + await expect(toastLocator(ctx.dialog)).toContainText("Menus copied"); }); }); } diff --git a/frontend/tests/system/notification-channels-refresh.spec.ts b/frontend/tests/system/notification-channels-refresh.spec.ts index 54b66daef..178c936f7 100644 --- a/frontend/tests/system/notification-channels-refresh.spec.ts +++ b/frontend/tests/system/notification-channels-refresh.spec.ts @@ -10,7 +10,7 @@ const notificationChannelsTab: Tab = { id: 'notification-channels', title: 'Channels', icon: 'Bell', - url: '/system/notification-channels', + url: '/console/notification-channels', }; test.describe('알림 채널 standalone 새로고침', { tag: '@system' }, () => { diff --git a/frontend/tests/system/notification-management.spec.ts b/frontend/tests/system/notification-management.spec.ts index f1ce69b36..b5f62a596 100644 --- a/frontend/tests/system/notification-management.spec.ts +++ b/frontend/tests/system/notification-management.spec.ts @@ -1,15 +1,22 @@ import { test, expect, type Page } from '@playwright/test'; -import { setupTestContext, MODES, json, type Tab } from '../helpers/test-context'; +import { + mockAppBootstrapApis, + setupTestContext, + MODES, + json, + type Tab, +} from '../helpers/test-context'; +import { dialogByName, dialogWithText, toastLocator } from '../helpers/overlays'; const notificationPrograms = [ { code: 'NOTIFICATION_CHANNELS', - path: '/system/notification-channels', + path: '/console/notification-channels', permissions: ['NOTIFICATION_MANAGEMENT_READ'], }, { code: 'NOTIFICATION_RULES', - path: '/system/notification-rules', + path: '/console/notification-rules', permissions: ['NOTIFICATION_MANAGEMENT_READ'], }, ]; @@ -17,7 +24,7 @@ const notificationPrograms = [ const notificationMenuTree = [ { id: 'system', - name: 'System', + name: 'Platform', icon: 'Settings', program: null, permissions: [], @@ -46,16 +53,24 @@ const notificationChannelsTab: Tab = { id: 'notification-channels', title: 'Notification Channels', icon: 'Bell', - url: '/system/notification-channels', + url: '/console/notification-channels', }; const notificationRulesTab: Tab = { id: 'notification-rules', title: 'Notification Rules', icon: 'SlidersHorizontal', - url: '/system/notification-rules', + url: '/console/notification-rules', }; +const notificationEventTypes = [ + { + code: 'USER_WELCOME', + label: 'User Welcome', + variables: [{ name: 'userName', description: 'User name' }], + }, +]; + function makeChannel(overrides: Record = {}) { return { id: 'ch-1', @@ -110,6 +125,13 @@ async function setupProviderTypeRoute(page: Page) { }); } +test.beforeEach(async ({ page }) => { + await mockAppBootstrapApis(page); + await page.route('**/api/v1/notification-rules/event-types', (route) => + route.fulfill(json(notificationEventTypes)) + ); +}); + for (const mode of MODES) { test.describe('알림 채널 관리', { tag: '@system' }, () => { test('관리자가 이메일 채널을 생성한다', async ({ page }) => { @@ -161,15 +183,16 @@ for (const mode of MODES) { await ctx.grid.getByRole('button', { name: 'Create' }).click(); await expect(ctx.dialog.getByText('Create Channel')).toBeVisible(); - const modal = ctx.dialog.locator('dialog.modal').last(); - const channelTypeSelect = modal.locator('select[name="channelType"]'); + const modal = dialogByName(ctx.dialog, 'Create Channel'); + const channelTypeSelect = modal.getByRole('combobox', { name: 'Channel Type' }); await expect(channelTypeSelect).toBeVisible(); - await channelTypeSelect.selectOption('EMAIL'); + await channelTypeSelect.click(); + await page.getByRole('option', { name: 'Email' }).click(); - const providerSelect = modal.locator('select[name="providerType"]'); + const providerSelect = modal.getByRole('combobox', { name: 'Provider' }); await expect(providerSelect).toBeEnabled(); - await expect(providerSelect.locator('option[value="RESEND"]')).toHaveCount(1); - await providerSelect.selectOption('RESEND'); + await providerSelect.click(); + await page.getByRole('option', { name: 'Resend' }).click(); await modal.getByPlaceholder('e.g., Production Email').fill('Ops Email'); await modal.getByPlaceholder('re_xxx...').fill('re_test_key'); @@ -221,15 +244,12 @@ for (const mode of MODES) { await ctx.grid.locator('.tabulator-row').first().click(); await ctx.grid.getByRole('button', { name: 'Delete' }).click(); - const confirmDialog = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Delete Channel' }) - .last(); + const confirmDialog = dialogWithText(ctx.dialog, 'Delete Channel'); await expect(confirmDialog).toBeVisible(); - await confirmDialog.getByRole('button', { name: 'Confirm' }).click(); + await confirmDialog.getByRole('button', { name: 'Delete' }).click(); await expect.poll(() => deleteCalled).toBe(true); - await expect(ctx.dialog.locator('#toast-container')).toContainText('Channel deleted'); + await expect(toastLocator(ctx.dialog)).toContainText('Channel deleted'); }); test('읽기 권한만 있으면 CRUD 버튼이 숨겨진다', async ({ page }) => { diff --git a/frontend/tests/system/sidebar-collapsed-dropdown.spec.ts b/frontend/tests/system/sidebar-collapsed-dropdown.spec.ts index ef660d5cf..69595ad82 100644 --- a/frontend/tests/system/sidebar-collapsed-dropdown.spec.ts +++ b/frontend/tests/system/sidebar-collapsed-dropdown.spec.ts @@ -7,7 +7,7 @@ const CALENDAR_PERMISSIONS = ['CALENDAR_READ']; const programs = [ { code: 'CALENDAR_SETTINGS', - path: '/calendar-settings/', + path: '/console/calendar-integrations/', permissions: CALENDAR_PERMISSIONS, }, ]; @@ -36,7 +36,7 @@ const integrationsTab: Tab = { id: 'integrations', title: 'Integrations', icon: 'LayoutGrid', - url: '/calendar-settings/', + url: '/console/calendar-integrations/', }; const calendarConnections = []; @@ -47,7 +47,7 @@ const providerStatuses = [ ]; async function setupCalendarSidebar(page: Page) { - await page.route('**/api/v1/system-settings', (route) => + await page.route('**/api/v1/platform-settings', (route) => route.fulfill( json({ brandName: 'Deck', diff --git a/frontend/tests/system/sidebar.spec.ts b/frontend/tests/system/sidebar.spec.ts index 369eb00b5..75621b8a1 100644 --- a/frontend/tests/system/sidebar.spec.ts +++ b/frontend/tests/system/sidebar.spec.ts @@ -1,12 +1,12 @@ import { test, expect } from '@playwright/test'; -import { setupTestContext, type Tab } from '../helpers/test-context'; +import { mockAppBootstrapApis, setupTestContext, type Tab } from '../helpers/test-context'; -const programs = [{ code: 'USERS', path: '/system/users', permissions: ['USER_MANAGEMENT_READ'] }]; +const programs = [{ code: 'USERS', path: '/console/users', permissions: ['USER_MANAGEMENT_READ'] }]; const menuTree = [ { id: 'system', - name: 'System', + name: 'Platform', icon: 'Settings', program: null, permissions: [], @@ -27,7 +27,7 @@ const usersTab: Tab = { id: 'users', title: 'Users', icon: 'Users', - url: '/system/users', + url: '/console/users', }; const emptyUsersPage = { @@ -47,6 +47,7 @@ test.describe('LNB 시나리오', { tag: '@system' }, () => { body: JSON.stringify(emptyUsersPage), }); }); + await mockAppBootstrapApis(page); await setupTestContext(page, { mode: 'standalone', @@ -71,8 +72,100 @@ test.describe('LNB 시나리오', { tag: '@system' }, () => { await expect(sidebar).toHaveAttribute('data-state', 'collapsed'); // 키보드 단축키 (Cmd/Ctrl+B)로 다시 펼치기 - const shortcut = process.platform === 'darwin' ? 'Meta+B' : 'Control+B'; - await page.keyboard.press(shortcut); + await page.evaluate(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'b', + metaKey: true, + ctrlKey: true, + bubbles: true, + }) + ); + }); await expect(sidebar).toHaveAttribute('data-state', 'expanded'); }); + + test('PLATFORM_MANAGED 메뉴는 일반 사용자 LNB에서 숨겨져야 함', async ({ page }) => { + await page.route('**/api/v1/users?**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(emptyUsersPage), + }); + }); + await mockAppBootstrapApis(page); + + await setupTestContext(page, { + mode: 'standalone', + permissions: ['USER_MANAGEMENT_READ'], + tab: usersTab, + isOwner: false, + programs, + menuTree: [ + { + id: 'platform', + name: 'Platform', + icon: 'Settings', + program: null, + permissions: [], + children: [ + { + id: 'users', + name: 'Users', + icon: 'Users', + program: 'USERS', + managementType: 'PLATFORM_MANAGED', + permissions: ['USER_MANAGEMENT_READ'], + children: [], + }, + ], + }, + ], + shell: true, + }); + + await expect(page.getByText('Users')).toHaveCount(0); + }); + + test('PLATFORM_MANAGED 메뉴는 platform admin LNB에서는 보여야 함', async ({ page }) => { + await page.route('**/api/v1/users?**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(emptyUsersPage), + }); + }); + await mockAppBootstrapApis(page); + + await setupTestContext(page, { + mode: 'standalone', + permissions: ['USER_MANAGEMENT_READ'], + tab: usersTab, + isOwner: true, + programs, + menuTree: [ + { + id: 'platform', + name: 'Platform', + icon: 'Settings', + program: null, + permissions: [], + children: [ + { + id: 'users', + name: 'Users', + icon: 'Users', + program: 'USERS', + managementType: 'PLATFORM_MANAGED', + permissions: ['USER_MANAGEMENT_READ'], + children: [], + }, + ], + }, + ], + shell: true, + }); + + await expect(page.getByText('Users')).toBeVisible(); + }); }); diff --git a/frontend/tests/system/standalone-menu-smoke.spec.ts b/frontend/tests/system/standalone-menu-smoke.spec.ts index d8df8ccb0..b7b47da4d 100644 --- a/frontend/tests/system/standalone-menu-smoke.spec.ts +++ b/frontend/tests/system/standalone-menu-smoke.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test"; import { json, setupTestContext, type Tab } from "../helpers/test-context"; import { getDefaultTestBaseUrl } from "../helpers/test-context-url"; -type ManagedType = "USER_MANAGED" | "SYSTEM_MANAGED" | null; +type ManagedType = "USER_MANAGED" | "PLATFORM_MANAGED" | null; interface StandaloneMenuCase { name: string; @@ -59,14 +59,14 @@ async function trackJsonRoute( } async function mockStandaloneBootstrap(page: Page, baseUrl: string) { - await page.route("**/api/v1/system-settings", (route) => + await page.route("**/api/v1/platform-settings", (route) => route.fulfill( json({ brandName: "Deck", baseUrl, workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, useSelector: true, }, }), @@ -80,7 +80,7 @@ async function mockStandaloneBootstrap(page: Page, baseUrl: string) { id: "ws-bootstrap-1", name: "Bootstrap Workspace", role: "OWNER", - managedType: "SYSTEM_MANAGED", + managedType: "PLATFORM_MANAGED", owners: [{ id: "owner-1", name: "Owner", email: "owner@deck.io" }], memberCount: 1, createdAt: "2026-03-27T00:00:00Z", @@ -115,7 +115,7 @@ const menuCases: StandaloneMenuCase[] = [ activeUsersCount: 0, errorStats: [], pendingInvitesCount: 0, - systemStatus: { + platformStatus: { emailEnabled: false, slackEnabled: false, activeNotificationChannels: 0, @@ -130,7 +130,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "users", title: "Users", icon: "Users", - url: "/system/users", + url: "/console/users", }, permissions: ["USER_MANAGEMENT_READ"], programCode: "USER_MANAGEMENT", @@ -145,7 +145,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "menus", title: "Menus", icon: "Menu", - url: "/system/menus", + url: "/settings/platform/menus", }, permissions: ["MENU_MANAGEMENT_READ"], programCode: "MENU_MANAGEMENT", @@ -167,7 +167,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "activity-logs", title: "Activity Logs", icon: "Activity", - url: "/system/activity-logs", + url: "/console/activity-logs", }, permissions: ["ACTIVITY_LOG_READ"], programCode: "ACTIVITY_LOGS", @@ -186,7 +186,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "api-audit-logs", title: "API Audit Logs", icon: "ShieldCheck", - url: "/system/api-audit-logs", + url: "/console/api-audit-logs", }, permissions: ["API_AUDIT_LOG_READ"], programCode: "API_AUDIT_LOGS", @@ -205,10 +205,10 @@ const menuCases: StandaloneMenuCase[] = [ id: "audit-logs", title: "Audit Logs", icon: "ScrollText", - url: "/system/audit-logs", + url: "/console/api-audit-logs", }, permissions: ["API_AUDIT_LOG_READ"], - programCode: "AUDIT_LOGS", + programCode: "API_AUDIT_LOG", managedType: null, expectedRequests: 1, setup: (page) => @@ -224,7 +224,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "email-templates", title: "Email Templates", icon: "Mail", - url: "/system/email-templates", + url: "/console/email-templates", }, permissions: ["EMAIL_TEMPLATE_MANAGEMENT_READ"], programCode: "EMAIL_TEMPLATES", @@ -243,7 +243,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "slack-templates", title: "Slack Templates", icon: "MessageSquare", - url: "/system/slack-templates", + url: "/console/slack-templates", }, permissions: ["SLACK_TEMPLATE_MANAGEMENT_READ"], programCode: "SLACK_TEMPLATES", @@ -262,7 +262,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "error-logs", title: "Error Logs", icon: "TriangleAlert", - url: "/system/error-logs", + url: "/console/error-logs", }, permissions: ["ERROR_LOG_READ"], programCode: "ERROR_LOGS", @@ -277,7 +277,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "login-history", title: "Login History", icon: "LogIn", - url: "/system/login-history", + url: "/console/login-history", }, permissions: ["LOGIN_HISTORY_READ"], programCode: "LOGIN_HISTORY", @@ -296,7 +296,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "notification-channels", title: "Channels", icon: "Bell", - url: "/system/notification-channels", + url: "/console/notification-channels", }, permissions: ["NOTIFICATION_MANAGEMENT_READ"], programCode: "NOTIFICATION_CHANNELS", @@ -311,7 +311,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "notification-rules", title: "Rules", icon: "BellRing", - url: "/system/notification-rules", + url: "/console/notification-rules", }, permissions: ["NOTIFICATION_MANAGEMENT_READ"], programCode: "NOTIFICATION_RULES", @@ -330,18 +330,18 @@ const menuCases: StandaloneMenuCase[] = [ id: "workspaces", title: "Workspaces", icon: "Building2", - url: "/system/workspaces", + url: "/console/workspaces", }, permissions: ["WORKSPACE_MANAGEMENT_READ"], programCode: "WORKSPACE_MANAGEMENT", - managedType: "SYSTEM_MANAGED", + managedType: "PLATFORM_MANAGED", expectedRequests: 1, setup: (page) => trackJsonRoute(page, /\/api\/v1\/workspaces$/, [ { id: "ws-system-1", name: "Core Workspace", - managedType: "SYSTEM_MANAGED", + managedType: "PLATFORM_MANAGED", owners: [{ id: "owner-1", name: "Owner", email: "owner@deck.io" }], memberCount: 3, createdAt: "2026-03-27T00:00:00Z", @@ -355,11 +355,11 @@ const menuCases: StandaloneMenuCase[] = [ id: "my-workspaces", title: "My Workspaces", icon: "Building2", - url: "/my-workspaces", + url: "/console/my-workspaces", }, permissions: ["MY_WORKSPACE_READ"], programCode: "MY_WORKSPACE", - managedType: null, + managedType: "USER_MANAGED", expectedRequests: 2, setup: (page) => trackJsonRoute(page, /\/api\/v1\/my-workspaces$/, [ @@ -367,7 +367,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "ws-my-1", name: "My Workspace", role: "OWNER", - managedType: "SYSTEM_MANAGED", + managedType: "PLATFORM_MANAGED", owners: [{ id: "owner-1", name: "Owner", email: "owner@deck.io" }], memberCount: 2, createdAt: "2026-03-27T00:00:00Z", diff --git a/frontend/tests/system/templates.spec.ts b/frontend/tests/system/templates.spec.ts index 36ae646cf..7960173e8 100644 --- a/frontend/tests/system/templates.spec.ts +++ b/frontend/tests/system/templates.spec.ts @@ -1,15 +1,22 @@ import { test, expect } from '@playwright/test'; -import { setupTestContext, MODES, json, type Tab } from '../helpers/test-context'; +import { + mockAppBootstrapApis, + setupTestContext, + MODES, + json, + type Tab, +} from '../helpers/test-context'; +import { dialogByName, dialogWithText, toastLocator } from '../helpers/overlays'; const templatePrograms = [ { code: 'EMAIL_TEMPLATES', - path: '/system/email-templates', + path: '/console/email-templates', permissions: ['EMAIL_TEMPLATE_MANAGEMENT_READ'], }, { code: 'SLACK_TEMPLATES', - path: '/system/slack-templates', + path: '/console/slack-templates', permissions: ['SLACK_TEMPLATE_MANAGEMENT_READ'], }, ]; @@ -17,7 +24,7 @@ const templatePrograms = [ const templateMenuTree = [ { id: 'system', - name: 'System', + name: 'Platform', icon: 'Settings', program: null, permissions: [], @@ -46,16 +53,24 @@ const emailTemplatesTab: Tab = { id: 'email-templates', title: 'Email Templates', icon: 'Mail', - url: '/system/email-templates', + url: '/console/email-templates', }; const slackTemplatesTab: Tab = { id: 'slack-templates', title: 'Slack Templates', icon: 'MessageSquare', - url: '/system/slack-templates', + url: '/console/slack-templates', }; +const notificationEventTypes = [ + { + code: 'USER_WELCOME', + label: 'User Welcome', + variables: [{ name: 'userName', description: 'User name' }], + }, +]; + function pageResponse(items: T[]) { return { content: items, @@ -105,13 +120,20 @@ function makeSlackTemplate(overrides: Record = {}) { }; } +test.beforeEach(async ({ page }) => { + await mockAppBootstrapApis(page); + await page.route('**/api/v1/notification-rules/event-types', (route) => + route.fulfill(json(notificationEventTypes)) + ); +}); + for (const mode of MODES) { test.describe('이메일 템플릿 관리', { tag: '@system' }, () => { test('관리자가 생성 모달을 열고 폼이 정상 로드된다', async ({ page }) => { await page.route('**/api/v1/email-templates?**', (route) => route.fulfill(json(pageResponse([makeEmailTemplate()]))) ); - await page.route('**/api/v1/system-settings', (route) => + await page.route('**/api/v1/platform-settings', (route) => route.fulfill(json({ brandName: 'Deck' })) ); @@ -127,10 +149,7 @@ for (const mode of MODES) { await expect(ctx.grid.locator('.tabulator-row').first()).toBeVisible({ timeout: 10000 }); await ctx.grid.getByRole('button', { name: 'Create' }).click(); - const modalDialog = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Create Template' }) - .last(); + const modalDialog = dialogByName(ctx.dialog, 'Create Template'); await expect(modalDialog).toBeVisible(); await expect(modalDialog.getByPlaceholder('e.g., user-password-issued')).toBeVisible({ @@ -173,13 +192,13 @@ for (const mode of MODES) { await ctx.grid.locator('.tabulator-row').first().click(); await ctx.grid.getByRole('button', { name: 'Delete' }).click(); - const confirmDialog = ctx.dialog.locator('dialog.modal').filter({ hasText: 'Delete' }).last(); + const confirmDialog = dialogWithText(ctx.dialog, 'Delete'); await expect(confirmDialog).toBeVisible(); await confirmDialog.getByRole('button', { name: 'Delete' }).click(); await expect.poll(() => deleteCalled).toBe(true); await expect.poll(() => listCallCount).toBeGreaterThan(1); - await expect(ctx.dialog.locator('#toast-container')).toContainText('Deleted'); + await expect(toastLocator(ctx.dialog)).toContainText('Deleted'); }); test('관리자는 built-in 템플릿을 삭제할 수 없다', async ({ page }) => { @@ -212,9 +231,7 @@ for (const mode of MODES) { await ctx.grid.getByRole('button', { name: 'Delete' }).click(); await expect.poll(() => deleteCalled).toBe(false); - await expect(ctx.dialog.locator('#toast-container')).toContainText( - 'Cannot delete built-in template' - ); + await expect(toastLocator(ctx.dialog)).toContainText('Cannot delete built-in template'); }); }); } @@ -238,10 +255,7 @@ for (const mode of MODES) { await expect(ctx.grid.locator('.tabulator-row').first()).toBeVisible({ timeout: 10000 }); await ctx.grid.getByRole('button', { name: 'Create' }).click(); - const modalDialog = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Create Slack Template' }) - .last(); + const modalDialog = dialogByName(ctx.dialog, 'Create Slack Template'); await expect(modalDialog).toBeVisible(); await expect(modalDialog.getByPlaceholder('e.g., system-alert')).toBeVisible({ @@ -284,13 +298,13 @@ for (const mode of MODES) { await ctx.grid.locator('.tabulator-row').first().click(); await ctx.grid.getByRole('button', { name: 'Delete' }).click(); - const confirmDialog = ctx.dialog.locator('dialog.modal').filter({ hasText: 'Delete' }).last(); + const confirmDialog = dialogWithText(ctx.dialog, 'Delete'); await expect(confirmDialog).toBeVisible(); await confirmDialog.getByRole('button', { name: 'Delete' }).click(); await expect.poll(() => deleteCalled).toBe(true); await expect.poll(() => listCallCount).toBeGreaterThan(1); - await expect(ctx.dialog.locator('#toast-container')).toContainText('Deleted'); + await expect(toastLocator(ctx.dialog)).toContainText('Deleted'); }); test('관리자는 built-in 템플릿을 삭제할 수 없다', async ({ page }) => { @@ -323,9 +337,7 @@ for (const mode of MODES) { await ctx.grid.getByRole('button', { name: 'Delete' }).click(); await expect.poll(() => deleteCalled).toBe(false); - await expect(ctx.dialog.locator('#toast-container')).toContainText( - 'Cannot delete built-in template' - ); + await expect(toastLocator(ctx.dialog)).toContainText('Cannot delete built-in template'); }); }); } diff --git a/frontend/tests/system/users.spec.ts b/frontend/tests/system/users.spec.ts index 48fdd873c..ee27fd3a7 100644 --- a/frontend/tests/system/users.spec.ts +++ b/frontend/tests/system/users.spec.ts @@ -1,5 +1,13 @@ import { test, expect } from '@playwright/test'; -import { setupTestContext, MODES, type Tab } from '../helpers/test-context'; +import { + mockAppBootstrapApis, + setupTestContext, + MODES, + type Tab, +} from '../helpers/test-context'; +import { dialogByName, dialogWithText, toastLocator } from '../helpers/overlays'; + +const nameFieldSelector = { name: /^Name/ } as const; // ───────────────────────────────────────────── // Mock Data @@ -22,12 +30,12 @@ const meta = { }, }; -const programs = [{ code: 'USERS', path: '/system/users', permissions: ['USER_MANAGEMENT_READ'] }]; +const programs = [{ code: 'USERS', path: '/console/users', permissions: ['USER_MANAGEMENT_READ'] }]; const menuTree = [ { id: 'system', - name: 'System', + name: 'Platform', icon: 'Settings', program: null, permissions: [], @@ -96,10 +104,81 @@ const usersTab: Tab = { id: 'users', title: 'Users', icon: 'Users', - url: '/system/users', + url: '/console/users', }; const WRITE_PERMISSIONS = ['USER_MANAGEMENT_READ', 'USER_MANAGEMENT_WRITE']; +const defaultContactFieldConfig = { + countryCodes: ['KR', 'US'], + defaultCountryCode: 'KR', + hideCountrySelector: false, + countries: [ + { + countryCode: 'KR', + address: { + mode: 'PROVIDER_SEARCH', + provider: 'KAKAO_POSTCODE', + fields: [ + { key: 'POSTAL_CODE', required: true, readOnly: true }, + { key: 'REGION', required: false, readOnly: true }, + { key: 'CITY', required: false, readOnly: true }, + { key: 'ADDRESS_LINE1', required: true, readOnly: true }, + { key: 'ADDRESS_LINE2', required: false }, + ], + }, + phone: { + presentationFormat: 'LOCAL_DASHED', + storageFormat: 'DIGITS_ONLY', + validationPattern: + '^(01[016789]\\d{7,8}|02\\d{7,8}|0[3-6][1-9]\\d{7,8}|1[5-9]\\d{6})$', + validationMessage: 'Invalid phone number', + }, + organizationIdentifier: { + idType: 'KR_BRN', + labelVariant: 'KR_BRN', + usesLookup: true, + exampleValue: '123-45-67890', + }, + }, + { + countryCode: 'US', + address: { + mode: 'MANUAL', + provider: null, + fields: [ + { key: 'POSTAL_CODE', required: true }, + { key: 'REGION', required: true }, + { key: 'CITY', required: true }, + { key: 'ADDRESS_LINE1', required: true }, + { key: 'ADDRESS_LINE2', required: false }, + ], + }, + phone: { + presentationFormat: 'PLAIN', + storageFormat: 'OPTIONAL_PLUS_DIGITS', + validationPattern: '^\\d{6,15}$', + validationMessage: 'Invalid phone number', + }, + organizationIdentifier: { + idType: 'US_EIN', + labelVariant: 'US_EIN', + usesLookup: false, + exampleValue: '12-3456789', + }, + }, + ], +} as const; + +test.beforeEach(async ({ page }) => { + await mockAppBootstrapApis(page); + await page.route('**/api/v1/users/contact-field-config**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(defaultContactFieldConfig), + }); + }); +}); function setupRolesRoute(page: import('@playwright/test').Page) { return page.route('**/api/v1/roles', async (route) => { @@ -155,16 +234,14 @@ for (const mode of MODES) { await expect(ctx.dialog.getByText('Create User')).toBeVisible(); // 모달 iframe 내 폼 작성 - const modal = ctx.dialog.locator('dialog.modal').last(); + const modal = dialogByName(ctx.dialog, 'Create User'); await expect(modal.getByPlaceholder('Username', { exact: true })).toBeVisible(); await modal.getByPlaceholder('Username', { exact: true }).fill('newuser'); await modal.getByPlaceholder('Password').fill('Password1!'); await modal.getByPlaceholder('Confirm').fill('Password1!'); - await modal.getByRole('textbox', { name: 'Name', exact: true }).fill('New User'); - await modal - .getByRole('textbox', { name: 'name@example.com', exact: true }) - .fill('new@example.com'); + await modal.getByRole('textbox', nameFieldSelector).fill('New User'); + await modal.getByPlaceholder('name@example.com', { exact: true }).fill('new@example.com'); await modal.getByRole('radio', { name: 'User' }).click(); // Save → API 호출 확인 @@ -207,7 +284,7 @@ for (const mode of MODES) { const ctx = await setupTestContext(page, { mode, permissions: WRITE_PERMISSIONS, - tab: { ...usersTab, url: '/system/users?userId=u1' }, + tab: { ...usersTab, url: '/console/users?userId=u1' }, isOwner: true, programs, menuTree, @@ -218,8 +295,8 @@ for (const mode of MODES) { await expect(ctx.dialog.getByText('Edit User')).toBeVisible(); // 모달 iframe에서 폼 로딩 대기 → 이름 변경 → Save - const modal = ctx.dialog.locator('dialog.modal').last(); - const nameField = modal.getByRole('textbox', { name: 'Name', exact: true }); + const modal = dialogByName(ctx.dialog, 'Edit User'); + const nameField = modal.getByRole('textbox', nameFieldSelector); await expect(nameField).toHaveValue('User One'); await nameField.fill('Updated Name'); @@ -282,7 +359,7 @@ for (const mode of MODES) { const ctx = await setupTestContext(page, { mode, permissions: WRITE_PERMISSIONS, - tab: { ...usersTab, url: '/system/users?userId=u1' }, + tab: { ...usersTab, url: '/console/users?userId=u1' }, isOwner: true, programs, menuTree, @@ -291,10 +368,8 @@ for (const mode of MODES) { await expect(ctx.dialog.getByText('Edit User')).toBeVisible(); - const modal = ctx.dialog.locator('dialog.modal').last(); - await expect(modal.getByRole('textbox', { name: 'Name', exact: true })).toHaveValue( - 'User One' - ); + const modal = dialogByName(ctx.dialog, 'Edit User'); + await expect(modal.getByRole('textbox', nameFieldSelector)).toHaveValue('User One'); await modal.getByTestId('postcode-search').click(); @@ -350,7 +425,7 @@ for (const mode of MODES) { const ctx = await setupTestContext(page, { mode, permissions: WRITE_PERMISSIONS, - tab: { ...usersTab, url: '/system/users?userId=u1' }, + tab: { ...usersTab, url: '/console/users?userId=u1' }, isOwner: true, programs, menuTree, @@ -361,16 +436,13 @@ for (const mode of MODES) { await expect(ctx.dialog.getByText('Edit User')).toBeVisible(); // iframe 폼 로딩 대기 → Reset PW 클릭 - const modal = ctx.dialog.locator('dialog.modal').last(); - const nameField = modal.getByRole('textbox', { name: 'Name', exact: true }); + const modal = dialogByName(ctx.dialog, 'Edit User'); + const nameField = modal.getByRole('textbox', nameFieldSelector); await expect(nameField).toHaveValue('User One'); await modal.getByRole('button', { name: 'Reset PW' }).click(); // 확인 다이얼로그 (부모 페이지에 렌더) - const resetDialog = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Reset Password' }) - .last(); + const resetDialog = dialogWithText(ctx.dialog, 'Reset Password'); await expect(resetDialog).toBeVisible(); await resetDialog.getByRole('button', { name: 'Reset' }).click(); @@ -378,16 +450,13 @@ for (const mode of MODES) { await expect.poll(() => resetPwCalled).toBe(true); // 임시 비밀번호 결과 다이얼로그 - const tempPwDialog = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'TmpPass12!@' }) - .last(); + const tempPwDialog = dialogWithText(ctx.dialog, 'TmpPass12!@'); await expect(tempPwDialog).toBeVisible(); await expect(tempPwDialog.getByText('TmpPass12!@')).toBeVisible(); // Copy 버튼 → 클립보드 복사 + "Copied" 토스트 await tempPwDialog.locator('button[title="Copy"]').click(); - await expect(ctx.dialog.getByText('Copied')).toBeVisible(); + await expect(toastLocator(ctx.dialog)).toContainText('Copied'); // OK로 닫기 await tempPwDialog.getByRole('button', { name: 'OK' }).click(); @@ -436,14 +505,14 @@ for (const mode of MODES) { await ctx.grid.getByRole('button', { name: 'Delete' }).click(); // 확인 다이얼로그 → Delete - const confirmDialog = ctx.dialog.locator('dialog.modal').filter({ hasText: 'Delete' }).last(); + const confirmDialog = dialogWithText(ctx.dialog, 'Delete'); await expect(confirmDialog).toBeVisible(); await confirmDialog.getByRole('button', { name: 'Delete' }).click(); // API 호출 + 목록 갱신 + 토스트 확인 await expect.poll(() => deleteCalled).toBe(true); await expect.poll(() => listCallCount).toBeGreaterThan(1); - await expect(ctx.dialog.locator('#toast-container')).toContainText('Deleted'); + await expect(toastLocator(ctx.dialog)).toContainText('Deleted'); }); test('Owner가 사용자를 초대한다', async ({ page }) => { @@ -486,10 +555,7 @@ for (const mode of MODES) { // Invite 버튼 클릭 → 다이얼로그 await ctx.grid.getByRole('button', { name: 'Invite' }).click(); - const inviteDialog = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Invite User' }) - .last(); + const inviteDialog = dialogWithText(ctx.dialog, 'Invite User'); await expect(inviteDialog).toBeVisible(); // 이메일 입력 + 역할 선택 → Send @@ -538,7 +604,7 @@ for (const mode of MODES) { const ctx = await setupTestContext(page, { mode, permissions: WRITE_PERMISSIONS, - tab: { ...usersTab, url: '/system/users?userId=u1' }, + tab: { ...usersTab, url: '/console/users?userId=u1' }, isOwner: true, programs, menuTree, @@ -549,17 +615,12 @@ for (const mode of MODES) { await expect(ctx.dialog.getByText('Edit User')).toBeVisible(); // iframe 폼 로딩 대기 → Approve 클릭 - const modal = ctx.dialog.locator('dialog.modal').last(); - await expect(modal.getByRole('textbox', { name: 'Name', exact: true })).toHaveValue( - 'User One' - ); + const modal = dialogByName(ctx.dialog, 'Edit User'); + await expect(modal.getByRole('textbox', nameFieldSelector)).toHaveValue('User One'); await modal.getByRole('button', { name: 'Approve' }).click(); // 확인 다이얼로그 → Approve - const confirmDialog = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Approve User' }) - .last(); + const confirmDialog = dialogWithText(ctx.dialog, 'Approve User'); await expect(confirmDialog).toBeVisible(); await confirmDialog.getByRole('button', { name: 'Approve' }).click(); @@ -602,7 +663,7 @@ for (const mode of MODES) { const ctx = await setupTestContext(page, { mode, permissions: WRITE_PERMISSIONS, - tab: { ...usersTab, url: '/system/users?userId=u1' }, + tab: { ...usersTab, url: '/console/users?userId=u1' }, isOwner: true, programs, menuTree, @@ -613,17 +674,12 @@ for (const mode of MODES) { await expect(ctx.dialog.getByText('Edit User')).toBeVisible(); // iframe 폼 로딩 대기 → Reject 클릭 - const modal = ctx.dialog.locator('dialog.modal').last(); - await expect(modal.getByRole('textbox', { name: 'Name', exact: true })).toHaveValue( - 'User One' - ); + const modal = dialogByName(ctx.dialog, 'Edit User'); + await expect(modal.getByRole('textbox', nameFieldSelector)).toHaveValue('User One'); await modal.getByRole('button', { name: 'Reject' }).click(); // 확인 다이얼로그 → Reject - const confirmDialog = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Reject User' }) - .last(); + const confirmDialog = dialogWithText(ctx.dialog, 'Reject User'); await expect(confirmDialog).toBeVisible(); await confirmDialog.getByRole('button', { name: 'Reject' }).click(); @@ -667,7 +723,7 @@ for (const mode of MODES) { const ctx = await setupTestContext(page, { mode, permissions: WRITE_PERMISSIONS, - tab: { ...usersTab, url: '/system/users?userId=u1' }, + tab: { ...usersTab, url: '/console/users?userId=u1' }, isOwner: true, programs, menuTree, @@ -678,17 +734,12 @@ for (const mode of MODES) { await expect(ctx.dialog.getByText('Edit User')).toBeVisible(); // iframe 폼 로딩 대기 → Unlock 클릭 - const modal = ctx.dialog.locator('dialog.modal').last(); - await expect(modal.getByRole('textbox', { name: 'Name', exact: true })).toHaveValue( - 'User One' - ); + const modal = dialogByName(ctx.dialog, 'Edit User'); + await expect(modal.getByRole('textbox', nameFieldSelector)).toHaveValue('User One'); await modal.getByRole('button', { name: 'Unlock' }).click(); // 확인 다이얼로그 → Unlock - const confirmDialog = ctx.dialog - .locator('dialog.modal') - .filter({ hasText: 'Unlock Account' }) - .last(); + const confirmDialog = dialogWithText(ctx.dialog, 'Unlock Account'); await expect(confirmDialog).toBeVisible(); await confirmDialog.getByRole('button', { name: 'Unlock' }).click(); @@ -719,8 +770,7 @@ for (const mode of MODES) { await expect(ctx.grid.locator('.tabulator-row').first()).toBeVisible(); - // Reset(읽기)만 보이고, Create/Edit/Delete는 없어야 함 - await expect(ctx.grid.getByRole('button', { name: 'Reset', exact: true })).toBeVisible(); + // 쓰기 액션은 숨기고, 목록 조회만 가능해야 함 await expect(ctx.grid.getByRole('button', { name: 'Create' })).toHaveCount(0); await expect(ctx.grid.getByRole('button', { name: 'Edit' })).toHaveCount(0); await expect(ctx.grid.getByRole('button', { name: 'Delete' })).toHaveCount(0); @@ -758,7 +808,7 @@ for (const mode of MODES) { const ctx = await setupTestContext(page, { mode, permissions: WRITE_PERMISSIONS, - tab: { ...usersTab, url: '/system/users?userId=u-admin' }, + tab: { ...usersTab, url: '/console/users?userId=u-admin' }, isOwner: true, programs, menuTree, @@ -769,10 +819,8 @@ for (const mode of MODES) { await expect(ctx.dialog.getByText('Edit User')).toBeVisible(); // iframe 폼 로딩 대기 - const modal = ctx.dialog.locator('dialog.modal').last(); - await expect(modal.getByRole('textbox', { name: 'Name', exact: true })).toHaveValue( - 'User One' - ); + const modal = dialogByName(ctx.dialog, 'Edit User'); + await expect(modal.getByRole('textbox', nameFieldSelector)).toHaveValue('User One'); // 자기 자신이므로 관리 버튼이 모두 보이지 않아야 함 await expect(modal.getByRole('button', { name: 'Approve' })).toHaveCount(0); diff --git a/frontend/tests/system/workspace-detail-route.spec.ts b/frontend/tests/system/workspace-detail-route.spec.ts index 0d3128f51..1cd5a140e 100644 --- a/frontend/tests/system/workspace-detail-route.spec.ts +++ b/frontend/tests/system/workspace-detail-route.spec.ts @@ -5,7 +5,7 @@ type WorkspaceSummary = { id: string; name: string; role?: string; - managedType: "SYSTEM_MANAGED" | "USER_MANAGED"; + managedType: "PLATFORM_MANAGED" | "USER_MANAGED"; owners?: Array<{ id: string; name: string; email: string }>; memberCount?: number; createdAt?: string; @@ -15,7 +15,7 @@ type WorkspaceSummary = { const systemPrograms = [ { code: "WORKSPACE_MANAGEMENT", - path: "/system/workspaces/", + path: "/console/workspaces/", permissions: ["WORKSPACE_MANAGEMENT_READ"], }, ]; @@ -23,7 +23,7 @@ const systemPrograms = [ const myWorkspacePrograms = [ { code: "MY_WORKSPACE", - path: "/my-workspaces/", + path: "/console/my-workspaces/", permissions: [], }, ]; @@ -31,7 +31,7 @@ const myWorkspacePrograms = [ const systemMenuTree = [ { id: "system", - name: "System", + name: "Platform", icon: "Settings", program: null, permissions: [], @@ -62,7 +62,7 @@ const myWorkspaceMenuTree = [ const systemWorkspace: WorkspaceSummary = { id: "ws-system-1", name: "Core Workspace", - managedType: "SYSTEM_MANAGED", + managedType: "PLATFORM_MANAGED", owners: [{ id: "owner-1", name: "Owner", email: "owner@deck.io" }], memberCount: 3, createdAt: "2026-03-23T00:00:00Z", @@ -80,6 +80,18 @@ const myWorkspace: WorkspaceSummary = { updatedAt: "2026-03-23T00:00:00Z", }; +const externalMyWorkspace: WorkspaceSummary = { + id: "ws-my-external-1", + name: "Synced Workspace", + role: "OWNER", + managedType: "PLATFORM_MANAGED", + owners: [{ id: "owner-1", name: "Owner", email: "owner@deck.io" }], + memberCount: 8, + createdAt: "2026-03-23T00:00:00Z", + updatedAt: "2026-03-23T00:00:00Z", + externalReference: { source: "AIP", externalId: "aip-org-1" }, +}; + const workspaceMembers = [ { userId: "owner-1", @@ -91,13 +103,13 @@ const workspaceMembers = [ ]; async function mockWorkspaceApis(page: Page) { - await page.route("**/api/v1/system-settings", (route) => + await page.route("**/api/v1/platform-settings", (route) => route.fulfill( json({ brandName: "Deck", workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, useSelector: true, }, }), @@ -130,7 +142,7 @@ test.describe("workspace detail route", { tag: "@system" }, () => { id: "workspace-management", title: "Workspaces", icon: "Building2", - url: "/system/workspaces/", + url: "/console/workspaces/", }; await setupTestContext(page, { @@ -142,13 +154,13 @@ test.describe("workspace detail route", { tag: "@system" }, () => { shell: true, }); - await page.goto(`/system/workspaces/${systemWorkspace.id}`, { + await page.goto(`/console/workspaces/${systemWorkspace.id}`, { waitUntil: "networkidle", }); await expect .poll(() => pathnameOf(page), { timeout: 30_000 }) - .toBe(`/system/workspaces/${systemWorkspace.id}`); + .toBe(`/console/workspaces/${systemWorkspace.id}`); await expect( page.locator('[data-testid="system-layout-header"] h1'), ).toContainText(systemWorkspace.name); @@ -156,12 +168,12 @@ test.describe("workspace detail route", { tag: "@system" }, () => { await page.reload({ waitUntil: "networkidle" }); await expect .poll(() => pathnameOf(page), { timeout: 30_000 }) - .toBe(`/system/workspaces/${systemWorkspace.id}`); + .toBe(`/console/workspaces/${systemWorkspace.id}`); await page.locator('[data-testid="system-layout-header"] h1 button').click(); await expect .poll(() => pathnameOf(page), { timeout: 30_000 }) - .toBe("/system/workspaces/"); + .toBe("/console/workspaces/"); }); test("my workspace detail은 direct URL 복원과 breadcrumb back이 동작해야 함", async ({ @@ -172,7 +184,7 @@ test.describe("workspace detail route", { tag: "@system" }, () => { id: "my-workspace", title: "My Workspace", icon: "Building2", - url: "/my-workspaces/", + url: "/console/my-workspaces/", }; await setupTestContext(page, { @@ -184,13 +196,13 @@ test.describe("workspace detail route", { tag: "@system" }, () => { shell: true, }); - await page.goto(`/my-workspaces/${myWorkspace.id}`, { + await page.goto(`/console/my-workspaces/${myWorkspace.id}`, { waitUntil: "networkidle", }); await expect .poll(() => pathnameOf(page), { timeout: 30_000 }) - .toBe(`/my-workspaces/${myWorkspace.id}`); + .toBe(`/console/my-workspaces/${myWorkspace.id}`); await expect( page.locator('[data-testid="system-layout-header"] h1'), ).toContainText(myWorkspace.name); @@ -198,6 +210,46 @@ test.describe("workspace detail route", { tag: "@system" }, () => { await page.locator('[data-testid="system-layout-header"] h1 button').click(); await expect .poll(() => pathnameOf(page), { timeout: 30_000 }) - .toBe("/my-workspaces/"); + .toBe("/console/my-workspaces/"); + }); + + test("external my workspace detail은 read-only로 렌더링되어야 함", async ({ page }) => { + await mockWorkspaceApis(page); + await page.route("**/api/v1/my-workspaces", (route) => + route.fulfill(json([externalMyWorkspace])), + ); + const tab: Tab = { + id: "my-workspace", + title: "My Workspace", + icon: "Building2", + url: "/console/my-workspaces/", + }; + + await setupTestContext(page, { + mode: "standalone", + permissions: [], + tab, + programs: myWorkspacePrograms, + menuTree: myWorkspaceMenuTree, + shell: true, + }); + + await page.goto(`/console/my-workspaces/${externalMyWorkspace.id}`, { + waitUntil: "networkidle", + }); + + await expect + .poll(() => pathnameOf(page), { timeout: 30_000 }) + .toBe(`/console/my-workspaces/${externalMyWorkspace.id}`); + await expect(page.locator("#my-workspace-info-form")).toHaveCount(0); + await expect(page.locator("#my-workspace-name")).toHaveValue(externalMyWorkspace.name); + await expect(page.locator("#my-workspace-name")).toHaveAttribute("readonly", ""); + await expect(page.locator("#my-workspace-owner")).toHaveAttribute("readonly", ""); + await expect(page.locator('[data-testid="my-workspace-member-actions"]')).toHaveCount(0); + await expect( + page.getByText( + "External workspaces are synced from an external source and cannot be modified in Deck.", + ), + ).toBeVisible(); }); });