From dd1574f326876da14cec61a6c12b319360be8beb Mon Sep 17 00:00:00 2001 From: keIIy-kim Date: Fri, 3 Apr 2026 22:31:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(frontend/app):=20=ED=94=8C?= =?UTF-8?q?=EB=9E=AB=ED=8F=BC=20=EC=85=B8=EA=B3=BC=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=ED=94=84=EB=A5=BC=20=EC=9E=AC=EC=A0=95=EB=A0=AC=ED=95=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Platform/Workspace/Account 용어와 console/settings 라우팅을 정리합니다 - external workspace lock과 AIP sync 경계를 반영합니다 - platform settings, seeds, reference 문서를 새 계약에 맞게 갱신합니다 Co-Authored-By: OpenAI Codex --- .../app/controller/MyWorkspaceController.kt | 4 +- .../app/controller/WorkspaceController.kt | 8 +- .../io/deck/app/controller/WorkspaceDtos.kt | 35 +- .../kotlin/io/deck/app/init/DevDataSeeder.kt | 17 +- .../UserNotificationDispatchResolver.kt | 6 +- .../io/deck/app/service/DashboardService.kt | 10 +- .../resources/db/migration/app/V1__init.sql | 90 ++- .../app/controller/DashboardControllerTest.kt | 6 +- .../controller/MyWorkspaceControllerTest.kt | 53 +- .../app/controller/WorkspaceControllerTest.kt | 64 +- .../io/deck/app/init/DevDataSeederTest.kt | 17 +- .../UserNotificationDispatchResolverTest.kt | 6 +- .../AppMigrationMenuPermissionsTest.kt | 37 +- .../deck/app/service/DashboardServiceTest.kt | 8 +- .../deck/audit/service/ApiAuditLogService.kt | 4 +- .../kotlin/io/deck/common/BrandingProvider.kt | 2 +- .../main/kotlin/io/deck/common/CacheNames.kt | 2 +- .../common/api/event/ActivityTargetType.kt | 2 +- .../service/CrmContactProfileService.kt | 6 +- .../deck/deskpie/init/DeskPieDevDataSeeder.kt | 2 +- .../shared/CrmContactProfileServiceTest.kt | 8 +- .../deskpie/init/DeskPieDevDataSeederTest.kt | 10 +- .../io/deck/iam/api/DevSeedUserManager.kt | 6 + .../kotlin/io/deck/iam/api/MenuSeedCommand.kt | 1 + ...ettingQuery.kt => PlatformSettingQuery.kt} | 2 +- .../io/deck/iam/api/WorkspaceDirectory.kt | 8 +- .../io/deck/iam/api/WorkspaceManagedType.kt | 2 +- .../kotlin/io/deck/iam/api/WorkspaceRecord.kt | 13 +- .../deck/iam/config/BrandingProviderImpl.kt | 8 +- .../io/deck/iam/controller/AuthController.kt | 6 +- .../deck/iam/controller/BrandingController.kt | 8 +- .../io/deck/iam/controller/MenuController.kt | 24 +- .../kotlin/io/deck/iam/controller/MenuDtos.kt | 4 + ... PlatformSettingAuthProviderController.kt} | 4 +- ...roller.kt => PlatformSettingController.kt} | 72 +-- ...mSettingDtos.kt => PlatformSettingDtos.kt} | 6 +- .../io/deck/iam/controller/UserController.kt | 6 +- .../iam/domain/ExternalOrganizationClaim.kt | 7 + .../io/deck/iam/domain/ExternalReference.kt | 10 + .../kotlin/io/deck/iam/domain/MenuEntity.kt | 7 + ...tingEntity.kt => PlatformSettingEntity.kt} | 8 +- .../kotlin/io/deck/iam/domain/UserEntity.kt | 2 +- .../io/deck/iam/domain/WorkspaceEntity.kt | 16 +- .../deck/iam/domain/WorkspaceManagedType.kt | 2 +- .../io/deck/iam/domain/WorkspacePolicy.kt | 6 +- .../io/deck/iam/event/IamActivityLogType.kt | 16 +- .../deck/iam/registry/IamProgramRegistrar.kt | 24 +- .../repository/PlatformSettingRepository.kt | 18 + .../iam/repository/SystemSettingRepository.kt | 18 - .../iam/repository/WorkspaceRepository.kt | 4 +- .../OAuth2AuthenticationSuccessHandler.kt | 33 +- .../iam/security/OAuth2UserInfoExtractor.kt | 55 +- .../kotlin/io/deck/iam/service/AuthService.kt | 16 +- .../iam/service/DevSeedUserManagerImpl.kt | 14 + .../io/deck/iam/service/InviteService.kt | 4 +- .../deck/iam/service/MenuSeedCommandImpl.kt | 10 + .../kotlin/io/deck/iam/service/MenuService.kt | 8 + .../deck/iam/service/OAuthProviderService.kt | 4 +- ...ngService.kt => PlatformSettingService.kt} | 82 +-- .../kotlin/io/deck/iam/service/UserService.kt | 138 +++- .../iam/service/WorkspaceDirectoryImpl.kt | 29 +- .../iam/service/WorkspaceInviteService.kt | 29 +- .../iam/service/WorkspaceMemberService.kt | 18 +- .../io/deck/iam/service/WorkspaceService.kt | 40 +- .../main/resources/messages-iam.properties | 1 + .../main/resources/messages-iam_ja.properties | 1 + .../main/resources/messages-iam_ko.properties | 1 + .../iam/controller/AuthControllerLoginTest.kt | 8 +- .../iam/controller/AuthControllerMeTest.kt | 8 +- .../controller/AuthControllerRefreshTest.kt | 8 +- .../iam/controller/BrandingControllerTest.kt | 34 +- .../deck/iam/controller/MenuControllerTest.kt | 32 +- ...tformSettingAuthProviderControllerTest.kt} | 6 +- ...st.kt => PlatformSettingControllerTest.kt} | 92 +-- .../deck/iam/controller/UserControllerTest.kt | 14 +- .../io/deck/iam/domain/WorkspaceEntityTest.kt | 18 +- .../io/deck/iam/security/OAuth2LinkingTest.kt | 66 +- .../iam/security/OwnerOnlyAnnotationTest.kt | 12 +- .../io/deck/iam/service/AuthServiceTest.kt | 146 ++++- .../iam/service/MenuSeedCommandImplTest.kt | 63 ++ .../io/deck/iam/service/MenuServiceTest.kt | 8 +- .../iam/service/OAuthProviderServiceTest.kt | 72 +-- ....kt => PlatformSettingServiceCacheTest.kt} | 90 +-- ...eTest.kt => PlatformSettingServiceTest.kt} | 406 ++++++------ .../deck/iam/service/ProgramRegistryTest.kt | 25 +- .../io/deck/iam/service/UserServiceTest.kt | 190 +++++- .../iam/service/WorkspaceInviteServiceTest.kt | 106 +++ .../iam/service/WorkspaceMemberServiceTest.kt | 69 ++ .../deck/iam/service/WorkspaceServiceTest.kt | 109 +++- .../registry/NotificationProgramRegistrar.kt | 8 +- ...platform-reset-and-workspace-scope-plan.md | 605 ++++++++++++++++++ ...4-03-platform-reset-and-workspace-scope.md | 369 +++++++++++ .../backend/encryption-architecture.md | 2 +- docs/reference/backend/globalization.md | 4 +- docs/reference/backend/oauth-setup.md | 4 +- docs/reference/backend/party.md | 2 +- docs/reference/common-rules.md | 3 +- docs/reference/frontend/features.md | 10 +- docs/reference/frontend/router.md | 77 ++- docs/reference/frontend/rules.md | 8 +- docs/reference/frontend/tabulator.md | 4 +- docs/reference/legal-pages.md | 4 +- docs/reference/meetpie.md | 8 +- docs/reference/workspace.md | 128 ++-- frontend/app/package.json | 4 +- frontend/app/playwright.account.config.ts | 9 + frontend/app/playwright.config.ts | 4 - frontend/app/src/app/App.tsx | 15 +- frontend/app/src/app/app.test.tsx | 42 +- frontend/app/src/app/auth.test.ts | 10 +- frontend/app/src/app/bootstrap.test.ts | 14 +- frontend/app/src/app/bootstrap.ts | 6 +- .../CommandPaletteProvider.test.tsx | 12 +- .../CommandPaletteProvider.tsx | 6 +- .../src/app/header/NotificationBell.test.tsx | 2 +- .../app/src/app/header/NotificationBell.tsx | 8 +- frontend/app/src/app/page-access.ts | 10 +- .../app/src/app/page-registry-routes.test.ts | 6 +- frontend/app/src/app/page-registry.test.ts | 75 ++- frontend/app/src/app/page-registry.ts | 31 +- frontend/app/src/app/reset.ts | 4 +- .../src/app/sidebar/SidebarWrapper.test.tsx | 14 +- .../app/src/app/sidebar/SidebarWrapper.tsx | 4 +- .../app/src/app/tabbar/TabBarWrapper.test.tsx | 4 +- frontend/app/src/app/tabs.test.ts | 42 +- frontend/app/src/app/tabs.ts | 6 +- .../app/src/canonical-dialog-imports.test.ts | 2 +- .../app/src/canonical-field-imports.test.ts | 4 +- .../app/src/canonical-switch-imports.test.ts | 2 +- frontend/app/src/entities/dashboard/index.ts | 2 +- frontend/app/src/entities/dashboard/types.ts | 4 +- frontend/app/src/entities/menu/types.ts | 5 +- .../entities/platform-settings/api.test.ts | 82 +++ .../api.ts | 26 +- .../index.ts | 7 +- .../src/entities/platform-settings/store.ts | 18 + .../types.ts | 7 +- .../workspace-access.test.ts | 22 + .../workspace-access.ts | 2 +- .../src/entities/system-settings/api.test.ts | 80 --- .../app/src/entities/system-settings/store.ts | 18 - .../app/src/entities/workspace/store.test.ts | 6 +- frontend/app/src/entities/workspace/store.ts | 2 +- frontend/app/src/entities/workspace/types.ts | 16 +- .../app/src/entities/workspace/visibility.ts | 6 +- .../login/model/use-login-bootstrap.test.ts | 4 +- .../ui/CommandPalette.test.tsx | 90 ++- .../command-palette/ui/CommandPalette.tsx | 6 +- .../logs/ui/log-detail-modal-body.test.tsx | 4 +- .../logs/ui/log-detail-modal-body.tsx | 6 +- .../menus/manage-menus/model/types.ts | 4 + .../manage-menus/model/use-menus-page.test.ts | 10 +- .../manage-menus/model/use-menus-page.ts | 16 +- .../ui/menu-detail-panel.test.tsx | 13 +- .../manage-menus/ui/menu-detail-panel.tsx | 28 + .../menu-form/model/use-menu-form.test.tsx | 45 +- .../menus/menu-form/model/use-menu-form.ts | 45 +- .../features/menus/menu-form/ui/menu-form.tsx | 26 + .../model/use-email-template-form.ts | 2 +- .../src/features/tour/tours/example-tours.ts | 2 +- .../manage-workspaces/model/columns.test.tsx | 6 +- .../ui/workspace-detail-page.tsx | 47 +- .../ui/workspace-form-content.test.tsx | 8 +- .../ui/workspace-form-content.tsx | 12 +- .../ui/workspace-info-tab.test.tsx | 33 +- .../ui/workspace-info-tab.tsx | 26 +- .../ui/workspace-members-tab.test.tsx | 33 +- .../ui/workspace-members-tab.tsx | 79 +-- .../my-workspaces/model/columns.test.tsx | 4 +- .../ui/my-workspace-detail-page.test.tsx | 119 ++++ .../ui/my-workspace-detail-page.tsx | 14 +- .../ui/my-workspace-info-tab.test.tsx | 31 +- .../ui/my-workspace-info-tab.tsx | 10 +- .../ui/my-workspace-members-tab.test.tsx | 34 +- .../ui/my-workspace-members-tab.tsx | 6 +- .../ui/my-workspaces-page-actions.test.tsx | 89 ++- .../ui/my-workspaces-page-actions.tsx | 9 +- .../shared/workspace-owner-field.test.tsx | 10 +- frontend/app/src/layouts/console-layout.tsx | 4 + frontend/app/src/layouts/index.ts | 1 + .../layout-landmarks-standards.test.tsx | 4 +- .../src/layouts/standalone-layout.test.tsx | 4 +- .../app/src/layouts/standalone-layout.tsx | 4 +- frontend/app/src/layouts/system-layout.tsx | 2 +- .../pages/account/canonical-imports.test.ts | 11 +- .../account/profile/connections-tab.test.tsx | 14 +- .../account/profile/preferences-tab.test.tsx | 2 +- .../setting/globalization-tab.test.tsx | 196 ------ .../account/setting/globalization-tab.tsx | 254 -------- .../account/setting/setting.page.test.tsx | 376 ----------- .../pages/account/setting/setting.page.tsx | 95 --- .../account/setting/use-setting-page.test.ts | 152 ----- .../pages/account/setting/use-setting-page.ts | 58 -- .../password-change.page.test.tsx | 8 +- .../use-password-change-page.test.ts | 4 +- .../dashboard/dashboard-owner-widgets.tsx | 20 +- .../pages/dashboard/dashboard.page.test.tsx | 18 +- .../src/pages/dashboard/dashboard.page.tsx | 2 +- .../dashboard/use-dashboard-page.test.ts | 2 +- .../src/pages/dashboard/use-dashboard-page.ts | 8 +- .../src/pages/legal/content/app/privacy.en.md | 2 +- .../src/pages/legal/content/app/privacy.ko.md | 2 +- .../app/src/pages/login/login.page.test.tsx | 8 +- .../my-workspace-detail.page.test.tsx | 6 +- .../my-workspace-detail.page.tsx | 4 +- .../my-workspaces/my-workspaces.page.test.tsx | 4 +- .../my-workspaces/my-workspaces.page.tsx | 5 +- .../app/src/pages/settings/settings-nav.ts | 46 +- .../src/pages/settings/settings.page.test.tsx | 102 +-- .../app/src/pages/settings/settings.page.tsx | 44 +- .../tabs}/auth-tab.test.tsx | 56 +- .../setting => settings/tabs}/auth-tab.tsx | 10 +- .../tabs}/branding-tab.test.tsx | 30 +- .../tabs}/branding-tab.tsx | 10 +- .../tabs}/general-tab.test.tsx | 26 +- .../setting => settings/tabs}/general-tab.tsx | 20 +- .../app/src/pages/settings/tabs/menus-tab.tsx | 83 +++ .../tabs}/roles-tab.test.tsx | 0 .../setting => settings/tabs}/roles-tab.tsx | 0 .../setting => settings/tabs}/types.ts | 4 +- .../tabs}/workspace-tab.test.tsx | 29 +- .../tabs}/workspace-tab.tsx | 32 +- .../email-templates/email-templates.page.tsx | 2 +- .../pages/system/menus/menus.page.test.tsx | 6 +- .../slack-templates/slack-templates.page.tsx | 2 +- .../pages/system/users/users.page.test.tsx | 2 +- .../workspaces/workspace-detail.page.test.tsx | 6 +- .../workspaces/workspace-detail.page.tsx | 4 +- .../workspaces/workspaces.page.test.tsx | 9 +- .../system/workspaces/workspaces.page.tsx | 10 +- frontend/app/src/shared/auth-redirect.test.ts | 16 +- frontend/app/src/shared/auth-redirect.ts | 36 +- .../app/src/shared/branding/branding.test.tsx | 16 +- .../shared/globalization/policy-runtime.ts | 2 +- .../shared/hooks/use-url-param-action.test.ts | 8 +- .../http-client/default-interceptors.test.ts | 4 +- .../src/shared/i18n/locales/en/account.json | 26 +- .../src/shared/i18n/locales/en/common.json | 3 + .../src/shared/i18n/locales/en/dashboard.json | 2 +- .../src/shared/i18n/locales/en/system.json | 32 +- .../src/shared/i18n/locales/ja/account.json | 26 +- .../src/shared/i18n/locales/ja/common.json | 3 + .../src/shared/i18n/locales/ja/dashboard.json | 2 +- .../src/shared/i18n/locales/ja/system.json | 32 +- .../src/shared/i18n/locales/ko/account.json | 26 +- .../src/shared/i18n/locales/ko/common.json | 3 + .../src/shared/i18n/locales/ko/dashboard.json | 2 +- .../src/shared/i18n/locales/ko/system.json | 32 +- .../shared/router/route-descriptor.test.ts | 30 +- .../app/src/shared/router/route-descriptor.ts | 36 +- frontend/app/src/shared/utils/avatar.test.ts | 4 +- .../app/src/test/test-context-url.test.ts | 8 +- .../app/src/widgets/sidebar/Sidebar.test.tsx | 83 ++- frontend/app/src/widgets/sidebar/Sidebar.tsx | 14 +- .../app/src/widgets/tabbar/tab-bar.test.tsx | 10 +- .../src/pages/deals/deal-edit-modal.test.tsx | 6 +- .../src/pages/deals/deal-edit-modal.tsx | 4 +- .../tests/account/branding-serving.spec.ts | 69 +- frontend/tests/account/setting.spec.ts | 90 ++- .../tests/features/command-palette.spec.ts | 2 +- frontend/tests/helpers/auth-state.ts | 2 +- frontend/tests/helpers/auth.ts | 2 +- frontend/tests/helpers/menu-smoke.ts | 13 +- frontend/tests/helpers/overlays.ts | 16 + frontend/tests/helpers/test-context.ts | 8 +- .../tests/manual/app/chunk-recovery.spec.ts | 2 +- .../tests/manual/app/logs-pagination.spec.ts | 2 +- .../app/shared-registry-contracts.spec.ts | 10 +- frontend/tests/manual/app/system.spec.ts | 94 +-- .../manual/app/users-side-effects.spec.ts | 6 +- frontend/tests/profile.spec.ts | 4 +- frontend/tests/shared/splitter-layout.spec.ts | 8 +- .../tests/shared/system-layout-sticky.spec.ts | 4 +- .../tests/system/accessibility-smoke.spec.ts | 2 +- frontend/tests/system/logs.spec.ts | 73 ++- .../tests/system/menu-runtime-smoke.spec.ts | 4 +- .../tests/system/menus-icon-picker.spec.ts | 13 +- frontend/tests/system/menus.spec.ts | 35 +- .../notification-channels-refresh.spec.ts | 2 +- .../system/notification-management.spec.ts | 56 +- .../system/sidebar-collapsed-dropdown.spec.ts | 2 +- frontend/tests/system/sidebar.spec.ts | 105 ++- .../system/standalone-menu-smoke.spec.ts | 42 +- frontend/tests/system/templates.spec.ts | 62 +- frontend/tests/system/users.spec.ts | 186 ++++-- .../system/workspace-detail-route.spec.ts | 84 ++- 286 files changed, 5495 insertions(+), 3288 deletions(-) rename backend/iam/src/main/kotlin/io/deck/iam/api/{SystemSettingQuery.kt => PlatformSettingQuery.kt} (67%) rename backend/iam/src/main/kotlin/io/deck/iam/controller/{SystemSettingAuthProviderController.kt => PlatformSettingAuthProviderController.kt} (95%) rename backend/iam/src/main/kotlin/io/deck/iam/controller/{SystemSettingController.kt => PlatformSettingController.kt} (83%) rename backend/iam/src/main/kotlin/io/deck/iam/controller/{SystemSettingDtos.kt => PlatformSettingDtos.kt} (95%) create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationClaim.kt create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalReference.kt rename backend/iam/src/main/kotlin/io/deck/iam/domain/{SystemSettingEntity.kt => PlatformSettingEntity.kt} (98%) create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/repository/PlatformSettingRepository.kt delete mode 100644 backend/iam/src/main/kotlin/io/deck/iam/repository/SystemSettingRepository.kt rename backend/iam/src/main/kotlin/io/deck/iam/service/{SystemSettingService.kt => PlatformSettingService.kt} (88%) rename backend/iam/src/test/kotlin/io/deck/iam/controller/{SystemSettingAuthProviderControllerTest.kt => PlatformSettingAuthProviderControllerTest.kt} (94%) rename backend/iam/src/test/kotlin/io/deck/iam/controller/{SystemSettingControllerTest.kt => PlatformSettingControllerTest.kt} (78%) rename backend/iam/src/test/kotlin/io/deck/iam/service/{SystemSettingServiceCacheTest.kt => PlatformSettingServiceCacheTest.kt} (54%) rename backend/iam/src/test/kotlin/io/deck/iam/service/{SystemSettingServiceTest.kt => PlatformSettingServiceTest.kt} (72%) create mode 100644 docs/plans/2026-04-03-platform-reset-and-workspace-scope-plan.md create mode 100644 docs/plans/2026-04-03-platform-reset-and-workspace-scope.md create mode 100644 frontend/app/playwright.account.config.ts create mode 100644 frontend/app/src/entities/platform-settings/api.test.ts rename frontend/app/src/entities/{system-settings => platform-settings}/api.ts (68%) rename frontend/app/src/entities/{system-settings => platform-settings}/index.ts (73%) create mode 100644 frontend/app/src/entities/platform-settings/store.ts rename frontend/app/src/entities/{system-settings => platform-settings}/types.ts (91%) create mode 100644 frontend/app/src/entities/platform-settings/workspace-access.test.ts rename frontend/app/src/entities/{system-settings => platform-settings}/workspace-access.ts (93%) delete mode 100644 frontend/app/src/entities/system-settings/api.test.ts delete mode 100644 frontend/app/src/entities/system-settings/store.ts create mode 100644 frontend/app/src/features/workspaces/my-workspaces/ui/my-workspace-detail-page.test.tsx create mode 100644 frontend/app/src/layouts/console-layout.tsx delete mode 100644 frontend/app/src/pages/account/setting/globalization-tab.test.tsx delete mode 100644 frontend/app/src/pages/account/setting/globalization-tab.tsx delete mode 100644 frontend/app/src/pages/account/setting/setting.page.test.tsx delete mode 100644 frontend/app/src/pages/account/setting/setting.page.tsx delete mode 100644 frontend/app/src/pages/account/setting/use-setting-page.test.ts delete mode 100644 frontend/app/src/pages/account/setting/use-setting-page.ts rename frontend/app/src/pages/{account/setting => settings/tabs}/auth-tab.test.tsx (88%) rename frontend/app/src/pages/{account/setting => settings/tabs}/auth-tab.tsx (98%) rename frontend/app/src/pages/{account/setting => settings/tabs}/branding-tab.test.tsx (93%) rename frontend/app/src/pages/{account/setting => settings/tabs}/branding-tab.tsx (98%) rename frontend/app/src/pages/{account/setting => settings/tabs}/general-tab.test.tsx (90%) rename frontend/app/src/pages/{account/setting => settings/tabs}/general-tab.tsx (91%) create mode 100644 frontend/app/src/pages/settings/tabs/menus-tab.tsx rename frontend/app/src/pages/{account/setting => settings/tabs}/roles-tab.test.tsx (100%) rename frontend/app/src/pages/{account/setting => settings/tabs}/roles-tab.tsx (100%) rename frontend/app/src/pages/{account/setting => settings/tabs}/types.ts (83%) rename frontend/app/src/pages/{account/setting => settings/tabs}/workspace-tab.test.tsx (72%) rename frontend/app/src/pages/{account/setting => settings/tabs}/workspace-tab.tsx (80%) create mode 100644 frontend/tests/helpers/overlays.ts 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..ee02ea429 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 @@ -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())) } @@ -94,7 +94,7 @@ class MyWorkspaceController( description = request.description, requestedBy = userId, managedType = WorkspaceManagedType.USER_MANAGED, - allowedDomains = request.allowedDomains, + autoJoinDomains = request.autoJoinDomains, ) val memberCount = workspaceRoster.countByWorkspaceId(id).toInt() return ResponseEntity.ok(updated.toMyWorkspaceDto(memberCount, true, loadOwnersByWorkspaceIds(listOf(id))[id].orEmpty())) 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..2f32f5ec6 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 @@ -33,7 +33,7 @@ class WorkspaceController( private val workspaceUserLookup: WorkspaceUserLookup, ) { private fun ensureOwnerManagedEnabled() { - workspaceDirectory.ensureManagedTypeEnabled(WorkspaceManagedType.SYSTEM_MANAGED) + workspaceDirectory.ensureManagedTypeEnabled(WorkspaceManagedType.PLATFORM_MANAGED) } @GetMapping @@ -63,8 +63,8 @@ class WorkspaceController( name = request.name, description = request.description, initialOwnerId = UUID.fromString(principal.name), - managedType = request.managedType ?: WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = request.allowedDomains, + managedType = request.managedType ?: WorkspaceManagedType.PLATFORM_MANAGED, + autoJoinDomains = request.autoJoinDomains, ) val memberCount = workspaceRoster.countByWorkspaceId(workspace.id).toInt() return ResponseEntity.status(HttpStatus.CREATED).body(workspace.toWorkspaceDto(memberCount, loadOwners(workspace.id))) @@ -85,7 +85,7 @@ class WorkspaceController( 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))) 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..92c1313dc 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,5 +1,6 @@ package io.deck.app.controller +import io.deck.iam.api.ExternalReferenceRecord import io.deck.iam.api.InviteStatus import io.deck.iam.api.WorkspaceInviteRecord import io.deck.iam.api.WorkspaceManagedType @@ -7,6 +8,8 @@ import io.deck.iam.api.WorkspaceMemberRecord import io.deck.iam.api.WorkspaceRecord import io.deck.iam.api.WorkspaceUserRecord import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID data class WorkspaceOwnerDto( @@ -19,25 +22,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 externalReference: ExternalReferenceDto?, val createdAt: Instant?, val updatedAt: Instant?, ) +data class ExternalReferenceDto( + val externalId: String, +) + data class CreateWorkspaceRequest( val name: String, val description: String? = null, - val allowedDomains: List = emptyList(), + val autoJoinDomains: List = emptyList(), val managedType: WorkspaceManagedType? = null, ) data class UpdateWorkspaceRequest( val name: String, val description: String? = null, - val allowedDomains: List = emptyList(), + val autoJoinDomains: List = emptyList(), val managedType: WorkspaceManagedType? = null, ) @@ -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 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(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..9f3a29830 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 @@ -71,6 +71,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) @@ -384,10 +388,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), @@ -493,7 +497,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 +539,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 +553,7 @@ class DevDataSeeder( logger = "window.onerror", message = "ChunkLoadError: Loading chunk vendors-node_modules_tabulator failed", method = "GET", - path = "/system/audit-logs/", + path = "/console/audit-logs/", userId = adminUser.id, username = "admin", email = DEFAULT_ADMIN_EMAIL, @@ -574,7 +578,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 +755,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/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..6dd0f59ea 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,9 @@ 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_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,12 +460,12 @@ 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_selector ) VALUES ( uuid_generate_v7(), @@ -496,16 +497,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 +529,7 @@ VALUES ('019bca88-0000-7000-8000-000000000301', -- Structure: -- Dashboard -- My Workspace --- System +-- Platform -- ├─ Users -- ├─ Menus -- ├─ Workspaces @@ -556,13 +557,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 +571,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 +579,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 +587,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 +602,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 +610,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 +618,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 +626,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 +641,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 +649,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, @@ -661,7 +662,7 @@ VALUES ('019bca88-0000-7000-8000-000000000008', '019bca88-0000-7000-8000-0000000 -- Structure: -- Dashboard -- My Workspace --- System +-- Platform -- └─ Notifications (group) -- ├─ Channels -- ├─ Email @@ -680,7 +681,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 +731,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,8 +1237,9 @@ 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_id VARCHAR(255), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -1221,6 +1248,9 @@ CREATE TABLE workspaces deleted_by UUID ); +CREATE UNIQUE INDEX udx_workspaces_external_id ON workspaces (external_id) + WHERE external_id IS NOT NULL AND deleted_at IS NULL; + -- ============================================= -- Workspace Members (hard delete, no soft delete) -- ============================================= 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..fb101c52c 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 @@ -48,13 +48,14 @@ class MyWorkspaceControllerTest : name: String = "Workspace", managedType: WorkspaceManagedType = WorkspaceManagedType.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 = WorkspaceManagedType.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( @@ -164,7 +165,7 @@ class MyWorkspaceControllerTest : description = "desc", requestedBy = userId, managedType = WorkspaceManagedType.USER_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 +178,43 @@ 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 = WorkspaceManagedType.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" } } 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..61cd8ffb4 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,5 +1,6 @@ package io.deck.app.controller +import io.deck.common.exception.BadRequestException import io.deck.common.exception.NotFoundException import io.deck.iam.api.InviteStatus import io.deck.iam.api.WorkspaceDirectory @@ -46,15 +47,16 @@ class WorkspaceControllerTest : fun workspace( name: String = "Workspace", - managedType: WorkspaceManagedType = WorkspaceManagedType.SYSTEM_MANAGED, + managedType: WorkspaceManagedType = WorkspaceManagedType.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(WorkspaceManagedType.PLATFORM_MANAGED) } throws NotFoundException("iam.workspace.not_found") shouldThrow { @@ -122,14 +124,14 @@ 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( name = "New Workspace", description = "desc", initialOwnerId = userId, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = listOf("acme.com"), + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + autoJoinDomains = listOf("acme.com"), ) } returns workspace every { workspaceRoster.countByWorkspaceId(workspace.id) } returns 1L @@ -138,7 +140,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,15 +149,15 @@ 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")) + val updatedWorkspace = workspace(id = workspaceId, name = "Updated Workspace", autoJoinDomains = listOf("acme.com", "dev.acme.com")) every { workspaceDirectory.get(workspaceId) } returns workspace(id = workspaceId) every { workspaceDirectory.updateByAdmin( @@ -163,8 +165,8 @@ class WorkspaceControllerTest : name = "Updated Workspace", description = "desc", updatedBy = currentUserId, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = listOf("acme.com", "dev.acme.com"), + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + autoJoinDomains = listOf("acme.com", "dev.acme.com"), ) } returns updatedWorkspace every { workspaceRoster.countByWorkspaceId(workspaceId) } returns 1L @@ -177,13 +179,43 @@ 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.get(workspaceId) } returns workspace(id = workspaceId) + every { + workspaceDirectory.updateByAdmin( + workspaceId = workspaceId, + name = "External Workspace", + description = "desc", + updatedBy = currentUserId, + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + 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" } } 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..311efad0b 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 @@ -74,6 +74,7 @@ class DevDataSeederTest : adminUser = SeedUserSummary( id = UUID.randomUUID(), + name = "시스템 관리자", email = "admin@deck.io", passwordMustChange = true, ) @@ -136,7 +137,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" } }, ) } @@ -286,12 +287,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..041231a1e 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 = @@ -147,14 +155,23 @@ class AppMigrationMenuPermissionsTest : Regex("""\["ACTIVITY_LOG_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_id\s+VARCHAR\(255\)""").containsMatchIn(workspacesBlock) 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 } } }) 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..62caa95fd 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,8 +191,8 @@ class ApiAuditLogService( method = it.method, path = it.path, statusCode = it.statusCode, - performedBy = it.userName ?: it.email ?: "System", - createdAt = it.createdAt, + performedBy = it.userName ?: it.email ?: "Platform", + createdAt = it.createdAt.toInstant(java.time.ZoneOffset.UTC), ) } } 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/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..e12164f65 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,6 +15,7 @@ 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.api.ExternalReferenceRecord import io.deck.iam.api.MenuSeedCommand import io.deck.iam.api.SeedMenuDefinition import io.deck.iam.api.WorkspaceDirectory @@ -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 autoJoinDomains: List = emptyList() override val managedType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED - override val createdAt: Instant? = null - override val updatedAt: Instant? = null + override val externalReference: ExternalReferenceRecord? = null + override val createdAt: LocalDateTime? = null + override val updatedAt: LocalDateTime? = null } 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..05a535730 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 @@ -24,6 +24,7 @@ data class SeedMenuDefinition( val namesI18n: Map? = null, val icon: String? = null, val programType: String = MenuSeedCommand.NONE_PROGRAM_TYPE, + val managementType: WorkspaceManagedType = WorkspaceManagedType.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/WorkspaceDirectory.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceDirectory.kt index 98021cc1d..a09938d50 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 @@ -27,14 +27,14 @@ interface WorkspaceDirectory { 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( @@ -43,7 +43,7 @@ interface WorkspaceDirectory { description: String?, updatedBy: UUID, managedType: WorkspaceManagedType, - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ): WorkspaceRecord fun updateForUser( @@ -52,7 +52,7 @@ interface WorkspaceDirectory { description: String?, requestedBy: UUID, managedType: WorkspaceManagedType, - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ): WorkspaceRecord fun deleteByAdminBatch( 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 index 851986159..6a8ab25f1 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceManagedType.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceManagedType.kt @@ -2,5 +2,5 @@ package io.deck.iam.api enum class WorkspaceManagedType { USER_MANAGED, - SYSTEM_MANAGED, + PLATFORM_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..edfef104f 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,19 @@ package io.deck.iam.api -import java.time.Instant +import java.time.LocalDateTime import java.util.UUID interface WorkspaceRecord { val id: UUID val name: String val description: String? - val allowedDomains: List + val autoJoinDomains: List val managedType: WorkspaceManagedType - val createdAt: Instant? - val updatedAt: Instant? + val externalReference: ExternalReferenceRecord? + val createdAt: LocalDateTime? + val updatedAt: LocalDateTime? } + +data class ExternalReferenceRecord( + 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..4aee5c833 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.domain.WorkspaceManagedType import io.deck.iam.service.MenuService import io.deck.iam.service.PermissionRegistry import io.deck.iam.service.ProgramRegistry @@ -75,7 +76,7 @@ class MenuController( val tree = menuService .findMenuTreeByRoleId(roleId) - .map { it.toTreeDto() } + .map { it.toTreeDto(programRegistry) } return ResponseEntity.ok(tree) } @@ -95,8 +96,9 @@ class MenuController( namesI18n = request.namesI18n, icon = request.icon, programType = request.program, + managementType = request.managementType ?: WorkspaceManagedType.USER_MANAGED, ) - return ResponseEntity.ok(menu.toDto()) + return ResponseEntity.ok(menu.toDto(programRegistry)) } /** @@ -117,8 +119,9 @@ class MenuController( namesI18n = request.namesI18n, icon = request.icon, programType = request.program, + managementType = request.managementType ?: WorkspaceManagedType.USER_MANAGED, ) - return ResponseEntity.ok(menu.toDto()) + return ResponseEntity.ok(menu.toDto(programRegistry)) } /** @@ -137,9 +140,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 +161,7 @@ class MenuController( newParentId = request.parentId, newSortOrder = request.sortOrder, ) - return ResponseEntity.ok(menu.toDto()) + return ResponseEntity.ok(menu.toDto(programRegistry)) } /** @@ -186,7 +190,7 @@ class MenuController( val tree = menuService .findMenuTreeByRoleId(request.targetRoleId) - .map { it.toTreeDto() } + .map { it.toTreeDto(programRegistry) } return ResponseEntity.ok(tree) } @@ -194,7 +198,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 +206,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 +219,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..370ca7939 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 @@ -26,6 +26,7 @@ data class MenuDto( val namesI18n: Map?, val icon: String?, val program: String, + val managementType: WorkspaceManagedType, val permissions: Set, ) @@ -35,6 +36,7 @@ data class MenuTreeDto( val namesI18n: Map?, val icon: String?, val program: String, + val managementType: WorkspaceManagedType, val permissions: Set, val children: List, ) @@ -44,6 +46,7 @@ data class CreateMenuRequest( val namesI18n: Map? = null, val icon: String? = null, val program: String, + val managementType: WorkspaceManagedType? = null, ) data class UpdateMenuRequest( @@ -51,6 +54,7 @@ data class UpdateMenuRequest( val namesI18n: Map? = null, val icon: String?, val program: String, + val managementType: WorkspaceManagedType? = 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 83% 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..fb060d532 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,7 @@ private fun SystemSettingEntity.toDto(baseUrl: String): SystemSettingDto = workspacePolicy?.let { WorkspacePolicyDto( useUserManaged = it.useUserManaged, - useSystemManaged = it.useSystemManaged, + usePlatformManaged = it.usePlatformManaged, useSelector = it.useSelector, ) }, @@ -253,7 +253,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 +263,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 +277,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 +312,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 95% 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..8419a946a 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,13 @@ data class UpdateGlobalizationPolicyRequest( data class WorkspacePolicyDto( val useUserManaged: Boolean, - val useSystemManaged: Boolean, + val usePlatformManaged: Boolean, val useSelector: Boolean, ) { fun toDomain(): WorkspacePolicy = WorkspacePolicy( useUserManaged = useUserManaged, - useSystemManaged = useSystemManaged, + usePlatformManaged = usePlatformManaged, 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/ExternalReference.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalReference.kt new file mode 100644 index 000000000..a611e5f22 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalReference.kt @@ -0,0 +1,10 @@ +package io.deck.iam.domain + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +data class ExternalReference( + @Column(name = "external_id", length = 255) + var externalId: String, +) 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..3229fb44f 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 @@ -5,6 +5,8 @@ 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 +51,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: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") var parent: MenuEntity? = null, @@ -82,12 +87,14 @@ class MenuEntity( namesI18n: Map?, icon: String?, programType: String, + managementType: WorkspaceManagedType, 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 98% 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..f9162cfa8 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(), @@ -305,7 +305,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..baa1827d7 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 @@ -3,6 +3,7 @@ package io.deck.iam.domain import io.deck.common.api.entity.SoftDeleteEntity import io.deck.common.api.id.UuidUtils import jakarta.persistence.Column +import jakarta.persistence.Embedded import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated @@ -22,27 +23,32 @@ 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, + @Embedded + var externalReference: ExternalReference? = null, id: UUID? = null, ) : SoftDeleteEntity(id = id ?: UuidUtils.generate()) { init { - allowedDomains = allowedDomains.normalizeAllowedDomains() + autoJoinDomains = autoJoinDomains.normalizeAllowedDomains() } + val isExternal: Boolean + get() = externalReference != null + fun update( name: String, description: String?, - allowedDomains: List = this.allowedDomains, + autoJoinDomains: List = this.autoJoinDomains, managedType: WorkspaceManagedType = this.managedType, ) { this.name = name this.description = description - this.allowedDomains = allowedDomains.normalizeAllowedDomains() + this.autoJoinDomains = autoJoinDomains.normalizeAllowedDomains() this.managedType = managedType } } 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 index 6d7937ee0..d2cf87d68 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceManagedType.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceManagedType.kt @@ -2,5 +2,5 @@ package io.deck.iam.domain enum class WorkspaceManagedType { USER_MANAGED, - SYSTEM_MANAGED, + PLATFORM_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..e9cb2df82 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,10 @@ 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_selector") var useSelector: Boolean = true, ) { - fun normalizedOrNull(): WorkspacePolicy? = takeIf { useUserManaged || useSystemManaged } + fun normalizedOrNull(): WorkspacePolicy? = takeIf { useUserManaged || usePlatformManaged } } 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..445c94ae4 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 @@ -11,30 +11,34 @@ 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", "/console/dashboard"), + ProgramDefinition( + "MENU_MANAGEMENT", + "/settings/platform/menus", + setOf("MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE"), + ), + ProgramDefinition("USER_MANAGEMENT", "/console/users", setOf("USER_MANAGEMENT_READ", "USER_MANAGEMENT_WRITE")), + ProgramDefinition("ERROR_LOG", "/console/error-logs", setOf("ERROR_LOG_READ", "ERROR_LOG_WRITE")), + ProgramDefinition("API_AUDIT_LOG", "/console/api-audit-logs", setOf("API_AUDIT_LOG_READ", "API_AUDIT_LOG_WRITE")), + ProgramDefinition("ACTIVITY_LOG", "/console/activity-logs", setOf("ACTIVITY_LOG_READ")), ProgramDefinition( "CODEBOOK_MANAGEMENT", - "/system/codebook", + "/console/codebook", setOf("CODEBOOK_MANAGEMENT_READ", "CODEBOOK_MANAGEMENT_WRITE"), ), ProgramDefinition( "WORKSPACE_MANAGEMENT", - "/system/workspaces", + "/console/workspaces", setOf("WORKSPACE_MANAGEMENT_READ", "WORKSPACE_MANAGEMENT_WRITE"), workspace = ProgramDefinition.WorkspacePolicy( required = true, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, + managedType = WorkspaceManagedType.PLATFORM_MANAGED, ), ), ProgramDefinition( "MY_WORKSPACE", - "/my-workspaces", + "/console/my-workspaces", setOf("MY_WORKSPACE_READ", "MY_WORKSPACE_WRITE"), workspace = ProgramDefinition.WorkspacePolicy( 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/WorkspaceRepository.kt b/backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceRepository.kt index b6ccc5fca..8d65e5fbf 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 @@ -6,6 +6,8 @@ import org.springframework.data.jpa.repository.Query import java.util.UUID interface WorkspaceRepository : JpaRepository { + fun findByExternalReferenceExternalId(externalId: String): WorkspaceEntity? + @Query( """ SELECT w FROM WorkspaceEntity w @@ -33,7 +35,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..2ec8e48ee 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( @@ -161,14 +161,26 @@ class OAuth2AuthenticationSuccessHandler( // 2. AuthService를 통한 OAuth 인증 when ( val result = - authService.authenticateWithOAuth( - provider = authProvider, - providerUserId = userInfo.sub, - email = userInfo.email, - name = userInfo.name, - ipAddress = ipAddress, - userAgent = userAgent, - ) + if (userInfo.externalOrganizations.isEmpty()) { + authService.authenticateWithOAuth( + provider = authProvider, + providerUserId = userInfo.sub, + email = userInfo.email, + name = userInfo.name, + ipAddress = ipAddress, + userAgent = userAgent, + ) + } else { + authService.authenticateWithOAuth( + provider = authProvider, + providerUserId = userInfo.sub, + email = userInfo.email, + name = userInfo.name, + ipAddress = ipAddress, + userAgent = userAgent, + externalOrganizations = userInfo.externalOrganizations, + ) + } ) { is AuthResult.Failure -> { val encodedMessage = URLEncoder.encode(result.message, "UTF-8") @@ -244,6 +256,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..f27bbbaea 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,7 @@ package io.deck.iam.security import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationClaim import org.slf4j.LoggerFactory import org.springframework.security.oauth2.core.oidc.user.OidcUser import org.springframework.security.oauth2.core.user.OAuth2User @@ -12,6 +13,7 @@ data class OAuth2UserInfo( val email: String, val sub: String, val name: String, + val externalOrganizations: List = emptyList(), ) /** @@ -29,7 +31,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 +115,54 @@ 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 externalOrganizations = + sequenceOf("organizations", "orgs") + .mapNotNull { claimName -> oidcUser.claims[claimName] } + .flatMap { claimsToOrganizations(it).asSequence() } + .distinctBy { it.externalId } + .toList() + return OAuth2UserInfo(email, sub, name, externalOrganizations) + } + + private fun claimsToOrganizations(value: Any): List = + when (value) { + is Collection<*> -> value.mapNotNull(::toOrganizationClaim) + is Array<*> -> value.mapNotNull(::toOrganizationClaim) + else -> emptyList() + } + + 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 + } + } +} 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..ecee5fb32 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,7 @@ 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.SessionType import io.deck.iam.domain.UserEntity import io.deck.iam.domain.UserId @@ -386,9 +387,18 @@ class AuthService( name: String, ipAddress: String, userAgent: String?, + externalOrganizations: List = emptyList(), ): AuthResult { // 1. 사용자 조회 또는 생성 - val user = userService.findOrCreateByOAuth(provider, providerUserId, email, name) + val user = + userService.findOrCreateByOAuth( + provider = provider, + providerUserId = providerUserId, + email = email, + name = name, + externalOrganizations = externalOrganizations, + syncExternalOrganizationsOnReturn = false, + ) // 2. 사용자 상태 검증 val statusError = getOAuthStatusError(user) @@ -397,6 +407,10 @@ class AuthService( return AuthResult.Failure(statusError.second, statusError.third) } + if (provider == AuthProvider.AIP && externalOrganizations.isNotEmpty()) { + userService.syncExternalOrganizationsForOAuthUser(user, externalOrganizations) + } + // 3. JWT 토큰 생성 + 세션 생성 val tokenResult = createTokenWithSession(user, SessionType.WEB, ipAddress, userAgent) 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/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..9fe8d6d36 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 @@ -8,6 +8,8 @@ import io.deck.iam.repository.MenuRepository import io.deck.iam.repository.RoleRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import io.deck.iam.api.WorkspaceManagedType as ApiWorkspaceManagedType +import io.deck.iam.domain.WorkspaceManagedType as DomainWorkspaceManagedType @Service class MenuSeedCommandImpl( @@ -69,6 +71,7 @@ class MenuSeedCommandImpl( namesI18n = seedMenu.namesI18n, icon = seedMenu.icon, programType = seedMenu.programType, + managementType = seedMenu.managementType.toDomain(), sortOrder = nextSortOrder, permissions = seedMenu.permissions, ), @@ -86,6 +89,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 +163,7 @@ class MenuSeedCommandImpl( namesI18n = seedMenu.namesI18n, icon = seedMenu.icon, programType = seedMenu.programType, + managementType = seedMenu.managementType.toDomain(), sortOrder = nextSortOrder, permissions = seedMenu.permissions, ), @@ -179,6 +184,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 +210,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 +220,7 @@ class MenuSeedCommandImpl( namesI18n = definition.namesI18n, icon = definition.icon, programType = definition.programType, + managementType = definition.managementType.toDomain(), permissions = mergedPermissions, ) if (menu.sortOrder != desiredSortOrder) { @@ -224,3 +232,5 @@ class MenuSeedCommandImpl( return menu } } + +private fun ApiWorkspaceManagedType.toDomain(): DomainWorkspaceManagedType = DomainWorkspaceManagedType.valueOf(name) 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..0af112573 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 @@ -8,6 +8,7 @@ import io.deck.common.api.exception.NotFoundException import io.deck.iam.domain.LocaleType import io.deck.iam.domain.MenuEntity import io.deck.iam.domain.RoleEntity +import io.deck.iam.domain.WorkspaceManagedType import io.deck.iam.event.IamActivityLogType import io.deck.iam.repository.MenuRepository import io.deck.iam.repository.RoleRepository @@ -61,6 +62,7 @@ class MenuService( namesI18n: Map? = null, icon: String? = null, programType: String, + managementType: WorkspaceManagedType = WorkspaceManagedType.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: WorkspaceManagedType = WorkspaceManagedType.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: WorkspaceManagedType? = 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/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 88% 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..b311c75a8 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,19 @@ class SystemSettingService( } /** - * 시스템 설정 조회 (단일 레코드, 캐시됨) + * 플랫폼 설정 조회 (단일 레코드, 캐시됨) * 캐시 미스 시 조회+생성이 필요하므로 @Transactional 유지 */ @Transactional @Cacheable(CACHE_NAME) - fun getSettings(): SystemSettingEntity = - systemSettingRepository.findFirst() - ?: systemSettingRepository.save(SystemSettingEntity()) + fun getSettings(): PlatformSettingEntity = + platformSettingRepository.findFirst() + ?: platformSettingRepository.save(PlatformSettingEntity()) override fun getDefaultCountryCode(): String = getSettings().countryPolicy.normalized().defaultCountryCode /** - * 시스템 설정 수정 (Owner만) + * 플랫폼 설정 수정 (Owner만) */ @Transactional @CacheEvict(CACHE_NAME, allEntries = true) @@ -226,13 +226,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 +249,19 @@ 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, "useSelector" to saved.workspacePolicy?.useSelector, ), ) @@ -273,14 +273,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 +297,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 +322,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 +354,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 +383,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 +414,7 @@ class SystemSettingService( /** * 인증 설정 조회 (Owner만) */ - fun getAuthSettings(userId: UUID): SystemSettingEntity { + fun getAuthSettings(userId: UUID): PlatformSettingEntity { requireOwner(userId) return getSettings() } @@ -435,7 +435,7 @@ class SystemSettingService( oktaDomain: String?, oktaClientId: String?, oktaClientSecret: String?, - ): SystemSettingEntity { + ): PlatformSettingEntity { requireOwner(userId) // 락아웃 방지: 활성화된 auth provider로 로그인 가능한 owner가 최소 1명 있어야 함 validateOwnerCanLogin(internalLoginEnabled, auth0Enabled, oktaEnabled) @@ -452,9 +452,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 +567,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 +583,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 +609,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..42996bf6c 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 @@ -12,12 +12,15 @@ import io.deck.globalization.contactfield.api.ContactPhoneNumberNormalizer 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.ExternalReference import io.deck.iam.domain.LocaleType import io.deck.iam.domain.TimezoneType 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.event.RoleInfo import io.deck.iam.event.UserActivityEvent @@ -66,7 +69,7 @@ class UserService( private val channelAvailability: ChannelAvailability, private val workspaceMemberRepository: WorkspaceMemberRepository, private val workspaceRepository: WorkspaceRepository, - private val systemSettingService: SystemSettingService, + private val platformSettingService: PlatformSettingService, private val contactPhoneNumberNormalizer: ContactPhoneNumberNormalizer, private val partyCommand: PartyCommand, private val partyQuery: PartyQuery, @@ -214,17 +217,32 @@ class UserService( providerUserId: String, email: String, name: String, + externalOrganizations: List = emptyList(), + syncExternalOrganizationsOnReturn: Boolean = true, ): UserEntity { + val normalizedExternalOrganizations = externalOrganizations.normalizeExternalOrganizations() + val platformManagedWorkspaceEnabled = isPlatformManagedWorkspaceEnabled() + // 1. 기존 Identity 확인 val existingIdentity = identityService.findByProviderAndProviderUserId(provider, providerUserId) if (existingIdentity != null) { // 기존 사용자 반환 (사용자 정보는 유지, Primary 변경 시에만 email 동기화) + if (syncExternalOrganizationsOnReturn) { + syncExternalOrganizationsForOAuthUser( + user = existingIdentity.user, + externalOrganizations = normalizedExternalOrganizations, + ) + } return existingIdentity.user } val allowedDomain = extractEmailDomain(email) val matchedWorkspaces = allowedDomain?.let(workspaceRepository::findAllByAllowedDomain).orEmpty() - val autoApproved = matchedWorkspaces.isNotEmpty() + val autoApprovedByAip = + provider == AuthProvider.AIP && + normalizedExternalOrganizations.isNotEmpty() && + platformManagedWorkspaceEnabled + val autoApproved = matchedWorkspaces.isNotEmpty() || autoApprovedByAip // 2. 새 사용자 생성 val user = @@ -247,10 +265,19 @@ class UserService( // 3. OAuth Identity 생성 identityService.createOAuthIdentity(savedUser, provider, providerUserId, email, isPrimary = true) - if (autoApproved) { + if (matchedWorkspaces.isNotEmpty()) { publishAutoApprovedEvent(savedUser, allowedDomain.orEmpty(), matchedWorkspaces) addUserToMatchedWorkspaces(savedUser.id, allowedDomain.orEmpty(), matchedWorkspaces) - } else { + } + + if (syncExternalOrganizationsOnReturn && provider == AuthProvider.AIP && normalizedExternalOrganizations.isNotEmpty()) { + syncExternalOrganizationsForOAuthUser( + user = savedUser, + externalOrganizations = normalizedExternalOrganizations, + ) + } + + if (!autoApproved) { // 4. 승인 대기 알림 이벤트 eventPublisher.publishEvent( InternalUserPendingEvent( @@ -265,6 +292,26 @@ class UserService( return savedUser } + @Transactional + fun syncExternalOrganizationsForOAuthUser( + user: UserEntity, + externalOrganizations: List, + ): List { + val normalizedExternalOrganizations = externalOrganizations.normalizeExternalOrganizations() + val syncedWorkspaces = + syncExternalOrganizations( + user = user, + externalOrganizations = normalizedExternalOrganizations, + enabled = isPlatformManagedWorkspaceEnabled(), + ) + + if (syncedWorkspaces.isNotEmpty()) { + publishExternalOrganizationApprovedEvent(user, syncedWorkspaces) + } + + return syncedWorkspaces + } + // ========== 사용자 정보 수정 ========== /** @@ -807,6 +854,22 @@ class UserService( ) } + private fun publishExternalOrganizationApprovedEvent( + user: UserEntity, + matchedWorkspaces: List, + ) { + eventPublisher.publishEvent( + InternalUserApprovedEvent( + email = user.email, + userName = user.name, + targetUserId = user.id, + approvedByUserId = SYSTEM_ACTOR_ID, + reason = AIP_EXTERNAL_ORGANIZATION_REASON, + matchedWorkspaceIds = matchedWorkspaces.map { it.id.toString() }.sorted(), + ), + ) + } + private fun addUserToMatchedWorkspaces( userId: UUID, domain: String, @@ -836,6 +899,54 @@ class UserService( } } + private fun syncExternalOrganizations( + user: UserEntity, + externalOrganizations: List, + enabled: Boolean, + ): List = + if (!enabled) { + emptyList() + } else { + externalOrganizations.map { organization -> + val workspace = + workspaceRepository.findByExternalReferenceExternalId(organization.externalId)?.also { + it.name = organization.name ?: it.name + it.description = organization.description ?: it.description + it.managedType = WorkspaceManagedType.PLATFORM_MANAGED + } + ?: workspaceRepository.save( + WorkspaceEntity( + name = organization.name ?: organization.externalId, + description = organization.description, + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + externalReference = ExternalReference(organization.externalId), + ), + ) + + if (!workspaceMemberRepository.existsByWorkspaceIdAndUserId(workspace.id, user.id)) { + workspaceMemberRepository.save( + WorkspaceMemberEntity( + workspaceId = workspace.id, + userId = user.id, + isOwner = false, + ), + ) + eventPublisher.publishEvent( + WorkspaceMemberAddedEvent( + workspaceId = workspace.id, + memberId = UserId(user.id), + addedBy = UserId(SYSTEM_ACTOR_ID), + reason = AIP_EXTERNAL_ORGANIZATION_REASON, + ), + ) + } + + workspace + } + } + + private fun isPlatformManagedWorkspaceEnabled(): Boolean = platformSettingService.getSettings().workspacePolicy?.usePlatformManaged == true + private fun findOwnedWorkspaceMemberships(userId: UUID): List = workspaceMemberRepository.findActiveOwnerMembershipsByUserId(userId) private fun ensureWorkspaceOwnerCanBeRemoved(ownedMemberships: List) { @@ -1094,7 +1205,7 @@ class UserService( ): String = normalizeCountryCode(countryCode) ?: normalizeCountryCode(fallbackCountryCode) ?: resolveDefaultCountryCode() private fun resolveDefaultCountryCode(): String = - systemSettingService + platformSettingService .getSettings() .countryPolicy .normalized() @@ -1201,9 +1312,26 @@ class UserService( companion object { private const val WORKSPACE_ALLOWED_DOMAIN_REASON = "WORKSPACE_ALLOWED_DOMAIN" + private const val AIP_EXTERNAL_ORGANIZATION_REASON = "AIP_EXTERNAL_ORGANIZATION" } } +private fun List.normalizeExternalOrganizations(): 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 { it.externalId } + .toList() + /** * TOTP 설정 시작 결과 */ 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..927ff5d20 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,10 +1,11 @@ package io.deck.iam.service +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.util.UUID @Service @@ -41,7 +42,7 @@ class WorkspaceDirectoryImpl( description: String?, initialOwnerId: UUID, managedType: WorkspaceManagedType, - allowedDomains: List, + autoJoinDomains: List, ): WorkspaceRecord = workspaceService .create( @@ -49,15 +50,15 @@ class WorkspaceDirectoryImpl( 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( workspaceId: UUID, @@ -65,7 +66,7 @@ class WorkspaceDirectoryImpl( description: String?, updatedBy: UUID, managedType: WorkspaceManagedType, - allowedDomains: List, + autoJoinDomains: List, ): WorkspaceRecord = workspaceService .updateByAdmin( @@ -74,7 +75,7 @@ class WorkspaceDirectoryImpl( description = description, updatedBy = updatedBy, managedType = managedType.toDomain(), - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, ).toRecord() override fun updateForUser( @@ -83,7 +84,7 @@ class WorkspaceDirectoryImpl( description: String?, requestedBy: UUID, managedType: WorkspaceManagedType, - allowedDomains: List, + autoJoinDomains: List, ): WorkspaceRecord = workspaceService .update( @@ -92,7 +93,7 @@ class WorkspaceDirectoryImpl( description = description, requestedBy = requestedBy, managedType = managedType.toDomain(), - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, ).toRecord() override fun deleteByAdminBatch( @@ -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 autoJoinDomains: List, override val managedType: WorkspaceManagedType, - override val createdAt: Instant?, - override val updatedAt: Instant?, + override val externalReference: ExternalReferenceRecord?, + override val createdAt: LocalDateTime?, + override val updatedAt: LocalDateTime?, ) : WorkspaceRecord private fun io.deck.iam.domain.WorkspaceEntity.toRecord(): WorkspaceRecord = @@ -126,8 +128,9 @@ private fun io.deck.iam.domain.WorkspaceEntity.toRecord(): WorkspaceRecord = id = id, name = name, description = description, - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, managedType = managedType.toApi(), + externalReference = externalReference?.let { ExternalReferenceRecord(it.externalId) }, createdAt = createdAt, updatedAt = updatedAt, ) 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..d1a415d9c 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 @@ -46,6 +46,16 @@ class WorkspaceInviteService( private val memberService: WorkspaceMemberService, private val eventPublisher: ApplicationEventPublisher, ) { + private fun requireInternalWorkspace(workspaceId: UUID) = + workspaceRepository + .findById(workspaceId) + .orElseThrow { NotFoundException("iam.workspace.not_found") } + .also { workspace -> + if (workspace.isExternal) { + throw BadRequestException(messageCode = "iam.workspace.external_locked") + } + } + fun findAllByWorkspace(workspaceId: UUID): List = inviteRepository.findAllByWorkspaceId(workspaceId) fun validateToken(token: String): WorkspaceInviteValidation { @@ -66,9 +76,10 @@ class WorkspaceInviteService( val alreadyMember = user != null && memberRepository.existsByWorkspaceIdAndUserId(invite.workspaceId, user.id) + val isExternalWorkspace = workspace?.isExternal == true return WorkspaceInviteValidation( - valid = invite.isValid() && !alreadyMember, + valid = invite.isValid() && !alreadyMember && !isExternalWorkspace, email = invite.email, workspaceName = workspace?.name, expired = invite.isExpired(), @@ -84,10 +95,7 @@ class WorkspaceInviteService( message: String?, invitedBy: UUID, ): WorkspaceInviteEntity { - val workspace = - workspaceRepository - .findById(workspaceId) - .orElseThrow { NotFoundException("iam.workspace.not_found") } + val workspace = requireInternalWorkspace(workspaceId) val existingUser = userRepository.findByEmail(email) if (existingUser != null && memberService.isMember(workspaceId, existingUser.id)) { throw BadRequestException("iam.workspace_member.already_member", "ALREADY_MEMBER") @@ -128,7 +136,7 @@ class WorkspaceInviteService( email = email, token = rawToken, message = message, - inviterName = inviter?.name ?: UserEntity.SYSTEM_NAME, + inviterName = inviter?.name ?: UserEntity.PLATFORM_NAME, inviteId = saved.id, invitedBy = UserId(invitedBy), ), @@ -148,6 +156,7 @@ class WorkspaceInviteService( ?: throw BadRequestException("iam.workspace_invite.invalid_token") require(invite.isValid()) { "Invite is not valid" } + requireInternalWorkspace(invite.workspaceId) val user = userRepository.findByEmail(invite.email) val resolvedUser = @@ -187,6 +196,7 @@ class WorkspaceInviteService( workspaceId: UUID, ) { val invite = findInviteBelongingTo(inviteId, workspaceId) + requireInternalWorkspace(workspaceId) invite.cancel() inviteRepository.save(invite) eventPublisher.publishEvent( @@ -216,10 +226,7 @@ class WorkspaceInviteService( workspaceId: UUID, ) { val invite = findInviteBelongingTo(inviteId, workspaceId) - val workspace = - workspaceRepository - .findById(invite.workspaceId) - .orElseThrow { NotFoundException("iam.workspace.not_found") } + val workspace = requireInternalWorkspace(invite.workspaceId) require(invite.status == InviteStatus.PENDING) { "Only PENDING invites can be resent" } @@ -236,7 +243,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), ), 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..06eb3c69f 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 @@ -24,6 +24,16 @@ class WorkspaceMemberService( private val workspaceRepository: WorkspaceRepository, private val eventPublisher: ApplicationEventPublisher, ) { + private fun requireInternalWorkspace(workspaceId: UUID) { + val workspace = + workspaceRepository + .findById(workspaceId) + .orElseThrow { NotFoundException("iam.workspace.not_found") } + if (workspace.isExternal) { + throw BadRequestException(messageCode = "iam.workspace.external_locked") + } + } + fun findMembers(workspaceId: UUID): List = memberRepository.findAllByWorkspaceId(workspaceId) fun findOwners(workspaceId: UUID): List = memberRepository.findAllByWorkspaceIdAndIsOwnerTrue(workspaceId) @@ -77,6 +87,7 @@ class WorkspaceMemberService( userId: UUID, addedBy: UUID, ): WorkspaceMemberEntity { + requireInternalWorkspace(workspaceId) if (memberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId)) { throw BadRequestException("iam.workspace_member.already_member", "ALREADY_MEMBER") } @@ -103,9 +114,7 @@ class WorkspaceMemberService( workspaceId: UUID, userId: UUID, ) { - workspaceRepository - .findById(workspaceId) - .orElseThrow { NotFoundException("iam.workspace.not_found") } + requireInternalWorkspace(workspaceId) val member = memberRepository.findByWorkspaceIdAndUserId(workspaceId, userId) @@ -121,6 +130,7 @@ class WorkspaceMemberService( userId: UUID, removedBy: UUID, ) { + requireInternalWorkspace(workspaceId) val member = memberRepository.findByWorkspaceIdAndUserId(workspaceId, userId) ?: throw NotFoundException("iam.workspace_member.not_found") @@ -142,6 +152,7 @@ class WorkspaceMemberService( userIds: Collection, removedBy: UUID, ) { + requireInternalWorkspace(workspaceId) val targetUserIds = userIds.distinct() if (targetUserIds.isEmpty()) { return @@ -172,6 +183,7 @@ class WorkspaceMemberService( workspaceId: UUID, ownerUserIds: Collection, ) { + requireInternalWorkspace(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/WorkspaceService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/WorkspaceService.kt index ba1289d10..d400b8ce1 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 @@ -25,7 +25,7 @@ class WorkspaceService( private val workspaceRepository: WorkspaceRepository, private val memberRepository: WorkspaceMemberRepository, private val eventPublisher: ApplicationEventPublisher, - private val systemSettingService: SystemSettingService, + private val platformSettingService: PlatformSettingService, ) { fun findById(id: UUID): WorkspaceEntity = workspaceRepository @@ -34,13 +34,19 @@ class WorkspaceService( fun findByUser(userId: UUID): List = workspaceRepository.findAllByMemberUserId(userId) + private fun requireInternalWorkspace(workspace: WorkspaceEntity) { + if (workspace.isExternal) { + throw BadRequestException(messageCode = "iam.workspace.external_locked") + } + } + 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() + val workspacePolicy = platformSettingService.getSettings().workspacePolicy ?: throw workspacePolicyNotFound() if (!isManagedTypeEnabled(workspacePolicy, managedType)) { throw workspacePolicyNotFound() } @@ -56,14 +62,14 @@ class WorkspaceService( name: String, description: String?, initialOwnerId: UUID, - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ): WorkspaceEntity { try { ensureManagedTypeEnabled(WorkspaceManagedType.USER_MANAGED) } catch (_: NotFoundException) { throw BadRequestException(messageCode = "iam.workspace.creation_disabled") } - return create(name, description, initialOwnerId, WorkspaceManagedType.USER_MANAGED, allowedDomains) + return create(name, description, initialOwnerId, WorkspaceManagedType.USER_MANAGED, autoJoinDomains) } @Transactional @@ -72,14 +78,14 @@ class WorkspaceService( description: String?, initialOwnerId: UUID, managedType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, - allowedDomains: List = emptyList(), + autoJoinDomains: List = emptyList(), ): WorkspaceEntity { - validateAllowedDomains(allowedDomains) + validateAllowedDomains(autoJoinDomains) val workspace = WorkspaceEntity( name = name, description = description, - allowedDomains = allowedDomains, + autoJoinDomains = autoJoinDomains, managedType = managedType, ) val saved = workspaceRepository.save(workspace) @@ -108,11 +114,12 @@ class WorkspaceService( name: String, description: String?, requestedBy: UUID, - allowedDomains: List? = null, + autoJoinDomains: List? = null, managedType: WorkspaceManagedType? = null, ): WorkspaceEntity { val workspace = findPolicyAccessibleWorkspace(workspaceId, managedType) - val effectiveAllowedDomains = allowedDomains ?: workspace.allowedDomains + requireInternalWorkspace(workspace) + val effectiveAllowedDomains = autoJoinDomains ?: workspace.autoJoinDomains val effectiveManagedType = managedType ?: workspace.managedType ensureOwner(workspace, requestedBy) validateAllowedDomains(effectiveAllowedDomains) @@ -134,6 +141,7 @@ class WorkspaceService( managedType: WorkspaceManagedType? = null, ) { val workspace = findPolicyAccessibleWorkspace(workspaceId, managedType) + requireInternalWorkspace(workspace) ensureOwner(workspace, deletedBy) workspace.softDelete(deletedBy) @@ -165,11 +173,12 @@ class WorkspaceService( name: String, description: String?, updatedBy: UUID, - allowedDomains: List? = null, + autoJoinDomains: List? = null, managedType: WorkspaceManagedType? = null, ): WorkspaceEntity { val workspace = findById(workspaceId) - val effectiveAllowedDomains = allowedDomains ?: workspace.allowedDomains + requireInternalWorkspace(workspace) + val effectiveAllowedDomains = autoJoinDomains ?: workspace.autoJoinDomains val effectiveManagedType = managedType ?: workspace.managedType validateAllowedDomains(effectiveAllowedDomains) workspace.update(name, description, effectiveAllowedDomains, effectiveManagedType) @@ -188,6 +197,7 @@ class WorkspaceService( deletedBy: UUID, ) { val workspace = findById(workspaceId) + requireInternalWorkspace(workspace) workspace.softDelete(deletedBy) eventPublisher.publishEvent( WorkspaceDeletedEvent( @@ -224,8 +234,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( @@ -254,7 +264,7 @@ class WorkspaceService( ): Boolean = when (managedType) { WorkspaceManagedType.USER_MANAGED -> workspacePolicy.useUserManaged - WorkspaceManagedType.SYSTEM_MANAGED -> workspacePolicy.useSystemManaged + WorkspaceManagedType.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/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..cc5fcc70e 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 @@ -46,6 +46,7 @@ class MenuControllerTest : namesI18n = mapOf("en" to "Profile", "ko" to "프로필"), icon = "user", programType = "BOOKING_PROFILE", + managementType = WorkspaceManagedType.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 = WorkspaceManagedType.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 WorkspaceManagedType.USER_MANAGED response.body ?.first() ?.children ?.first() ?.name shouldBe "프로필" + response.body + ?.first() + ?.children + ?.first() + ?.managementType shouldBe WorkspaceManagedType.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 = WorkspaceManagedType.PLATFORM_MANAGED, + sortOrder = 0, + ) + + every { menuService.findMenuTreeByRoleId(roleId) } returns listOf(menu) + + val response = controller.getMenuTree(roleId) + + response.body?.first()?.managementType shouldBe WorkspaceManagedType.PLATFORM_MANAGED } } @@ -83,7 +111,7 @@ class MenuControllerTest : listOf( ProgramDefinition( code = "MY_WORKSPACE", - path = "/my-workspaces", + path = "/console/my-workspaces", permissions = setOf("MY_WORKSPACE_READ"), workspace = ProgramDefinition.WorkspacePolicy( @@ -101,7 +129,7 @@ class MenuControllerTest : listOf( ProgramDto( code = "MY_WORKSPACE", - path = "/my-workspaces", + path = "/console/my-workspaces", permissions = setOf("MY_WORKSPACE_READ"), workspace = ProgramWorkspacePolicyDto( 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 78% 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..00c85053f 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,14 @@ 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, useSelector = true), ) every { - systemSettingService.updateGeneral( + platformSettingService.updateGeneral( userId = ownerId, brandName = "Deck Next", contactEmail = "privacy@deck.io", @@ -162,7 +162,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 +178,20 @@ class SystemSettingControllerTest : workspacePolicy = WorkspacePolicyDto( useUserManaged = true, - useSystemManaged = false, + usePlatformManaged = false, useSelector = true, ), ) val settings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Next", - workspacePolicy = WorkspacePolicy(useUserManaged = true, useSystemManaged = false, useSelector = true), + workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false, useSelector = true), ) every { - systemSettingService.updateWorkspacePolicy( + platformSettingService.updateWorkspacePolicy( userId = ownerId, - workspacePolicy = WorkspacePolicy(useUserManaged = true, useSystemManaged = false, useSelector = true), + workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false, useSelector = true), ) } returns settings @@ -202,9 +202,9 @@ 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, useSelector = true), ) } } @@ -221,13 +221,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 +240,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 +259,7 @@ class SystemSettingControllerTest : ), ) val settings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Next", currencyPolicy = CurrencyPolicy( @@ -269,7 +269,7 @@ class SystemSettingControllerTest : ) every { - systemSettingService.updateCurrencyPolicy( + platformSettingService.updateCurrencyPolicy( userId = ownerId, currencyPolicy = CurrencyPolicy( @@ -286,7 +286,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 +313,7 @@ class SystemSettingControllerTest : ), ) val settings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Next", countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "US"), currencyPolicy = @@ -324,7 +324,7 @@ class SystemSettingControllerTest : ) every { - systemSettingService.updateGlobalizationPolicy( + platformSettingService.updateGlobalizationPolicy( userId = ownerId, countryPolicy = CountryPolicy(enabledCountryCodes = listOf("KR", "US"), defaultCountryCode = "US"), currencyPolicy = @@ -341,7 +341,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/WorkspaceEntityTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/domain/WorkspaceEntityTest.kt index 57f137c8c..84fd42166 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 @@ -13,6 +13,20 @@ class WorkspaceEntityTest : } } + describe("externalReference") { + it("externalReference가 있으면 external workspace로 판단한다") { + val workspace = + WorkspaceEntity( + name = "AIP Workspace", + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + externalReference = ExternalReference(externalId = "aip-org-1"), + ) + + workspace.isExternal shouldBe true + workspace.externalReference?.externalId shouldBe "aip-org-1" + } + } + describe("update") { it("name, description, managedType을 변경한다") { val workspace = @@ -24,12 +38,12 @@ class WorkspaceEntityTest : workspace.update( name = "새 이름", description = "새 설명", - managedType = WorkspaceManagedType.SYSTEM_MANAGED, + managedType = WorkspaceManagedType.PLATFORM_MANAGED, ) workspace.name shouldBe "새 이름" workspace.description shouldBe "새 설명" - workspace.managedType shouldBe WorkspaceManagedType.SYSTEM_MANAGED + workspace.managedType shouldBe WorkspaceManagedType.PLATFORM_MANAGED } } }) 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..cb372ee50 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,7 @@ 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.UserEntity import io.deck.iam.domain.UserStatus import io.deck.iam.service.AuthErrorType @@ -63,13 +64,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 +157,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 +170,60 @@ 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"), + ) + + 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", + externalOrganizations = externalOrganizations, + ) + } 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", + externalOrganizations = externalOrganizations, + ) + } + verify { response.sendRedirect("/") } + } + it("AuthService 호출 성공 시 쿠키 설정 후 리다이렉트") { // given val user = createTestUser() @@ -408,7 +464,7 @@ class OAuth2LinkingTest : 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가 없으면 일반 로그인으로 처리한다") { 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..90420ad5f 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,7 @@ 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.IdentityEntity import io.deck.iam.domain.SessionType import io.deck.iam.domain.UserEntity @@ -297,6 +298,84 @@ class AuthServiceTest : } describe("authenticateWithOAuth") { + it("AIP external organization claims를 UserService로 전달한다") { + 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"), + ) + + every { + userService.findOrCreateByOAuth( + AuthProvider.AIP, + "aip-sub", + "oauth@example.com", + "OAuth User", + externalOrganizations, + false, + ) + } returns user + every { + userService.syncExternalOrganizationsForOAuthUser( + user, + externalOrganizations, + ) + } returns emptyList() + 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", + externalOrganizations = externalOrganizations, + ) + + result.shouldBeInstanceOf() + verify { + userService.findOrCreateByOAuth( + AuthProvider.AIP, + "aip-sub", + "oauth@example.com", + "OAuth User", + externalOrganizations, + false, + ) + } + verify { + userService.syncExternalOrganizationsForOAuthUser( + user, + externalOrganizations, + ) + } + } + it("OAuth 로그인 성공 시도도 세션 중심 토큰 발급을 사용한다") { val user = createUser() val sessionId = UUID.randomUUID() @@ -309,7 +388,14 @@ class AuthServiceTest : ) every { - userService.findOrCreateByOAuth(AuthProvider.GOOGLE, "google-sub", "oauth@example.com", "OAuth User") + userService.findOrCreateByOAuth( + AuthProvider.GOOGLE, + "google-sub", + "oauth@example.com", + "OAuth User", + emptyList(), + false, + ) } returns user every { sessionService.createPending( @@ -346,7 +432,14 @@ class AuthServiceTest : it("잠긴 OAuth 사용자는 세션을 만들지 않고 ACCOUNT_LOCKED를 반환한다") { val user = createUser(status = UserStatus.LOCKED) every { - userService.findOrCreateByOAuth(AuthProvider.GOOGLE, "google-sub", "oauth@example.com", "OAuth User") + userService.findOrCreateByOAuth( + AuthProvider.GOOGLE, + "google-sub", + "oauth@example.com", + "OAuth User", + emptyList(), + false, + ) } returns user val result = @@ -364,13 +457,60 @@ 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"), + ) + + every { + userService.findOrCreateByOAuth( + AuthProvider.AIP, + "aip-sub-locked", + "oauth@example.com", + "OAuth User", + externalOrganizations, + false, + ) + } returns user + + 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", + externalOrganizations = externalOrganizations, + ) + + result.shouldBeInstanceOf() + result.errorType shouldBe AuthErrorType.ACCOUNT_LOCKED + verify(exactly = 0) { + userService.syncExternalOrganizationsForOAuthUser( + any(), + any(), + ) + } + 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") + userService.findOrCreateByOAuth( + AuthProvider.GOOGLE, + "google-sub", + "oauth@example.com", + "OAuth User", + emptyList(), + false, + ) } returns user val result = 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..9fcbdddaf 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 @@ -3,6 +3,7 @@ package io.deck.iam.service import io.deck.iam.api.SeedMenuDefinition import io.deck.iam.domain.MenuEntity import io.deck.iam.domain.RoleEntity +import io.deck.iam.domain.WorkspaceManagedType import io.deck.iam.repository.MenuRepository import io.deck.iam.repository.RoleRepository import io.kotest.core.spec.style.DescribeSpec @@ -15,6 +16,7 @@ import io.mockk.runs import io.mockk.slot import io.mockk.verify import java.util.UUID +import io.deck.iam.api.WorkspaceManagedType as ApiWorkspaceManagedType class MenuSeedCommandImplTest : DescribeSpec({ @@ -84,6 +86,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 = ApiWorkspaceManagedType.PLATFORM_MANAGED, + children = + listOf( + SeedMenuDefinition( + name = "Users", + programType = "USER_MANAGEMENT", + managementType = ApiWorkspaceManagedType.PLATFORM_MANAGED, + ), + ), + ), + ), + ) + + savedMenus[0].managementType shouldBe WorkspaceManagedType.PLATFORM_MANAGED + savedMenus[1].managementType shouldBe WorkspaceManagedType.PLATFORM_MANAGED + } + it("이미 존재하는 메뉴는 중복 생성하지 않는다") { val role = RoleEntity(name = "ADMIN", label = "관리자", id = UUID.randomUUID()) val existingMenu = @@ -261,6 +294,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 = WorkspaceManagedType.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 = ApiWorkspaceManagedType.PLATFORM_MANAGED, + ), + ), + ) + + existingRoot.managementType shouldBe WorkspaceManagedType.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..a1d9f5a1c 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.domain.WorkspaceManagedType.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.domain.WorkspaceManagedType.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.domain.WorkspaceManagedType.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.domain.WorkspaceManagedType.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.domain.WorkspaceManagedType.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.domain.WorkspaceManagedType.PLATFORM_MANAGED } } 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 72% 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..69092d347 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,44 @@ 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()) } } } 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 +113,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 +130,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 +140,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 +154,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 +179,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 +207,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 +260,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 +271,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 +287,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 +299,7 @@ class SystemSettingServiceTest : every { ownerService.isOwner(ownerId) } returns true shouldThrow { - systemSettingService.updateCountryPolicy( + platformSettingService.updateCountryPolicy( userId = ownerId, countryPolicy = CountryPolicy( @@ -309,7 +309,7 @@ class SystemSettingServiceTest : ) } - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } } } @@ -317,7 +317,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 +328,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 +349,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 +372,7 @@ class SystemSettingServiceTest : ) } - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } } it("형식이 잘못된 raw code는 정규화로 숨기지 않고 거부해야 한다") { @@ -381,7 +381,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 +392,7 @@ class SystemSettingServiceTest : ) } - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } } } @@ -401,7 +401,7 @@ class SystemSettingServiceTest : // given val ownerId = UUID.randomUUID() val existingSettings = - SystemSettingEntity( + PlatformSettingEntity( brandName = "Deck Enterprise", countryPolicy = CountryPolicy( @@ -411,12 +411,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 +433,7 @@ class SystemSettingServiceTest : defaultCurrencyCode = "USD", preferredCurrencyCodes = listOf("USD", "KRW", "EUR"), ) - verify { systemSettingRepository.save(existingSettings) } + verify { platformSettingRepository.save(existingSettings) } } it("지원하지 않는 통화 코드는 거부해야 한다") { @@ -442,7 +442,7 @@ class SystemSettingServiceTest : every { ownerService.isOwner(ownerId) } returns true shouldThrow { - systemSettingService.updateCurrencyPolicy( + platformSettingService.updateCurrencyPolicy( userId = ownerId, currencyPolicy = CurrencyPolicy( @@ -452,7 +452,7 @@ class SystemSettingServiceTest : ) } - verify(exactly = 0) { systemSettingRepository.save(any()) } + verify(exactly = 0) { platformSettingRepository.save(any()) } } } @@ -461,17 +461,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 +484,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 +506,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 +528,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 +556,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 +580,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 +603,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 +626,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 +649,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 +672,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 +695,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 +713,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 +731,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 +748,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 +767,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 +807,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 +839,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 +875,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 +911,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 +946,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 +972,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 +1001,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 +1031,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 +1069,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 +1093,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 +1120,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 +1135,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 +1147,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 +1162,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 +1174,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 +1197,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 +1221,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 +1247,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..68a8f9105 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 @@ -8,13 +8,32 @@ import io.kotest.matchers.shouldNotBe class ProgramRegistryTest : DescribeSpec({ describe("findByCode") { + it("DASHBOARD 프로그램은 console dashboard path를 사용해야 한다") { + val registry = ProgramRegistry(listOf(IamProgramRegistrar())) + + val program = registry.findByCode("DASHBOARD") + + program shouldNotBe null + 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("CODEBOOK_MANAGEMENT 프로그램이 등록되어 있어야 한다") { val registry = ProgramRegistry(listOf(IamProgramRegistrar())) val program = registry.findByCode("CODEBOOK_MANAGEMENT") program shouldNotBe null - program?.path shouldBe "/system/codebook" + program?.path shouldBe "/console/codebook" program?.permissions?.contains("CODEBOOK_MANAGEMENT_READ") shouldBe true program?.permissions?.contains("CODEBOOK_MANAGEMENT_WRITE") shouldBe true } @@ -36,11 +55,11 @@ class ProgramRegistryTest : val activityLogProgram = registry.findByCode("ACTIVITY_LOG") 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") } } 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..fbe5ed0ab 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 @@ -17,16 +17,19 @@ 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.ExternalReference 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 @@ -88,7 +91,7 @@ class UserServiceTest : lateinit var channelAvailability: ChannelAvailability lateinit var workspaceMemberRepository: WorkspaceMemberRepository lateinit var workspaceRepository: WorkspaceRepository - lateinit var systemSettingService: SystemSettingService + lateinit var platformSettingService: PlatformSettingService lateinit var contactPhoneNumberNormalizer: ContactPhoneNumberNormalizer lateinit var partyCommand: PartyCommand lateinit var partyQuery: PartyQuery @@ -262,7 +265,7 @@ class UserServiceTest : channelAvailability = mockk(relaxed = true) workspaceMemberRepository = mockk(relaxed = true) workspaceRepository = mockk(relaxed = true) - systemSettingService = mockk(relaxed = true) + platformSettingService = mockk(relaxed = true) contactPhoneNumberNormalizer = object : ContactPhoneNumberNormalizer { override fun normalize( @@ -297,7 +300,7 @@ class UserServiceTest : 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 @@ -316,7 +319,7 @@ class UserServiceTest : channelAvailability = channelAvailability, workspaceMemberRepository = workspaceMemberRepository, workspaceRepository = workspaceRepository, - systemSettingService = systemSettingService, + platformSettingService = platformSettingService, contactPhoneNumberNormalizer = contactPhoneNumberNormalizer, partyCommand = partyCommand, partyQuery = partyQuery, @@ -616,8 +619,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 +1025,96 @@ class UserServiceTest : describe("findOrCreateByOAuth") { describe("기존 Identity가 있는 경우") { + it("AIP 재로그인 시 같은 externalId workspace를 재사용하고 누락된 membership만 추가한다") { + val user = createTestUser(name = "AIP User", email = "aip@example.com") + val existingIdentity = createOAuthIdentity(user, provider = AuthProvider.AIP, providerUserId = "aip-sub-123", email = "aip@example.com") + val workspaceA = + WorkspaceEntity( + name = "Acme", + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-1"), + ) + val workspaceB = + WorkspaceEntity( + name = "Beta", + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-2"), + ) + + every { + identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-sub-123") + } returns existingIdentity + every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns workspaceA + every { workspaceRepository.findByExternalReferenceExternalId("aip-org-2") } returns workspaceB + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(workspaceA.id, user.id) } returns true + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(workspaceB.id, user.id) } returns false + every { workspaceMemberRepository.save(any()) } answers { firstArg() } + + val result = + userService.findOrCreateByOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-sub-123", + email = "aip@example.com", + name = "AIP User", + externalOrganizations = + listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta"), + ), + ) + + result shouldBe user + verify(exactly = 0) { userRepository.save(any()) } + verify(exactly = 0) { workspaceRepository.save(any()) } + verify(exactly = 1) { + workspaceMemberRepository.save( + match { + it.workspaceId == workspaceB.id && + it.userId == user.id && + !it.isOwner + }, + ) + } + } + + it("AIP 재로그인이라도 platform-managed policy가 꺼져 있으면 external sync를 건너뛴다") { + val user = createTestUser(name = "AIP User", email = "aip@example.com") + val existingIdentity = + createOAuthIdentity( + user, + provider = AuthProvider.AIP, + providerUserId = "aip-sub-locked", + email = "aip@example.com", + ) + + every { platformSettingService.getSettings() } returns + PlatformSettingEntity(workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false)) + every { + identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-sub-locked") + } returns existingIdentity + every { workspaceRepository.findByExternalReferenceExternalId(any()) } returns null + every { workspaceRepository.save(any()) } answers { firstArg() } + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(any(), any()) } returns false + every { workspaceMemberRepository.save(any()) } answers { firstArg() } + + val result = + userService.findOrCreateByOAuth( + provider = AuthProvider.AIP, + providerUserId = "aip-sub-locked", + email = "aip@example.com", + name = "AIP User", + externalOrganizations = + listOf( + ExternalOrganizationClaim(externalId = "aip-org-locked", name = "Locked Org"), + ), + ) + + result shouldBe user + verify(exactly = 0) { workspaceRepository.findByExternalReferenceExternalId(any()) } + verify(exactly = 0) { workspaceRepository.save(any()) } + verify(exactly = 0) { workspaceMemberRepository.save(any()) } + } + it("기존 사용자 정보를 유지하고 반환한다 (OAuth 정보로 덮어쓰지 않음)") { // given val user = createTestUser(name = "Old Name", email = "old@example.com") @@ -1149,8 +1242,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 +1275,79 @@ class UserServiceTest : } describe("OAuth 신규 사용자 PENDING 상태") { + it("AIP 조직 claim이 있으면 external workspace를 auto-provision하고 ACTIVE로 생성한다") { + val savedUser = slot() + val savedWorkspaces = mutableListOf() + val savedMemberships = mutableListOf() + + every { + identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-sub-new") + } returns null + every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns null + every { workspaceRepository.findByExternalReferenceExternalId("aip-org-2") } returns null + every { userRepository.save(capture(savedUser)) } answers { savedUser.captured } + every { workspaceRepository.save(any()) } answers { + firstArg().also(savedWorkspaces::add) + } + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(any(), any()) } returns false + every { workspaceMemberRepository.save(any()) } answers { + firstArg().also(savedMemberships::add) + } + + 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"), + ), + ) + + result.status shouldBe UserStatus.ACTIVE + savedWorkspaces shouldHaveSize 2 + savedWorkspaces.map { it.externalReference?.externalId } shouldBe listOf("aip-org-1", "aip-org-2") + savedWorkspaces.map { it.managedType }.distinct() shouldBe listOf(WorkspaceManagedType.PLATFORM_MANAGED) + savedMemberships shouldHaveSize 2 + savedMemberships.map { it.userId }.distinct() shouldBe listOf(result.id) + savedMemberships.all { !it.isOwner } shouldBe true + } + + it("platform-managed policy가 꺼져 있으면 AIP 조직 claim이 있어도 external workspace를 만들지 않고 PENDING으로 남긴다") { + val savedUser = slot() + + every { platformSettingService.getSettings() } returns + PlatformSettingEntity(workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false)) + every { + identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-sub-disabled") + } returns null + every { userRepository.save(capture(savedUser)) } answers { savedUser.captured } + every { workspaceRepository.findByExternalReferenceExternalId(any()) } returns null + every { workspaceRepository.save(any()) } answers { firstArg() } + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(any(), any()) } returns false + every { workspaceMemberRepository.save(any()) } answers { firstArg() } + + 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"), + ), + ) + + result.status shouldBe UserStatus.PENDING + verify(exactly = 0) { workspaceRepository.findByExternalReferenceExternalId(any()) } + verify(exactly = 0) { workspaceRepository.save(any()) } + verify(exactly = 0) { workspaceMemberRepository.save(any()) } + } + it("신규 OAuth 사용자는 PENDING 상태로 생성된다") { // given val savedUser = slot() @@ -1204,11 +1370,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 = WorkspaceManagedType.PLATFORM_MANAGED, autoJoinDomains = listOf("acme.com")) + val workspaceB = WorkspaceEntity(name = "Dev", managedType = WorkspaceManagedType.PLATFORM_MANAGED, autoJoinDomains = listOf("acme.com")) val publishedEvents = mutableListOf() every { 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..63e03bbcb 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 @@ -3,6 +3,7 @@ package io.deck.iam.service import io.deck.common.exception.BadRequestException import io.deck.common.exception.NotFoundException import io.deck.common.utils.HashUtils +import io.deck.iam.domain.ExternalReference import io.deck.iam.domain.InviteStatus import io.deck.iam.domain.UserEntity import io.deck.iam.domain.UserId @@ -77,6 +78,12 @@ class WorkspaceInviteServiceTest : memberService = mockk() eventPublisher = mockk() workspaceInviteService = WorkspaceInviteService(inviteRepository, userRepository, workspaceRepository, memberRepository, userService, memberService, eventPublisher) + every { workspaceRepository.findById(any()) } answers { + Optional.of( + io.deck.iam.domain + .WorkspaceEntity(name = "워크스페이스", id = firstArg()), + ) + } } describe("findAllByWorkspace") { @@ -91,6 +98,35 @@ class WorkspaceInviteServiceTest : } } + describe("validateToken") { + 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 { + workspaceRepository.findById(workspaceId) + } returns Optional.of( + io.deck.iam.domain.WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + externalReference = ExternalReference("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") { it("초대를 생성하고 WorkspaceInviteSentEvent를 발행한다") { val workspaceId = UUID.randomUUID() @@ -149,6 +185,21 @@ class WorkspaceInviteServiceTest : ex.code shouldBe "ALREADY_MEMBER" } + it("external workspace에는 초대를 생성할 수 없다") { + val workspaceId = UUID.randomUUID() + every { workspaceRepository.findById(workspaceId) } returns Optional.of( + io.deck.iam.domain.WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + externalReference = ExternalReference("aip-org-1"), + ), + ) + + 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") @@ -286,6 +337,25 @@ class WorkspaceInviteServiceTest : ) } } + + 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 { workspaceRepository.findById(workspaceId) } returns Optional.of( + io.deck.iam.domain.WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + externalReference = ExternalReference("aip-org-1"), + ), + ) + + shouldThrow { + workspaceInviteService.accept(token) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("cancel") { @@ -347,6 +417,24 @@ 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 { workspaceRepository.findById(workspaceId) } returns Optional.of( + io.deck.iam.domain.WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + externalReference = ExternalReference("aip-org-1"), + ), + ) + + shouldThrow { + workspaceInviteService.cancel(inviteId, workspaceId) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("resend") { @@ -430,5 +518,23 @@ 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 { workspaceRepository.findById(workspaceId) } returns Optional.of( + io.deck.iam.domain.WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + externalReference = ExternalReference("aip-org-1"), + ), + ) + + 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..5256fb41a 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 @@ -2,6 +2,7 @@ package io.deck.iam.service import io.deck.common.exception.BadRequestException import io.deck.common.exception.NotFoundException +import io.deck.iam.domain.ExternalReference import io.deck.iam.domain.WorkspaceEntity import io.deck.iam.domain.WorkspaceMemberEntity import io.deck.iam.event.WorkspaceMemberAddedEvent @@ -35,6 +36,9 @@ class WorkspaceMemberServiceTest : eventPublisher = mockk() workspaceMemberService = WorkspaceMemberService(memberRepository, workspaceRepository, eventPublisher) every { memberRepository.countByWorkspaceIdAndIsOwnerTrue(any()) } returns 2L + every { workspaceRepository.findById(any()) } answers { + Optional.of(WorkspaceEntity(name = "워크스페이스", id = firstArg())) + } } describe("findMembers") { @@ -131,6 +135,7 @@ class WorkspaceMemberServiceTest : val userId = UUID.randomUUID() val addedBy = UUID.randomUUID() val memberSlot = io.mockk.slot() + every { workspaceRepository.findById(workspaceId) } returns Optional.of(WorkspaceEntity(name = "워크스페이스", id = workspaceId)) every { memberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId) } returns false every { memberRepository.save(capture(memberSlot)) } answers { firstArg() } @@ -164,6 +169,22 @@ class WorkspaceMemberServiceTest : ex.code shouldBe "ALREADY_MEMBER" } + + it("external workspace에는 멤버를 추가할 수 없다") { + val workspaceId = UUID.randomUUID() + val userId = UUID.randomUUID() + every { workspaceRepository.findById(workspaceId) } returns Optional.of( + WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + externalReference = ExternalReference("aip-org-1"), + ), + ) + + shouldThrow { + workspaceMemberService.addMember(workspaceId, userId, UUID.randomUUID()) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("removeMember") { @@ -172,6 +193,7 @@ class WorkspaceMemberServiceTest : val userId = UUID.randomUUID() val removedBy = UUID.randomUUID() val member = WorkspaceMemberEntity(workspaceId = workspaceId, userId = userId) + every { workspaceRepository.findById(workspaceId) } returns Optional.of(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 +222,22 @@ class WorkspaceMemberServiceTest : workspaceMemberService.removeMember(workspaceId, userId, UUID.randomUUID()) } } + + it("external workspace의 멤버는 제거할 수 없다") { + val workspaceId = UUID.randomUUID() + val userId = UUID.randomUUID() + every { workspaceRepository.findById(workspaceId) } returns Optional.of( + WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + externalReference = ExternalReference("aip-org-1"), + ), + ) + + shouldThrow { + workspaceMemberService.removeMember(workspaceId, userId, UUID.randomUUID()) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("leave") { @@ -260,6 +298,22 @@ class WorkspaceMemberServiceTest : workspaceMemberService.leave(workspaceId, userId) } } + + it("external workspace는 탈퇴할 수 없다") { + val workspaceId = UUID.randomUUID() + val userId = UUID.randomUUID() + every { workspaceRepository.findById(workspaceId) } returns Optional.of( + WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + externalReference = ExternalReference("aip-org-1"), + ), + ) + + shouldThrow { + workspaceMemberService.leave(workspaceId, userId) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("removeMember") { @@ -398,5 +452,20 @@ class WorkspaceMemberServiceTest : workspaceMemberService.replaceOwners(workspaceId, emptyList()) }.messageCode shouldBe "iam.workspace.last_owner_required" } + + it("external workspace는 owner를 교체할 수 없다") { + val workspaceId = UUID.randomUUID() + every { workspaceRepository.findById(workspaceId) } returns Optional.of( + WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + externalReference = ExternalReference("aip-org-1"), + ), + ) + + 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/WorkspaceServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/WorkspaceServiceTest.kt index 9e686b387..6c85131ca 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,7 +2,8 @@ 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.domain.ExternalReference +import io.deck.iam.domain.PlatformSettingEntity import io.deck.iam.domain.WorkspaceEntity import io.deck.iam.domain.WorkspaceManagedType import io.deck.iam.domain.WorkspaceMemberEntity @@ -30,20 +31,20 @@ class WorkspaceServiceTest : lateinit var workspaceRepository: WorkspaceRepository lateinit var memberRepository: WorkspaceMemberRepository lateinit var eventPublisher: ApplicationEventPublisher - lateinit var systemSettingService: SystemSettingService + lateinit var platformSettingService: PlatformSettingService lateinit var workspaceService: WorkspaceService beforeEach { workspaceRepository = mockk() memberRepository = mockk() eventPublisher = mockk() - systemSettingService = mockk() - workspaceService = WorkspaceService(workspaceRepository, memberRepository, eventPublisher, systemSettingService) + platformSettingService = mockk() + workspaceService = WorkspaceService(workspaceRepository, memberRepository, eventPublisher, platformSettingService) 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 +71,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()) } @@ -79,11 +80,11 @@ 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 ownerManaged = WorkspaceEntity(name = "B", managedType = WorkspaceManagedType.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) @@ -122,7 +123,7 @@ 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() } @@ -133,14 +134,14 @@ class WorkspaceServiceTest : name = "새 워크스페이스", description = null, initialOwnerId = initialOwnerId, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = listOf(" ACME.COM ", "acme.com", "dev.acme.com"), + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + 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 = @@ -149,8 +150,8 @@ class WorkspaceServiceTest : name = "새 워크스페이스", description = null, initialOwnerId = initialOwnerId, - managedType = WorkspaceManagedType.SYSTEM_MANAGED, - allowedDomains = listOf("invalid domain"), + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + autoJoinDomains = listOf("invalid domain"), ) } @@ -161,7 +162,7 @@ class WorkspaceServiceTest : 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) @@ -204,6 +205,23 @@ 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 = WorkspaceManagedType.PLATFORM_MANAGED, + externalReference = ExternalReference("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") { @@ -220,6 +238,23 @@ class WorkspaceServiceTest : 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 = WorkspaceManagedType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-1"), + ) + every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + + shouldThrow { + workspaceService.updateByAdmin(workspaceId, "변경", null, updatedBy) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("delete") { @@ -262,6 +297,40 @@ class WorkspaceServiceTest : ) } } + + it("external workspace는 owner라도 삭제할 수 없다") { + val workspaceId = UUID.randomUUID() + val deletedBy = UUID.randomUUID() + val workspace = + WorkspaceEntity( + name = "AIP Workspace", + id = workspaceId, + managedType = WorkspaceManagedType.PLATFORM_MANAGED, + externalReference = ExternalReference("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 = WorkspaceManagedType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-1"), + ) + every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) + + shouldThrow { + workspaceService.deleteByAdmin(workspaceId, deletedBy) + }.messageCode shouldBe "iam.workspace.external_locked" + } } describe("verifyOwner") { 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/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..00aa40717 --- /dev/null +++ b/docs/plans/2026-04-03-platform-reset-and-workspace-scope-plan.md @@ -0,0 +1,605 @@ +--- +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.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다. + +## Target Contract + +- authenticated route + - `/console/*` + - `/settings/*` +- public route + - `/p/:id` + - `/i/:token` +- settings 그룹 + - `Account` + - `Platform` +- platform settings leaf + - `General` + - `Branding` + - `Authentication` + - `Workspace Policy` + - `Roles` + - `Menus` +- `Workspace`는 settings 그룹으로 승격하지 않는다. +- 공용 `ManagementType` + - `USER_MANAGED` + - `PLATFORM_MANAGED` +- `PLATFORM_MANAGED` menu는 runtime에서 `Platform Admin`만 보고, 메뉴 관리 화면에서는 조회/수정 가능하다. +- `Workspace.externalReference.externalId`는 AIP의 실제 조직 ID다. +- `externalReference != null`인 workspace는 external workspace이며, identity/membership은 Deck에서 직접 수정하지 않는다. +- `Workspace.autoJoinDomains`는 empty list면 off로 해석한다. + +## File Map + +| # | 파일 | 변경 | +|---|------|------| +| 1 | `backend/app/src/main/resources/db/migration/app/V1__init.sql` | `menus.managed_type`, `auto_join_domains`, `external_id`, `workspace_use_platform_managed`를 포함한 초기 스키마 최종본 | +| 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`와 external sync 규칙으로 대체 | +| 21 | `backend/iam/src/main/kotlin/io/deck/iam/service/AuthService.kt` | OAuth 로그인 후 workspace sync 진입점과 연결 | +| 22 | `backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2AuthenticationSuccessHandler.kt` | 실제 AIP 로그인 콜백 경로에서 external workspace sync 트리거 | +| 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만 책임진다. +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 필터를 그대로 탄다. 아직 릴리즈 전이므로 V1 안에서 DB table도 `platform_settings`로 정리한다. +5. external workspace lock은 엔티티가 아니라 service/use-case 경계에서 강제한다. +6. AIP sync는 `OAuth2AuthenticationSuccessHandler -> AuthService -> UserService` 실제 로그인 플로우에서 시작한다. + +## 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로 맞춘다. + - 아직 릴리즈 전이므로 `V1__init.sql` 안에서 DB 테이블명도 `platform_settings`로 정리하고, 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 schema와 platform settings schema를 V1 최종 스키마로 정리 + +**Files:** +- Modify: `backend/app/src/main/resources/db/migration/app/V1__init.sql` +- Modify: `backend/app/src/main/resources/db/migration/ci-seed/V9999__ci-seed.sql` + +- [x] **Step 1: migration regression 테스트 또는 flyway validation 기준 추가** + - `V1__init.sql`에 `workspaces.external_id`, `workspaces.auto_join_domains`, `menus.managed_type`, `platform_settings.workspace_use_platform_managed`가 모두 존재해야 한다는 검증을 추가한다. +- [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: 최소 구현** + - `allowed_domains -> auto_join_domains`, `workspace_use_system_managed -> workspace_use_platform_managed`, `external_id`, `menus.managed_type`를 `V1__init.sql`과 seed/update 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 backend/app/src/main/resources/db/migration/ci-seed/V9999__ci-seed.sql` + - `git commit -m "feat: fold platform reset schema 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`에 추가한다. + - 같은 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를 받아 `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.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` 전체 legacy grep cleanup PASS +10. full verification + - frontend `pnpm vitest run` PASS (`318 files / 2401 tests`) + - backend `./gradlew test` PASS + - frontend `pnpm build` PASS + - backend `./gradlew ktlintCheck` PASS + +## 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는 `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..ad6bedce4 --- /dev/null +++ b/docs/plans/2026-04-03-platform-reset-and-workspace-scope.md @@ -0,0 +1,369 @@ +--- +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로 제어한다. 메뉴와 워크스페이스는 공용 `ManagementType(USER_MANAGED | PLATFORM_MANAGED)`를 사용하고, 외부(AIP) 연계는 `Workspace.externalReference.externalId` 기반 foundation만 먼저 만든다. + +**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.externalId` + `PLATFORM_MANAGED` + +## Scope + +### 포함 + +- `System` 용어 제거 및 `Platform` 용어 고정 +- `/system/*` 계열 console route를 `/console/*` 계열로 재배치 +- `/settings/system/*`를 `/settings/platform/*`로 재배치 +- `SystemSetting*`를 `PlatformSetting*`로 rename +- `WorkspaceManagedType`를 공용 `ManagementType`으로 확장 +- menu의 `PLATFORM_MANAGED` 런타임 노출 규칙 추가 +- `Workspace.externalReference.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/*` + - `/console/deskpie/*` + - `/console/meetpie/*` +- settings: + - `/settings/account/*` + - `/settings/platform/*` +- public: + - `/p/:id` + - `/i/:token` + +### Workspace + +- 공통 settings 그룹으로 올리지 않는다. +- 서비스가 필요할 때만 `workspace_id` query param으로 scope를 준다. +- `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에서 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` | `/console/*`, `/settings/platform/*` canonicalization | +| 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/account/setting/workspace-tab.tsx` | `Workspace Policy`를 `Platform Settings` 하위 leaf로 유지 | +| FE entities | `frontend/app/src/entities/system-settings/*` | `platform-settings`로 rename 및 타입/경로 정리 | +| 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/SystemSettingEntity.kt` | `PlatformSettingEntity`로 rename, workspace policy 계속 소유 | +| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt` | `PlatformSettingService`로 rename | +| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt` | `PlatformSettingController`로 rename | +| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingAuthProviderController.kt` | auth provider settings rename | +| 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 수정 잠금, AIP sync foundation | +| 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` | pre-release 기준 canonical rename + workspace external columns | + +## 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/SystemSettingEntity.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/repository/SystemSettingRepository.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingAuthProviderController.kt` +- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/controller/SystemSettingControllerTest.kt` +- Test: `backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.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.SystemSettingControllerTest" --tests "io.deck.iam.service.SystemSettingServiceTest"` +- [ ] 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/system-settings/api.ts` +- Modify: `frontend/app/src/entities/system-settings/types.ts` +- Modify: `frontend/app/src/entities/system-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/account/setting/workspace-tab.tsx` +- Test: `frontend/app/src/entities/system-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/system-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/domain/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/system-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를 주면 동일 `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.controller.SystemSettingControllerTest" --tests "io.deck.iam.service.SystemSettingServiceTest" --tests "io.deck.iam.service.WorkspaceServiceTest" --tests "io.deck.iam.service.UserServiceTest" --tests "io.deck.iam.controller.MenuControllerTest" --tests "io.deck.iam.service.ProgramRegistryTest" :app:test --tests "io.deck.app.migration.AppMigrationMenuPermissionsTest"` +- [ ] 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/pages/settings/settings.page.test.tsx src/widgets/sidebar/Sidebar.test.tsx src/entities/system-settings/api.test.ts src/entities/workspace/store.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/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..dd0746b76 100644 --- a/docs/reference/backend/oauth-setup.md +++ b/docs/reference/backend/oauth-setup.md @@ -202,10 +202,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..d65b9a632 100644 --- a/docs/reference/frontend/router.md +++ b/docs/reference/frontend/router.md @@ -2,59 +2,67 @@ ## 목적 -각 탭 페이지 내에서 hash 기반 라우팅으로 뷰를 전환한다. +보호된 SPA 화면은 `pathname`으로 shell을 결정하고, 페이지 내부의 목록/상세 전환은 hash 라우팅으로 처리한다. + +## Shell 계약 + +- authenticated route + - `/console/*` + - `/settings/*` +- public route + - `/p/:id` + - `/i/:token` +- `console/settings` 중첩은 만들지 않는다. +- URL 계약은 `Settings` 복수형을 사용한다. ## 구성 요소 -| 모듈 | 위치 | 역할 | -| ------------------------- | ----------------- | -------------------------------------------- | -| `useRouter` | `@/shared/router` | hash 경로 매칭, navigate, label 제공 | -| `SystemLayout` breadcrumb | `@/layouts` | `breadcrumb` prop으로 title 뒤에 경로명 표시 | +| 모듈 | 위치 | 역할 | +| --- | --- | --- | +| `useRouter` | `@/shared/router` | hash 경로 매칭, navigate, label 제공 | +| 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 +70,34 @@ 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/` +- 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를 확장한다. ## 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/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..a954545b6 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,7 @@ toastSuccess('스케줄이 생성됐습니다.'); ### MUST - 메뉴 등록은 `ProgramRegistrar`에 `ProgramDefinition(code, path, permissions, workspace = ...)`를 추가한다. -- workspace가 전제인 page는 `ProgramDefinition.WorkspacePolicy(required = true)`를 선언한다. +- workspace가 전제인 page만 `ProgramDefinition.WorkspacePolicy(required = true)`를 선언한다. - 새 도메인 엔티티는 Delete 전략에 따라 `SoftDeleteEntity` 또는 `HardDeleteEntity`를 상속한다. Soft Delete 시 `@SQLRestriction`을 적용한다. - 모든 `@*Mapping` 핸들러에 `@PreAuthorize`를 명시한다. @@ -89,7 +89,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..2ab6adc65 100644 --- a/docs/reference/workspace.md +++ b/docs/reference/workspace.md @@ -2,56 +2,65 @@ ## 목적 -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)를 추가로 따른다. ## 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 기능 전체를 끈 것으로 해석한다. +- workspace 관리 타입은 `USER_MANAGED`, `PLATFORM_MANAGED` 두 가지다. +- `Workspace.externalReference?.externalId`는 AIP의 실제 조직 ID다. +- `externalReference != null`인 workspace는 external workspace이며 사실상 `PLATFORM_MANAGED`로 취급한다. +- external workspace의 identity와 membership은 Deck UI에서 직접 수정하지 않는다. +- `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 → UserService → Workspace sync by externalId ``` ## 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, managementType)`로 선언한다. - workspace가 필요한 page/program은 정책상 비활성화되면 메뉴에서 숨기고 직접 URL 접근도 `404`로 처리한다. +- service는 필요할 때만 `workspace_id` query param으로 active workspace를 요구한다. ### 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` 권한을 사용한다. @@ -76,36 +85,30 @@ SystemSettingController WorkspaceController MyWorkspaceC - `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 결과로만 반영한다. +- 사용자 삭제/탈퇴 시 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 +123,36 @@ SystemSettingController WorkspaceController MyWorkspaceC - invite 수락 시 기존 사용자는 membership만 추가하고, 비회원은 계정 생성 후 membership을 추가한다. - 비회원 invite로 생성되는 사용자는 명시 role이 없으면 현재 default role을 부여받는다. - role은 정확히 1개의 default를 유지해야 한다. -- 관리자 workspace와 `My Workspace`의 invite 정책은 동일해야 한다. ### 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를 수행한다. +- `externalId`가 같은 조직은 기존 workspace를 재사용한다. +- 매칭되는 workspace가 없으면 생성한다. +- 같은 사용자가 여러 외부 조직에 속할 수 있으므로 여러 workspace membership을 동시에 가질 수 있어야 한다. -### 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`와 `managementType`으로 필터링한다. - `currentWorkspaceId`는 filtered workspace 기준으로만 계산한다. - `useSelector=false`거나 filtered workspace가 `0`개면 sidebar는 horizontal logo를 표시한다. - workspace가 필요한 program은 활성 workspace가 없으면 menu에서 숨긴다. - direct URL 진입도 route guard에서 다시 검증한다. +- service는 필요할 때만 `workspace_id` query param을 읽는다. ### SHOULD @@ -163,38 +164,39 @@ 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만 요구한다. +- `managementType`이 필요한 page만 `USER_MANAGED` 또는 `PLATFORM_MANAGED`를 명시한다. +- 일반 business page는 `required = true`, `managementType = null`로 두고 현재 활성 workspace만 요구한다. ### 예시 -- `/system/workspaces`: `required = true`, `managedType = SYSTEM_MANAGED` -- `/my-workspaces`: `required = true`, `managedType = null` -- deskpie business pages: `required = true`, `managedType = null` +- `/console/workspaces`: `required = true`, `managementType = PLATFORM_MANAGED` +- `/console/my-workspaces`: `required = true`, `managementType = null` +- deskpie business pages: `required = true`, `managementType = null` - 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..e1346a9a4 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", @@ -110,7 +110,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..396a65c42 100644 --- a/frontend/app/src/app/App.tsx +++ b/frontend/app/src/app/App.tsx @@ -48,7 +48,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')); @@ -120,6 +119,12 @@ function AppShellRoute() { return ; } +function LegacyDashboardRedirect() { + const location = useLocation(); + + return ; +} + function useAppShellBootstrap(enabled: boolean) { useEffect(() => { if (!enabled) return; @@ -161,8 +166,14 @@ const standaloneRoutes: RouteObject[] = [ { path: '/legal/terms/*', element: withStandaloneFallback() }, { path: '/legal/:serviceId/:documentId', element: withStandaloneFallback() }, { path: '/settings/*', element: withStandaloneFallback() }, + { path: '/dashboard', element: }, + { path: '/dashboard/*', element: }, + { path: '/my-workspaces', element: }, + { path: '/my-workspaces/*', element: }, + { path: '/console/menus', element: }, + { path: '/console/menus/*', element: }, { path: '/account/profile/*', element: }, - { path: '/account/setting/*', element: withStandaloneFallback() }, + { path: '/account/setting/*', 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..e40be93b7 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,38 @@ 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('/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 } + ); + }); + describe('Icon 컴포넌트 전환 회귀', () => { it('App JSX에 data-lucide 속성이 존재하지 않아야 함', () => { const { container } = render(); @@ -482,12 +514,12 @@ describe('App overlay dismiss 통합', () => { }, ]); await Promise.resolve(); - setSystemSettings({ + setPlatformSettings({ brandName: 'Deck', baseUrl: 'https://localhost:4022', workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, useSelector: true, }, }); @@ -498,7 +530,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..0a79100b9 100644 --- a/frontend/app/src/app/auth.test.ts +++ b/frontend/app/src/app/auth.test.ts @@ -31,25 +31,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..2548df4df 100644 --- a/frontend/app/src/app/bootstrap.test.ts +++ b/frontend/app/src/app/bootstrap.test.ts @@ -233,7 +233,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 +257,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 +275,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 +286,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 +304,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 +312,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..67c234f00 100644 --- a/frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx +++ b/frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx @@ -129,7 +129,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,11 +138,11 @@ 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'); }); }); @@ -162,14 +162,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 +195,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..58affbb11 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; } 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/page-access.ts b/frontend/app/src/app/page-access.ts index 74a8e5e55..e18f6cfa1 100644 --- a/frontend/app/src/app/page-access.ts +++ b/frontend/app/src/app/page-access.ts @@ -1,7 +1,7 @@ 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 type { WorkspacePolicy } from '#app/entities/platform-settings'; +import { platformSettings } from '#app/entities/platform-settings'; +import { isProgramAccessible } from '#app/entities/platform-settings/workspace-access'; import { getProgramByPath, programs, type Program } from '#app/widgets/sidebar'; import { resolveRouteDescriptor } from '#app/shared/router'; import { getNotFoundPage, getPage, hasPage } from './page-registry'; @@ -32,7 +32,7 @@ export function isPageAccessible( export function canAccessPage(url: string): boolean { const path = resolveCanonicalPath(url); const program = getProgramByPath(path); - const workspacePolicy = systemSettings.get()?.workspacePolicy ?? null; + const workspacePolicy = platformSettings.get()?.workspacePolicy ?? null; const activeWorkspaceId = currentWorkspaceId.get(); return isPageAccessible(path, program, workspacePolicy, activeWorkspaceId); @@ -48,6 +48,6 @@ export function getAccessiblePage(url: string) { export function usePageAccessDependencies() { programs.useStore(); - systemSettings.useStore(); + 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..26d2d9513 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 type { WorkspacePolicy } from '#app/entities/platform-settings'; import { 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, type UserSession } from '#app/features/auth'; @@ -31,7 +31,7 @@ const session: UserSession = { describe('page-registry workspace access', () => { afterEach(() => { setPrograms([]); - setSystemSettings(null); + setPlatformSettings(null); currentWorkspaceId.set(''); clearSession(); }); @@ -40,31 +40,31 @@ 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', + managedType: '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, useSelector: true, }, } as never); currentWorkspaceId.set('ws-1'); - const pageAfterAccess = getPage('/system/workspaces/'); + const pageAfterAccess = getPage('/console/workspaces/'); expect(pageAfterAccess).toBe(pageBeforeAccess); }); @@ -73,32 +73,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', + managedType: '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, useSelector: true, }, } as never); currentWorkspaceId.set('ws-1'); - expect(getAccessiblePage('/system/workspaces/')).toBe(actualPage); + expect(getAccessiblePage('/console/workspaces/')).toBe(actualPage); }); it('workspacePolicy가 null이면 workspace required 페이지를 막아야 한다', () => { @@ -109,12 +109,12 @@ describe('page-registry workspace access', () => { }, }); - expect(isPageAccessible('/my-workspaces/', program, null, '')).toBe(false); + expect(isPageAccessible('/console/my-workspaces/', program, null, '')).toBe(false); }); it('managedType이 꺼져 있으면 해당 페이지를 막아야 한다', () => { const program = createProgram({ - path: '/my-workspaces', + path: '/console/my-workspaces', workspace: { required: true, managedType: 'USER_MANAGED', @@ -122,11 +122,11 @@ describe('page-registry workspace access', () => { }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: 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가 없으면 막아야 한다', () => { @@ -139,7 +139,7 @@ describe('page-registry workspace access', () => { }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: true, useSelector: true, }; @@ -156,7 +156,7 @@ describe('page-registry workspace access', () => { }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: true, useSelector: true, }; @@ -165,7 +165,7 @@ describe('page-registry workspace access', () => { it('my-workspaces는 system-managed only 정책에서도 visible workspace가 있으면 접근을 허용한다', () => { const program = createProgram({ - path: '/my-workspaces', + path: '/console/my-workspaces', workspace: { required: true, managedType: null, @@ -173,62 +173,67 @@ describe('page-registry workspace access', () => { }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: true, useSelector: true, }; - expect(isPageAccessible('/my-workspaces/', program, policy, 'ws-1')).toBe(true); + expect(isPageAccessible('/console/my-workspaces/', program, policy, 'ws-1')).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', + managedType: 'PLATFORM_MANAGED', }, }); const policy: WorkspacePolicy = { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: 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, 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, 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..9613ae9ef 100644 --- a/frontend/app/src/app/page-registry.ts +++ b/frontend/app/src/app/page-registry.ts @@ -4,29 +4,28 @@ import { resolveRouteDescriptor } from '#app/shared/router'; 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/audit-logs/': () => import('#app/pages/system/audit-logs/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 }>>) { 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..a5d282f1a 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'; @@ -37,7 +37,7 @@ describe('SidebarWrapper', () => { act(() => { user.set(null); theme.set('light'); - setSystemSettings(null); + setPlatformSettings(null); workspaceList.set([]); }); cleanup(); @@ -125,12 +125,12 @@ describe('SidebarWrapper', () => { it('selector가 비활성화되면 workspace가 있어도 horizontal logo를 렌더링해야 함', () => { act(() => { - setSystemSettings({ + setPlatformSettings({ brandName: 'Deck', baseUrl: 'https://deck.test', workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, useSelector: false, }, }); @@ -191,7 +191,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 +311,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 +381,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/tabs.test.ts b/frontend/app/src/app/tabs.test.ts index 54e61be71..9dac128b6 100644 --- a/frontend/app/src/app/tabs.test.ts +++ b/frontend/app/src/app/tabs.test.ts @@ -129,7 +129,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'); @@ -175,13 +175,13 @@ 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/' }]; await import('./tabs'); mockActiveTabId.set('tab-1'); await vi.waitFor(() => { - expect(replaceStateMock).toHaveBeenCalledWith({}, '', '/system/users/'); + expect(replaceStateMock).toHaveBeenCalledWith({}, '', '/console/users/'); }); }); @@ -196,7 +196,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,14 +210,14 @@ 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', }; @@ -255,21 +255,21 @@ 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({ code: 'MY_WORKSPACE', - path: '/my-workspaces', + path: '/console/my-workspaces', permissions: [], }); vi.mocked(sidebar.findRawMenuByPath).mockReturnValue({ id: 'my-workspace', label: 'My Workspace', icon: 'Building2', - url: '/my-workspaces/', + url: '/console/my-workspaces/', }); const { restoreTabs } = await import('./tabs'); @@ -279,7 +279,7 @@ describe('tabs', () => { id: 'my-workspace', title: 'My Workspace', icon: 'Building2', - url: '/my-workspaces/', + url: '/console/my-workspaces/', }); expect(mockActiveTabId._value).toBe('my-workspace'); }); @@ -317,42 +317,42 @@ describe('tabs', () => { it('workspace detail URL로 직접 진입하면 parent menu metadata로 탭을 복원해야 함', async () => { 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({ code: 'WORKSPACE_MANAGEMENT', - path: '/system/workspaces', + path: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ'], workspace: { required: true, - managedType: 'SYSTEM_MANAGED', + managedType: 'PLATFORM_MANAGED', }, }); vi.mocked(sidebar.findRawMenuByPath).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('현재 URL이 권한 없는 program path와 일치하면 탭을 복원하지 않아야 함', async () => { Object.defineProperty(window, 'location', { - value: { ...window.location, pathname: '/system/workspaces/' }, + value: { ...window.location, pathname: '/console/workspaces/' }, writable: true, }); sessionStorage.setItem( @@ -372,11 +372,11 @@ describe('tabs', () => { const sidebar = await getSidebarModule(); vi.mocked(sidebar.getProgramByPath).mockReturnValue({ code: 'WORKSPACE_MANAGEMENT', - path: '/system/workspaces', + path: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ', 'WORKSPACE_MANAGEMENT_WRITE'], workspace: { required: true, - managedType: 'SYSTEM_MANAGED', + managedType: 'PLATFORM_MANAGED', }, }); vi.mocked(sidebar.findRawMenuByPath).mockReturnValue(null); @@ -409,7 +409,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 +419,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..b5cf559fc 100644 --- a/frontend/app/src/app/tabs.ts +++ b/frontend/app/src/app/tabs.ts @@ -1,6 +1,6 @@ import { currentWorkspaceId } from '#app/entities/workspace'; -import { systemSettings } from '#app/entities/system-settings'; -import { isProgramAccessible } from '#app/entities/system-settings/workspace-access'; +import { platformSettings } from '#app/entities/platform-settings'; +import { isProgramAccessible } from '#app/entities/platform-settings/workspace-access'; import { tabs, activeTabId, @@ -196,7 +196,7 @@ export function restoreTabs(initialUrl = getCurrentUrl()) { (!!currentPathProgram && isProgramAccessible( currentPathProgram, - systemSettings.get()?.workspacePolicy ?? null, + platformSettings.get()?.workspacePolicy ?? null, currentWorkspaceId.get() )); 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/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..0ec7b579b 100644 --- a/frontend/app/src/entities/menu/types.ts +++ b/frontend/app/src/entities/menu/types.ts @@ -4,7 +4,7 @@ * 메뉴 관련 타입 정의 */ -import type { WorkspaceManagedType } from '#app/entities/system-settings'; +import type { ManagementType, WorkspaceManagedType } from '#app/entities/platform-settings'; export interface ProgramWorkspacePolicy { required: boolean; @@ -17,6 +17,7 @@ export interface Menu { namesI18n?: Record; icon?: string; program?: string; + managementType?: ManagementType; permissions: string[]; url?: string; sortOrder?: number; @@ -37,6 +38,7 @@ export interface CreateMenuData { name: string; namesI18n?: Record; icon?: string; + managementType?: ManagementType; url?: string; sortOrder?: number; parentId?: string | null; @@ -47,6 +49,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..8992559bd --- /dev/null +++ b/frontend/app/src/entities/platform-settings/api.test.ts @@ -0,0 +1,82 @@ +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, useSelector: true }, + }); + + expect(http.put).toHaveBeenCalledWith('/platform-settings/workspace-policy', { + workspacePolicy: { useUserManaged: true, usePlatformManaged: 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 73% rename from frontend/app/src/entities/system-settings/index.ts rename to frontend/app/src/entities/platform-settings/index.ts index 27bfd7447..a2d18a287 100644 --- a/frontend/app/src/entities/system-settings/index.ts +++ b/frontend/app/src/entities/platform-settings/index.ts @@ -1,11 +1,12 @@ -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, 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 91% rename from frontend/app/src/entities/system-settings/types.ts rename to frontend/app/src/entities/platform-settings/types.ts index 45db73b45..22f43b51b 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 type WorkspaceManagedType = ManagementType; export interface WorkspacePolicy { useUserManaged: boolean; - useSystemManaged: boolean; + usePlatformManaged: 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..37707c60f --- /dev/null +++ b/frontend/app/src/entities/platform-settings/workspace-access.test.ts @@ -0,0 +1,22 @@ +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, managedType: 'PLATFORM_MANAGED' }, + { + useUserManaged: true, + usePlatformManaged: false, + useSelector: true, + }, + 'workspace-1' + ) + ).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 93% rename from frontend/app/src/entities/system-settings/workspace-access.ts rename to frontend/app/src/entities/platform-settings/workspace-access.ts index 5c42f7e48..ee4e8768e 100644 --- a/frontend/app/src/entities/system-settings/workspace-access.ts +++ b/frontend/app/src/entities/platform-settings/workspace-access.ts @@ -23,7 +23,7 @@ export function isWorkspaceAccessible( return false; } - if (workspace.managedType === 'SYSTEM_MANAGED' && !workspacePolicy.useSystemManaged) { + if (workspace.managedType === 'PLATFORM_MANAGED' && !workspacePolicy.usePlatformManaged) { 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/store.test.ts b/frontend/app/src/entities/workspace/store.test.ts index 2516533a3..2770f2ca7 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,7 @@ describe('workspace store helpers', () => { it('useUserManaged만 켜져 있으면 USER_MANAGED만 남긴다', () => { const policy: WorkspacePolicy = { useUserManaged: true, - useSystemManaged: false, + usePlatformManaged: 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..76fdf8bfa 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 { WorkspaceManagedType } from '#app/entities/platform-settings'; export interface WorkspaceOwner { id: string; @@ -6,14 +6,19 @@ export interface WorkspaceOwner { email: string; } +export interface ExternalReference { + externalId: string; +} + export interface Workspace { id: string; name: string; description: string | null; - allowedDomains?: string[]; + autoJoinDomains?: string[]; owners: WorkspaceOwner[]; memberCount: number; managedType: WorkspaceManagedType; + externalReference?: ExternalReference | null; createdAt: string | null; updatedAt: string | null; } @@ -22,10 +27,11 @@ export interface MyWorkspace { id: string; name: string; description: string | null; - allowedDomains?: string[]; + autoJoinDomains?: string[]; owners: WorkspaceOwner[]; memberCount: number; managedType: WorkspaceManagedType; + externalReference?: ExternalReference | null; role: 'OWNER' | 'MEMBER'; createdAt: string | null; updatedAt: string | null; @@ -34,14 +40,14 @@ export interface MyWorkspace { export interface CreateWorkspaceRequest { name: string; description?: string; - allowedDomains: string[]; + autoJoinDomains: string[]; managedType?: WorkspaceManagedType; } export interface UpdateWorkspaceRequest { name: string; description?: string; - allowedDomains: string[]; + autoJoinDomains: string[]; managedType?: WorkspaceManagedType; } 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/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..38847f9ef 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..8dc1ea87d --- /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: { 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..2168053b8 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..3d4d4ef38 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: { 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..a6ccf1ab6 100644 --- a/frontend/app/src/pages/settings/settings-nav.ts +++ b/frontend/app/src/pages/settings/settings-nav.ts @@ -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,13 +65,13 @@ 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', @@ -74,7 +80,7 @@ export const settingsNav: SettingsGroup[] = [ { 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', @@ -83,39 +89,39 @@ export const settingsNav: SettingsGroup[] = [ { 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, }, { - 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, - }, { 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, }, + { + key: 'menus', + labelKey: 'settingsShell.leaves.menus.label', + path: settingsPlatformMenusPath, + titleKey: 'settingsShell.leaves.menus.title', + subtitleKey: 'settingsShell.leaves.menus.subtitle', + icon: 'PanelsTopLeft', + ownerOnly: true, + }, ], }, ]; diff --git a/frontend/app/src/pages/settings/settings.page.test.tsx b/frontend/app/src/pages/settings/settings.page.test.tsx index 29b1299bc..3d5561ee5 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,15 @@ 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'); + }, + }; +}); + const ownerUser = { id: 'user-1', username: 'owner', @@ -202,14 +202,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 +217,7 @@ vi.mock('#app/entities/system-settings', async () => { }), getPublicBranding: vi.fn(() => new Promise(() => {})), }, - setSystemSettings: vi.fn(), + setPlatformSettings: vi.fn(), }; }); @@ -247,7 +247,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 +259,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 +300,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 +308,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 +318,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 +335,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 +359,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 +371,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 +386,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 +405,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 +484,8 @@ describe('SettingsPage route shell', () => { }); }); - it('마운트 시 system settings를 미리 로드해야 함', async () => { - const { systemSettingsApi } = await import('#app/entities/system-settings'); + it('마운트 시 platform settings를 미리 로드해야 함', async () => { + const { platformSettingsApi } = await import('#app/entities/platform-settings'); window.history.replaceState({}, '', '/settings/account/profile'); render( @@ -495,7 +495,7 @@ describe('SettingsPage route shell', () => { ); await waitFor(() => { - expect(systemSettingsApi.get).toHaveBeenCalledTimes(1); + expect(platformSettingsApi.get).toHaveBeenCalledTimes(1); }); }); @@ -527,8 +527,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 +541,8 @@ 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( diff --git a/frontend/app/src/pages/settings/settings.page.tsx b/frontend/app/src/pages/settings/settings.page.tsx index 75725e08b..d7328b894 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,12 @@ 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 { 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 +32,7 @@ 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 effectiveHasInternalIdentity = currentUser?.hasInternalIdentity ?? hasInternalIdentity; useEffect(() => { @@ -50,9 +50,9 @@ export function SettingsPage() { }, []); useEffect(() => { - void systemSettingsApi.get().then((data) => { + void platformSettingsApi.get().then((data) => { setSettings(data); - setSystemSettings(data); + setPlatformSettings(data); }); }, []); @@ -93,23 +93,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 +145,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..08920f853 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,7 @@ const mockSettings = { baseUrl: 'https://deck.example.com', workspacePolicy: { useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, useSelector: true, }, }; @@ -126,12 +126,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 +139,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 +148,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 +160,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 +173,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 72% 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..5be71c228 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,7 @@ const settings = { baseUrl: 'https://deck.test', workspacePolicy: { useUserManaged: true, - useSystemManaged: false, + usePlatformManaged: false, useSelector: true, }, }; @@ -44,7 +44,7 @@ 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 selector')).toBeDefined(); expect(screen.getAllByTestId('settings-row')).toHaveLength(3); expect(screen.getByTestId('settings-page-actions')).toBeDefined(); @@ -61,6 +61,17 @@ describe('WorkspaceTab', () => { expect(description.previousElementSibling?.textContent).toBe('Use user-managed'); }); + it('platform-managed 설명은 external AIP sync workspace 의미를 안내해야 한다', () => { + render(); + + const description = screen.getByText( + 'Allow external workspaces that are synced from AIP organization claims.' + ); + + expect(description.closest('[data-testid="settings-row"]')).not.toBeNull(); + expect(description.previousElementSibling?.textContent).toBe('Use platform-managed'); + }); + it('workspace 제목을 클릭하면 해당 토글이 전환되어야 한다', () => { render(); @@ -73,7 +84,7 @@ describe('WorkspaceTab', () => { }); it('두 managed 타입을 모두 끄면 null policy로 저장해야 한다', async () => { - (systemSettingsApi.updateWorkspacePolicy as Mock).mockResolvedValue(settings); + (platformSettingsApi.updateWorkspacePolicy as Mock).mockResolvedValue(settings); render(); @@ -81,7 +92,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 80% rename from frontend/app/src/pages/account/setting/workspace-tab.tsx rename to frontend/app/src/pages/settings/tabs/workspace-tab.tsx index 33399f839..e568ed467 100644 --- a/frontend/app/src/pages/account/setting/workspace-tab.tsx +++ b/frontend/app/src/pages/settings/tabs/workspace-tab.tsx @@ -8,22 +8,22 @@ 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) { + if (!policy.useUserManaged && !policy.usePlatformManaged) { return null; } return policy; @@ -33,7 +33,7 @@ export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) const { t } = useTranslation('account'); const [policy, setPolicy] = useState({ useUserManaged: true, - useSystemManaged: true, + usePlatformManaged: true, useSelector: true, }); const [saving, setSaving] = useState(false); @@ -44,7 +44,7 @@ export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) } else { setPolicy({ useUserManaged: false, - useSystemManaged: false, + usePlatformManaged: false, useSelector: false, }); } @@ -56,11 +56,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 +76,9 @@ 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: 'useSelector' as const, 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..5ea46920b 100644 --- a/frontend/app/src/pages/system/users/users.page.test.tsx +++ b/frontend/app/src/pages/system/users/users.page.test.tsx @@ -70,7 +70,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..697c39e7f 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: { 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', }, @@ -175,7 +176,7 @@ describe('WorkspacesPage', () => { expect(mockGrid.props?.rowId).toBe('id'); expect(mockGrid.props?.selectedRowIds).toEqual(['ws-1', '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..42c2bf612 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,22 @@ 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'); + }); }); diff --git a/frontend/app/src/shared/auth-redirect.ts b/frontend/app/src/shared/auth-redirect.ts index 0829ce2c8..0ac669007 100644 --- a/frontend/app/src/shared/auth-redirect.ts +++ b/frontend/app/src/shared/auth-redirect.ts @@ -3,12 +3,44 @@ import { getSearchParams } from '#app/shared/runtime'; 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'; +const DEFAULT_POST_AUTH_URL = '/console/dashboard'; function toPathnameSearchHash(url: URL): string { return `${url.pathname}${url.search}${url.hash}`; } +function normalizeLegacyPath(path: string): string { + const url = new URL(path, window.location.origin); + const pathname = url.pathname.replace(/\/$/, '') || '/'; + + if (pathname === '/system' || pathname.startsWith('/system/')) { + url.pathname = pathname.replace(/^\/system/, '/console'); + 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 === '/account/setting' || pathname.startsWith('/account/setting/')) { + url.pathname = '/settings/platform/general'; + return toPathnameSearchHash(url); + } + + return path; +} + function safeDecode(value: string): string { try { return decodeURIComponent(value); @@ -42,7 +74,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(toPathnameSearchHash(url)); 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..49ed8110e 100644 --- a/frontend/app/src/shared/i18n/locales/en/account.json +++ b/frontend/app/src/shared/i18n/locales/en/account.json @@ -36,8 +36,8 @@ "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 external workspaces that are synced 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 +201,7 @@ "backToApp": "Back to App", "groups": { "account": "Account", - "system": "System" + "platform": "Platform" }, "leaves": { "profile": { @@ -227,17 +227,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 +248,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..6fe393f68 100644 --- a/frontend/app/src/shared/i18n/locales/ja/account.json +++ b/frontend/app/src/shared/i18n/locales/ja/account.json @@ -36,8 +36,8 @@ "workspace": { "useUserManaged": "ユーザー管理型を使用", "useUserManagedDescription": "ユーザーが自分で作成・管理する workspace を使用できます。", - "useSystemManaged": "システム管理型を使用", - "useSystemManagedDescription": "管理者が作成して割り当てる workspace を使用できます。", + "usePlatformManaged": "プラットフォーム管理型を使用", + "usePlatformManagedDescription": "AIP の organization claim と同期される external workspace を使用できます。", "useSelector": "選択 UI を使用", "useSelectorDescription": "表示可能な workspace がある場合にサイドバーへ selector を表示します。", "enabledCountryCodes": "許可する国コード", @@ -201,7 +201,7 @@ "backToApp": "アプリに戻る", "groups": { "account": "アカウント", - "system": "システム" + "platform": "プラットフォーム" }, "leaves": { "profile": { @@ -227,17 +227,12 @@ "general": { "label": "一般", "title": "一般", - "subtitle": "システムの既定値を管理します" + "subtitle": "プラットフォームの既定値を管理します" }, - "workspace": { - "label": "ワークスペース", - "title": "ワークスペース", - "subtitle": "ワークスペースのポリシーと可用性を管理します" - }, - "globalization": { - "label": "グローバル", - "title": "グローバル", - "subtitle": "バックオフィスの国と通貨の既定値を管理します" + "workspacePolicy": { + "label": "ワークスペースポリシー", + "title": "ワークスペースポリシー", + "subtitle": "ワークスペースの可用性と同期の既定値を管理します" }, "branding": { "label": "ブランディング", @@ -253,6 +248,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..7b8c2a6d5 100644 --- a/frontend/app/src/shared/i18n/locales/ko/account.json +++ b/frontend/app/src/shared/i18n/locales/ko/account.json @@ -36,8 +36,8 @@ "workspace": { "useUserManaged": "사용자 관리형 사용", "useUserManagedDescription": "사용자가 직접 생성하고 관리하는 workspace를 허용합니다.", - "useSystemManaged": "시스템 관리형 사용", - "useSystemManagedDescription": "관리자가 생성하고 배정하는 workspace를 허용합니다.", + "usePlatformManaged": "플랫폼 관리형 사용", + "usePlatformManagedDescription": "AIP 조직 claim과 동기화되는 external workspace를 허용합니다.", "useSelector": "선택 UI 사용", "useSelectorDescription": "보이는 workspace가 있을 때 사이드바에 workspace 선택 UI를 표시합니다.", "enabledCountryCodes": "허용 국가 코드", @@ -201,7 +201,7 @@ "backToApp": "앱으로 돌아가기", "groups": { "account": "계정", - "system": "시스템" + "platform": "플랫폼" }, "leaves": { "profile": { @@ -227,17 +227,12 @@ "general": { "label": "일반", "title": "일반", - "subtitle": "시스템 기본값을 관리합니다" + "subtitle": "플랫폼 기본값을 관리합니다" }, - "workspace": { - "label": "워크스페이스", - "title": "워크스페이스", - "subtitle": "워크스페이스 정책과 가용성을 관리합니다" - }, - "globalization": { - "label": "글로벌", - "title": "글로벌", - "subtitle": "백오피스 폼의 국가 및 통화 기본값을 관리합니다" + "workspacePolicy": { + "label": "워크스페이스 정책", + "title": "워크스페이스 정책", + "subtitle": "워크스페이스 가용성과 동기화 기본값을 관리합니다" }, "branding": { "label": "브랜딩", @@ -253,6 +248,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/route-descriptor.test.ts b/frontend/app/src/shared/router/route-descriptor.test.ts index 47bb13be3..2cc1721b2 100644 --- a/frontend/app/src/shared/router/route-descriptor.test.ts +++ b/frontend/app/src/shared/router/route-descriptor.test.ts @@ -2,44 +2,44 @@ import { describe, expect, it } from 'vitest'; import { resolveRouteDescriptor } 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/', + canonicalPath: '/console/dashboard/', 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', }); diff --git a/frontend/app/src/shared/router/route-descriptor.ts b/frontend/app/src/shared/router/route-descriptor.ts index 5a96faa91..ed9e9171e 100644 --- a/frontend/app/src/shared/router/route-descriptor.ts +++ b/frontend/app/src/shared/router/route-descriptor.ts @@ -1,6 +1,9 @@ const URL_PARSE_BASE = 'http://localhost'; -const SYSTEM_WORKSPACE_DETAIL_PATTERN = /^\/system\/workspaces\/([^/]+)\/?$/; -const MY_WORKSPACE_DETAIL_PATTERN = /^\/my-workspaces\/([^/]+)\/?$/; +const CONSOLE_WORKSPACE_DETAIL_PATTERN = /^\/console\/workspaces\/([^/]+)\/?$/; +const MY_WORKSPACE_DETAIL_PATTERN = /^\/console\/my-workspaces\/([^/]+)\/?$/; +const LEGACY_CANONICAL_PATHS: Record = { + '/dashboard': '/console/dashboard', +}; export interface RouteDescriptor { actualPath: string; @@ -18,16 +21,19 @@ export function resolveRouteDescriptor(url: string): RouteDescriptor { const parsedUrl = new URL(url, URL_PARSE_BASE); const pathnameWithoutTrailingSlash = parsedUrl.pathname === '/' ? parsedUrl.pathname : parsedUrl.pathname.replace(/\/$/, ''); + const legacyCanonicalPath = LEGACY_CANONICAL_PATHS[pathnameWithoutTrailingSlash]; const normalizedPath = normalizePathname(parsedUrl.pathname); const actualPath = `${normalizedPath}${parsedUrl.search}${parsedUrl.hash}`; - const systemWorkspaceDetail = pathnameWithoutTrailingSlash.match(SYSTEM_WORKSPACE_DETAIL_PATTERN); - if (systemWorkspaceDetail) { + const consoleWorkspaceDetail = pathnameWithoutTrailingSlash.match( + CONSOLE_WORKSPACE_DETAIL_PATTERN + ); + if (consoleWorkspaceDetail) { return { actualPath: `${pathnameWithoutTrailingSlash}${parsedUrl.search}${parsedUrl.hash}`, - canonicalPath: '/system/workspaces/', + canonicalPath: '/console/workspaces/', kind: 'workspace-detail', - workspaceId: systemWorkspaceDetail[1], + workspaceId: consoleWorkspaceDetail[1], }; } @@ -35,12 +41,28 @@ export function resolveRouteDescriptor(url: string): RouteDescriptor { if (myWorkspaceDetail) { return { actualPath: `${pathnameWithoutTrailingSlash}${parsedUrl.search}${parsedUrl.hash}`, - canonicalPath: '/my-workspaces/', + canonicalPath: '/console/my-workspaces/', kind: 'workspace-detail', workspaceId: myWorkspaceDetail[1], }; } + if (pathnameWithoutTrailingSlash.startsWith('/settings/')) { + return { + actualPath: `${pathnameWithoutTrailingSlash}${parsedUrl.search}${parsedUrl.hash}`, + canonicalPath: pathnameWithoutTrailingSlash, + kind: 'regular', + }; + } + + if (legacyCanonicalPath) { + return { + actualPath, + canonicalPath: normalizePathname(legacyCanonicalPath), + kind: 'regular', + }; + } + return { actualPath, canonicalPath: normalizedPath, 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/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..4fb0462f4 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, @@ -43,7 +44,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 +63,8 @@ describe('Sidebar', () => { afterEach(() => { cleanup(); currentWorkspaceId.set(''); - setSystemSettings(null); + setPlatformSettings(null); + user.set(null); sessionStorage.clear(); }); @@ -285,7 +298,7 @@ describe('Sidebar', () => { setPrograms([ { code: 'MY_WORKSPACE', - path: '/my-workspaces', + path: '/console/my-workspaces', permissions: [], workspace: { required: true, @@ -293,12 +306,12 @@ describe('Sidebar', () => { }, }, ]); - setSystemSettings({ + setPlatformSettings({ brandName: 'Deck', baseUrl: 'http://localhost:8011', workspacePolicy: { useUserManaged: false, - useSystemManaged: true, + usePlatformManaged: true, useSelector: true, }, }); @@ -320,7 +333,7 @@ describe('Sidebar', () => { setPrograms([ { code: 'MY_WORKSPACE', - path: '/my-workspaces', + path: '/console/my-workspaces', permissions: [], workspace: { required: true, @@ -329,15 +342,15 @@ describe('Sidebar', () => { }, { code: 'WORKSPACE_MANAGEMENT', - path: '/system/workspaces', + path: '/console/workspaces', permissions: [], workspace: { required: true, - managedType: 'SYSTEM_MANAGED', + managedType: 'PLATFORM_MANAGED', }, }, ]); - setSystemSettings({ + setPlatformSettings({ brandName: 'Deck', baseUrl: 'http://localhost:8011', workspacePolicy: null, @@ -369,6 +382,56 @@ describe('Sidebar', () => { expect(menuItems.get()).toHaveLength(0); }); + + 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', + }); + }); }); describe('toggleGroup', () => { diff --git a/frontend/app/src/widgets/sidebar/Sidebar.tsx b/frontend/app/src/widgets/sidebar/Sidebar.tsx index 5a4207916..5e5d5fc22 100644 --- a/frontend/app/src/widgets/sidebar/Sidebar.tsx +++ b/frontend/app/src/widgets/sidebar/Sidebar.tsx @@ -9,8 +9,9 @@ import { createStore } from '#app/shared/store/create-store'; import { Icon } from '#app/shared/icon'; +import { user } from '#app/app/state'; import { currentWorkspaceId } from '#app/entities/workspace'; -import { systemSettings, isProgramAccessible } from '#app/entities/system-settings'; +import { platformSettings, isProgramAccessible } from '#app/entities/platform-settings'; import { SidebarMenu, SidebarMenuItem, @@ -23,7 +24,7 @@ import { 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 type { ManagementType, WorkspaceManagedType } from '#app/entities/platform-settings'; const SIDEBAR_KEY = 'deck-sidebar'; const MENU_EXPANDED_KEY = 'deck-menu-expanded'; @@ -54,6 +55,7 @@ export interface ApiMenu { name: string; icon?: string; program?: string; + managementType?: ManagementType; permissions: string[]; children: ApiMenu[]; } @@ -129,12 +131,16 @@ function isMenuProgramAccessible(programCode?: string): boolean { return isProgramAccessible( program, - systemSettings.get()?.workspacePolicy ?? null, + platformSettings.get()?.workspacePolicy ?? null, currentWorkspaceId.get() ); } function convertApiMenuToMenuItem(apiMenu: ApiMenu): MenuItem | null { + if (apiMenu.managementType === 'PLATFORM_MANAGED' && !user.get()?.isOwner) { + return null; + } + const program = findProgram(apiMenu.program); const url = program?.path || undefined; const children = apiMenu.children @@ -266,7 +272,7 @@ export function resetSidebar() { currentRole.set(''); } -systemSettings.subscribe(syncMenuData); +platformSettings.subscribe(syncMenuData); currentWorkspaceId.subscribe(syncMenuData); // ============================================ 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/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/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/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..981caf1e1 100644 --- a/frontend/tests/helpers/auth-state.ts +++ b/frontend/tests/helpers/auth-state.ts @@ -6,7 +6,7 @@ export function isAllowedSignedInPath(pathname: string): boolean { return ( pathname === "/" || pathname === "/dashboard/" || - pathname === "/my-workspaces/" + pathname === "/console/my-workspaces/" ); } diff --git a/frontend/tests/helpers/auth.ts b/frontend/tests/helpers/auth.ts index b68bf13f7..d60960551 100644 --- a/frontend/tests/helpers/auth.ts +++ b/frontend/tests/helpers/auth.ts @@ -49,7 +49,7 @@ async function waitForSignedIn(page: Page, baseURL: string): Promise { originMatched && (pathname === "/" || pathname === "/dashboard/" || - pathname === "/my-workspaces/") + pathname === "/console/my-workspaces/") ); }, { expectedOrigin: new URL(baseURL).origin }, 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/chunk-recovery.spec.ts b/frontend/tests/manual/app/chunk-recovery.spec.ts index 145bd3274..72df4b443 100644 --- a/frontend/tests/manual/app/chunk-recovery.spec.ts +++ b/frontend/tests/manual/app/chunk-recovery.spec.ts @@ -12,7 +12,7 @@ test.describe('채널 매뉴얼 — stale chunk recovery', { tag: ['@manual', '@ await page.getByRole('button', { name: 'Login' }).click() await page.waitForURL('**/') - 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/logs-pagination.spec.ts b/frontend/tests/manual/app/logs-pagination.spec.ts index 92d7df6c0..098f24ed5 100644 --- a/frontend/tests/manual/app/logs-pagination.spec.ts +++ b/frontend/tests/manual/app/logs-pagination.spec.ts @@ -35,6 +35,6 @@ test.describe('로그 매뉴얼 — 페이지네이션', { tag: ['@manual', '@ma }) 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/shared-registry-contracts.spec.ts b/frontend/tests/manual/app/shared-registry-contracts.spec.ts index 6a83c3037..b24564f4f 100644 --- a/frontend/tests/manual/app/shared-registry-contracts.spec.ts +++ b/frontend/tests/manual/app/shared-registry-contracts.spec.ts @@ -36,7 +36,7 @@ test.describe( test("알림/활동 로그 화면이 shared registry contract를 통해 정상 로드된다", async ({ page, }) => { - await ensureSignedIn(page, "/system/notification-channels/"); + await ensureSignedIn(page, "/console/notification-channels/"); 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..353a39b6c 100644 --- a/frontend/tests/manual/app/system.spec.ts +++ b/frontend/tests/manual/app/system.spec.ts @@ -22,12 +22,12 @@ test.describe('시스템 매뉴얼 — Dashboard', { tag: ['@manual', '@smoke', // 2. Users // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Users', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Users', { tag: ['@manual', '@manager'] }, () => { test('사용자 목록이 표시된다', async ({ page }) => { - await page.goto('/system/users/') - await expect(page).toHaveURL(/system\/users/, { timeout: 15000 }) + await page.goto('/console/users/') + await expect(page).toHaveURL(/console\/users/, { timeout: 15000 }) await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-users') + await capture(page, 'app/console-users') }) }) @@ -35,12 +35,12 @@ test.describe('시스템 매뉴얼 — Users', { tag: ['@manual', '@manager'] }, // 3. Menus // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Menus', { tag: ['@manual', '@manager'] }, () => { +test.describe('설정 매뉴얼 — Menus', { tag: ['@manual', '@manager'] }, () => { test('메뉴 관리 페이지가 표시된다', async ({ page }) => { - await page.goto('/system/menus/') - await expect(page).toHaveURL(/system\/menus/, { timeout: 15000 }) + await page.goto('/settings/platform/menus/') + await expect(page).toHaveURL(/settings\/platform\/menus/, { timeout: 15000 }) 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 +48,12 @@ test.describe('시스템 매뉴얼 — Menus', { tag: ['@manual', '@manager'] }, // 4. Activity Logs // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Activity Logs', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Activity Logs', { tag: ['@manual', '@manager'] }, () => { 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 expect(page).toHaveURL(/console\/activity-logs/, { timeout: 15000 }) 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 +61,12 @@ test.describe('시스템 매뉴얼 — Activity Logs', { tag: ['@manual', '@mana // 5. Audit Logs // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Audit Logs', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Audit Logs', { tag: ['@manual', '@manager'] }, () => { test('감사 로그 목록이 표시된다', async ({ page }) => { - await page.goto('/system/audit-logs/') - await expect(page).toHaveURL(/system\/audit-logs/, { timeout: 15000 }) + await page.goto('/console/audit-logs/') + await expect(page).toHaveURL(/console\/audit-logs/, { timeout: 15000 }) 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 +74,12 @@ test.describe('시스템 매뉴얼 — Audit Logs', { tag: ['@manual', '@manager // 6. Error Logs // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Error Logs', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Error Logs', { tag: ['@manual', '@manager'] }, () => { 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 expect(page).toHaveURL(/console\/error-logs/, { timeout: 15000 }) 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 +87,12 @@ test.describe('시스템 매뉴얼 — Error Logs', { tag: ['@manual', '@manager // 7. Login History // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Login History', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Login History', { tag: ['@manual', '@manager'] }, () => { 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 expect(page).toHaveURL(/console\/login-history/, { timeout: 15000 }) 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 +100,12 @@ test.describe('시스템 매뉴얼 — Login History', { tag: ['@manual', '@mana // 8. Email Templates // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Email Templates', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Email Templates', { tag: ['@manual', '@manager'] }, () => { 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 expect(page).toHaveURL(/console\/email-templates/, { timeout: 15000 }) 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 +113,12 @@ test.describe('시스템 매뉴얼 — Email Templates', { tag: ['@manual', '@ma // 9. Slack Templates // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Slack Templates', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Slack Templates', { tag: ['@manual', '@manager'] }, () => { 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 expect(page).toHaveURL(/console\/slack-templates/, { timeout: 15000 }) 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 +126,12 @@ test.describe('시스템 매뉴얼 — Slack Templates', { tag: ['@manual', '@ma // 10. Notification Channels // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Notification Channels', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Notification Channels', { tag: ['@manual', '@manager'] }, () => { 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 expect(page).toHaveURL(/console\/notification-channels/, { timeout: 15000 }) 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 +139,12 @@ test.describe('시스템 매뉴얼 — Notification Channels', { tag: ['@manual' // 11. Notification Rules // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Notification Rules', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Notification Rules', { tag: ['@manual', '@manager'] }, () => { 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 expect(page).toHaveURL(/console\/notification-rules/, { timeout: 15000 }) 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 +152,12 @@ test.describe('시스템 매뉴얼 — Notification Rules', { tag: ['@manual', ' // 12. Workspaces Admin // ───────────────────────────────────────────── -test.describe('시스템 매뉴얼 — Workspaces Admin', { tag: ['@manual', '@manager'] }, () => { +test.describe('콘솔 매뉴얼 — Workspaces Admin', { tag: ['@manual', '@manager'] }, () => { test('워크스페이스 관리 목록이 표시된다', async ({ page }) => { - await page.goto('/system/workspaces/') - await expect(page).toHaveURL(/system\/workspaces/, { timeout: 15000 }) + await page.goto('/console/workspaces/') + await expect(page).toHaveURL(/console\/workspaces/, { timeout: 15000 }) await expect(page.locator('.tabulator-row').first()).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/system-workspaces') + await capture(page, 'app/console-workspaces') }) }) @@ -167,9 +167,9 @@ test.describe('시스템 매뉴얼 — Workspaces Admin', { tag: ['@manual', '@m test.describe('시스템 매뉴얼 — My Workspaces', { tag: ['@manual', '@manager'] }, () => { test('내 워크스페이스 목록이 표시된다', async ({ page }) => { - await page.goto('/my-workspaces/') - await expect(page).toHaveURL(/my-workspaces/, { timeout: 15000 }) + await page.goto('/console/my-workspaces/') + await expect(page).toHaveURL(/console\/my-workspaces/, { timeout: 15000 }) await expect(page.getByRole('heading', { name: /Workspaces/i })).toBeVisible({ timeout: 15000 }) - await capture(page, 'app/my-workspaces') + await capture(page, 'app/console-my-workspaces') }) }) 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/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..c346c1a8e 100644 --- a/frontend/tests/system/accessibility-smoke.spec.ts +++ b/frontend/tests/system/accessibility-smoke.spec.ts @@ -11,7 +11,7 @@ const appRoutes: A11ySmokeRoute[] = [ }, { 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..53811ad53 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: 'AUDIT_LOGS', path: '/console/audit-logs', permissions: ['API_AUDIT_LOG_READ'] }, + { code: 'LOGIN_HISTORY', path: '/console/login-history', permissions: ['LOGIN_HISTORY_READ'] }, ]; const menuTree = [ @@ -32,7 +39,7 @@ const menuTree = [ name: 'Audit Logs', icon: 'FileText', program: 'AUDIT_LOGS', - permissions: ['AUDIT_LOG_READ'], + 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/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..de7b3e8d7 100644 --- a/frontend/tests/system/sidebar-collapsed-dropdown.spec.ts +++ b/frontend/tests/system/sidebar-collapsed-dropdown.spec.ts @@ -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..dec92225b 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,7 +205,7 @@ const menuCases: StandaloneMenuCase[] = [ id: "audit-logs", title: "Audit Logs", icon: "ScrollText", - url: "/system/audit-logs", + url: "/console/audit-logs", }, permissions: ["API_AUDIT_LOG_READ"], programCode: "AUDIT_LOGS", @@ -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,7 +355,7 @@ 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", @@ -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..bdb12d429 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: { 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(); }); }); From a4cbd028b0d6632b73dbe8940f2478796d6059e3 Mon Sep 17 00:00:00 2001 From: keIIy-kim Date: Sat, 4 Apr 2026 02:54:52 +0900 Subject: [PATCH 2/3] feat(app): refine platform routing and external workspace sync --- .../app/controller/MyWorkspaceController.kt | 12 +- .../app/controller/WorkspaceController.kt | 63 +-- .../io/deck/app/controller/WorkspaceDtos.kt | 8 +- .../listener/WorkspaceAutoCreateListener.kt | 2 +- .../resources/db/migration/app/V1__init.sql | 3 + .../controller/MyWorkspaceControllerTest.kt | 22 +- .../app/controller/WorkspaceControllerTest.kt | 34 +- .../main/kotlin/io/deck/iam/ManagementType.kt | 6 + .../kotlin/io/deck/iam/api/MenuSeedCommand.kt | 4 +- .../io/deck/iam/api/ProgramDefinition.kt | 4 +- .../io/deck/iam/api/WorkspaceDirectory.kt | 23 +- .../io/deck/iam/api/WorkspaceManagedType.kt | 6 - .../kotlin/io/deck/iam/api/WorkspaceRecord.kt | 3 +- .../io/deck/iam/controller/MenuController.kt | 8 +- .../kotlin/io/deck/iam/controller/MenuDtos.kt | 12 +- .../controller/PlatformSettingController.kt | 1 + .../iam/controller/PlatformSettingDtos.kt | 2 + .../iam/domain/ExternalOrganizationSync.kt | 11 + .../kotlin/io/deck/iam/domain/MenuEntity.kt | 5 +- .../deck/iam/domain/PlatformSettingEntity.kt | 6 + .../io/deck/iam/domain/WorkspaceEntity.kt | 23 +- .../deck/iam/domain/WorkspaceManagedType.kt | 6 - .../io/deck/iam/domain/WorkspacePolicy.kt | 20 +- .../deck/iam/registry/IamProgramRegistrar.kt | 6 +- .../OAuth2AuthenticationSuccessHandler.kt | 29 +- .../iam/security/OAuth2UserInfoExtractor.kt | 64 ++- .../kotlin/io/deck/iam/service/AuthService.kt | 41 +- .../service/ExternalWorkspaceSyncService.kt | 182 ++++++++ .../deck/iam/service/MenuSeedCommandImpl.kt | 5 +- .../kotlin/io/deck/iam/service/MenuService.kt | 8 +- .../service/OAuthLoginProvisioningService.kt | 87 ++++ .../iam/service/PlatformSettingService.kt | 5 +- .../kotlin/io/deck/iam/service/UserService.kt | 135 +++--- .../iam/service/WorkspaceDirectoryImpl.kt | 40 +- .../iam/service/WorkspaceInviteService.kt | 113 +++-- .../iam/service/WorkspaceMemberService.kt | 23 +- .../WorkspaceProvisioningCommandImpl.kt | 4 +- .../io/deck/iam/service/WorkspaceService.kt | 101 ++-- .../deck/iam/controller/MenuControllerTest.kt | 18 +- .../PlatformSettingControllerTest.kt | 33 +- .../iam/domain/ManagementTypeContractTest.kt | 36 ++ .../io/deck/iam/domain/WorkspaceEntityTest.kt | 37 +- .../io/deck/iam/security/OAuth2LinkingTest.kt | 22 +- .../security/OAuth2UserInfoExtractorTest.kt | 66 +++ .../io/deck/iam/service/AuthServiceTest.kt | 137 ++++-- .../ExternalWorkspaceSyncServiceTest.kt | 286 ++++++++++++ .../iam/service/MenuSeedCommandImplTest.kt | 17 +- .../io/deck/iam/service/MenuServiceTest.kt | 12 +- .../OAuthLoginProvisioningServiceTest.kt | 123 +++++ .../iam/service/PlatformSettingServiceTest.kt | 29 ++ .../io/deck/iam/service/UserServiceTest.kt | 300 ++++++++---- .../iam/service/WorkspaceInviteServiceTest.kt | 197 +++++--- .../iam/service/WorkspaceMemberServiceTest.kt | 61 +-- .../WorkspaceProvisioningCommandImplTest.kt | 6 +- .../deck/iam/service/WorkspaceServiceTest.kt | 70 ++- ...platform-reset-and-workspace-scope-plan.md | 23 +- ...4-03-platform-reset-and-workspace-scope.md | 64 +-- docs/reference/frontend/router.md | 12 + docs/reference/glossary.md | 97 ++++ docs/reference/workspace.md | 46 +- frontend/app/src/app/App.tsx | 37 +- frontend/app/src/app/app.test.tsx | 105 ++++- frontend/app/src/app/auth.test.ts | 11 +- .../CommandPaletteProvider.test.tsx | 13 +- .../CommandPaletteProvider.tsx | 3 +- .../app/src/app/navigation/menu-runtime.ts | 248 ++++++++++ frontend/app/src/app/page-access.ts | 40 +- frontend/app/src/app/page-registry.test.ts | 54 ++- .../src/app/sidebar/SidebarWrapper.test.tsx | 12 +- frontend/app/src/app/tabbar/TabBarWrapper.tsx | 7 +- frontend/app/src/app/tabs.test.ts | 434 +++++++++++++++++- frontend/app/src/app/tabs.ts | 73 ++- frontend/app/src/entities/account/types.ts | 1 + frontend/app/src/entities/menu/types.ts | 4 +- .../entities/platform-settings/api.test.ts | 14 +- .../src/entities/platform-settings/index.ts | 1 - .../src/entities/platform-settings/types.ts | 2 +- .../workspace-access.test.ts | 3 +- .../platform-settings/workspace-access.ts | 10 +- frontend/app/src/entities/workspace/index.ts | 8 + .../app/src/entities/workspace/scope.test.ts | 53 +++ frontend/app/src/entities/workspace/scope.ts | 49 ++ .../app/src/entities/workspace/store.test.ts | 1 + frontend/app/src/entities/workspace/types.ts | 8 +- .../app/src/pages/settings/settings-nav.ts | 14 +- .../pages/settings/tabs/general-tab.test.tsx | 1 + .../settings/tabs/workspace-tab.test.tsx | 19 +- .../src/pages/settings/tabs/workspace-tab.tsx | 30 +- .../src/pages/settings/use-settings-page.ts | 5 +- .../pages/system/users/users.page.test.tsx | 18 +- frontend/app/src/shared/auth-redirect.test.ts | 9 + frontend/app/src/shared/auth-redirect.ts | 39 +- .../src/shared/i18n/locales/en/account.json | 4 +- .../src/shared/i18n/locales/ja/account.json | 4 +- .../src/shared/i18n/locales/ko/account.json | 4 +- frontend/app/src/shared/router/index.ts | 6 +- .../app/src/shared/router/legacy-path.test.ts | 34 ++ frontend/app/src/shared/router/legacy-path.ts | 99 ++++ .../shared/router/route-descriptor.test.ts | 40 +- .../app/src/shared/router/route-descriptor.ts | 81 ++-- .../app/src/widgets/sidebar/Sidebar.test.tsx | 164 ++++++- frontend/app/src/widgets/sidebar/Sidebar.tsx | 191 +------- frontend/app/src/widgets/sidebar/index.ts | 5 +- .../src/pages/companies/companies.page.tsx | 4 +- .../src/pages/contacts/contacts.page.tsx | 4 +- .../contracting-parties.page.tsx | 4 +- .../src/pages/contracts/contracts.page.tsx | 4 +- .../deskpie/src/pages/deals/deals.page.tsx | 4 +- .../deskpie/src/pages/leads/leads.page.tsx | 4 +- .../license-requests.page.tsx | 4 +- .../src/pages/licenses/licenses.page.tsx | 4 +- .../src/pages/pipelines/pipelines.page.tsx | 4 +- .../src/pages/products/products.page.tsx | 4 +- .../deskpie/src/pages/quotes/quotes.page.tsx | 4 +- 114 files changed, 3676 insertions(+), 1145 deletions(-) create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/ManagementType.kt delete mode 100644 backend/iam/src/main/kotlin/io/deck/iam/api/WorkspaceManagedType.kt create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationSync.kt delete mode 100644 backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceManagedType.kt create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/service/OAuthLoginProvisioningService.kt create mode 100644 backend/iam/src/test/kotlin/io/deck/iam/domain/ManagementTypeContractTest.kt create mode 100644 backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2UserInfoExtractorTest.kt create mode 100644 backend/iam/src/test/kotlin/io/deck/iam/service/ExternalWorkspaceSyncServiceTest.kt create mode 100644 backend/iam/src/test/kotlin/io/deck/iam/service/OAuthLoginProvisioningServiceTest.kt create mode 100644 docs/reference/glossary.md create mode 100644 frontend/app/src/app/navigation/menu-runtime.ts create mode 100644 frontend/app/src/entities/workspace/scope.test.ts create mode 100644 frontend/app/src/entities/workspace/scope.ts create mode 100644 frontend/app/src/shared/router/legacy-path.test.ts create mode 100644 frontend/app/src/shared/router/legacy-path.ts 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 ee02ea429..210295909 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) + workspaceDirectory.ensureAccessible(workspaceId, ManagementType.USER_MANAGED) } private fun verifyUserManagedOwner( workspaceId: UUID, userId: UUID, ) { - workspaceDirectory.verifyOwner(workspaceId, userId, WorkspaceManagedType.USER_MANAGED) + workspaceDirectory.verifyOwner(workspaceId, userId, ManagementType.USER_MANAGED) } @GetMapping @@ -93,7 +93,7 @@ class MyWorkspaceController( name = request.name, description = request.description, requestedBy = userId, - managedType = WorkspaceManagedType.USER_MANAGED, + managedType = ManagementType.USER_MANAGED, autoJoinDomains = request.autoJoinDomains, ) val memberCount = workspaceRoster.countByWorkspaceId(id).toInt() @@ -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() } 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 2f32f5ec6..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.PLATFORM_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,13 +61,12 @@ 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.PLATFORM_MANAGED, autoJoinDomains = request.autoJoinDomains, ) val memberCount = workspaceRoster.countByWorkspaceId(workspace.id).toInt() @@ -77,14 +80,13 @@ 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, autoJoinDomains = request.autoJoinDomains, ) val memberCount = workspaceRoster.countByWorkspaceId(id).toInt() @@ -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 92c1313dc..a3c2e2945 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,9 +1,9 @@ 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 @@ -25,7 +25,7 @@ data class WorkspaceDto( val autoJoinDomains: List, val owners: List, val memberCount: Int, - val managedType: WorkspaceManagedType, + val managedType: ManagementType, val externalReference: ExternalReferenceDto?, val createdAt: Instant?, val updatedAt: Instant?, @@ -39,14 +39,12 @@ data class CreateWorkspaceRequest( val name: String, val description: String? = null, val autoJoinDomains: List = emptyList(), - val managedType: WorkspaceManagedType? = null, ) data class UpdateWorkspaceRequest( val name: String, val description: String? = null, val autoJoinDomains: List = emptyList(), - val managedType: WorkspaceManagedType? = null, ) data class WorkspaceMemberDto( @@ -83,7 +81,7 @@ data class MyWorkspaceDto( val autoJoinDomains: List, val owners: List, val memberCount: Int, - val managedType: WorkspaceManagedType, + val managedType: ManagementType, val externalReference: ExternalReferenceDto?, val role: String, val createdAt: Instant?, 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/resources/db/migration/app/V1__init.sql b/backend/app/src/main/resources/db/migration/app/V1__init.sql index 6dd0f59ea..f81603fc2 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 @@ -451,6 +451,7 @@ CREATE TABLE platform_settings -- 워크스페이스 설정 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', @@ -466,12 +467,14 @@ INSERT INTO platform_settings ( brand_name, workspace_use_user_managed, workspace_use_platform_managed, + workspace_use_external_sync, workspace_use_selector ) VALUES ( uuid_generate_v7(), 'Deck', true, true, + true, true ); 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 fb101c52c..a467da143 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,7 +46,7 @@ class MyWorkspaceControllerTest : fun workspace( name: String = "Workspace", - managedType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, + managedType: ManagementType = ManagementType.USER_MANAGED, id: UUID = UUID.randomUUID(), autoJoinDomains: List = emptyList(), ) = object : WorkspaceRecord { @@ -92,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.PLATFORM_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) @@ -164,7 +164,7 @@ class MyWorkspaceControllerTest : name = "Updated", description = "desc", requestedBy = userId, - managedType = WorkspaceManagedType.USER_MANAGED, + managedType = ManagementType.USER_MANAGED, autoJoinDomains = listOf("acme.com", "dev.acme.com"), ) } returns updatedWorkspace @@ -197,7 +197,7 @@ class MyWorkspaceControllerTest : name = "External Workspace", description = "desc", requestedBy = userId, - managedType = WorkspaceManagedType.USER_MANAGED, + managedType = ManagementType.USER_MANAGED, autoJoinDomains = emptyList(), ) } throws BadRequestException("iam.workspace.external_locked") @@ -223,7 +223,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 = @@ -245,7 +245,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 = @@ -268,7 +268,7 @@ class MyWorkspaceControllerTest : workspaceDirectory.deleteBatch( workspaceIds, currentUserId, - WorkspaceManagedType.USER_MANAGED, + ManagementType.USER_MANAGED, ) } just runs @@ -283,7 +283,7 @@ class MyWorkspaceControllerTest : workspaceDirectory.deleteBatch( workspaceIds, currentUserId, - WorkspaceManagedType.USER_MANAGED, + ManagementType.USER_MANAGED, ) } } @@ -312,7 +312,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 = @@ -332,7 +332,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 61cd8ffb4..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 @@ -2,11 +2,11 @@ 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 @@ -47,7 +47,7 @@ class WorkspaceControllerTest : fun workspace( name: String = "Workspace", - managedType: WorkspaceManagedType = WorkspaceManagedType.PLATFORM_MANAGED, + managedType: ManagementType = ManagementType.PLATFORM_MANAGED, id: UUID = UUID.randomUUID(), autoJoinDomains: List = emptyList(), ) = object : WorkspaceRecord { @@ -91,7 +91,7 @@ class WorkspaceControllerTest : describe("list") { it("platform-managed 정책이 꺼져 있으면 예외를 그대로 던진다") { - every { workspaceDirectory.ensureManagedTypeEnabled(WorkspaceManagedType.PLATFORM_MANAGED) } throws + every { workspaceDirectory.ensureManagedTypeEnabled(ManagementType.PLATFORM_MANAGED) } throws NotFoundException("iam.workspace.not_found") shouldThrow { @@ -103,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)) @@ -126,11 +126,10 @@ class WorkspaceControllerTest : val principal = principal(userId) val workspace = workspace(name = "New Workspace", autoJoinDomains = listOf("acme.com")) every { - workspaceDirectory.create( + workspaceDirectory.createPlatformManaged( name = "New Workspace", description = "desc", initialOwnerId = userId, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, autoJoinDomains = listOf("acme.com"), ) } returns workspace @@ -158,14 +157,12 @@ class WorkspaceControllerTest : val workspaceId = UUID.randomUUID() val currentUserId = UUID.randomUUID() val updatedWorkspace = workspace(id = workspaceId, name = "Updated Workspace", autoJoinDomains = listOf("acme.com", "dev.acme.com")) - every { workspaceDirectory.get(workspaceId) } returns workspace(id = workspaceId) every { - workspaceDirectory.updateByAdmin( + workspaceDirectory.updatePlatformManaged( workspaceId = workspaceId, name = "Updated Workspace", description = "desc", updatedBy = currentUserId, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, autoJoinDomains = listOf("acme.com", "dev.acme.com"), ) } returns updatedWorkspace @@ -191,14 +188,12 @@ class WorkspaceControllerTest : it("external workspace 수정은 external_locked 예외를 그대로 올린다") { val workspaceId = UUID.randomUUID() val currentUserId = UUID.randomUUID() - every { workspaceDirectory.get(workspaceId) } returns workspace(id = workspaceId) every { - workspaceDirectory.updateByAdmin( + workspaceDirectory.updatePlatformManaged( workspaceId = workspaceId, name = "External Workspace", description = "desc", updatedBy = currentUserId, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, autoJoinDomains = emptyList(), ) } throws BadRequestException("iam.workspace.external_locked") @@ -224,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 = @@ -234,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) } } } @@ -254,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( @@ -265,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()) } } @@ -274,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) } } } @@ -288,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( @@ -297,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) } } } @@ -305,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 = @@ -314,6 +315,7 @@ class WorkspaceControllerTest : ) response.statusCode shouldBe HttpStatus.NO_CONTENT + verify(exactly = 1) { workspaceDirectory.getPlatformManaged(workspaceId) } verify(exactly = 1) { workspaceInvitations.cancelBatch(workspaceId, inviteIds) } } } @@ -323,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 = @@ -333,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/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/MenuSeedCommand.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/MenuSeedCommand.kt index 05a535730..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,7 +26,7 @@ data class SeedMenuDefinition( val namesI18n: Map? = null, val icon: String? = null, val programType: String = MenuSeedCommand.NONE_PROGRAM_TYPE, - val managementType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, + 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/ProgramDefinition.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/ProgramDefinition.kt index a88352e14..c14816f42 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,6 @@ data class ProgramDefinition( ) { data class WorkspacePolicy( val required: Boolean = false, - val managedType: WorkspaceManagedType? = null, + val requiredManagedType: ManagementType? = null, ) } 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 a09938d50..fe2bb38f6 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,32 +1,32 @@ package io.deck.iam.api +import io.deck.iam.ManagementType import java.util.UUID interface WorkspaceDirectory { - fun ensureManagedTypeEnabled(managedType: WorkspaceManagedType) + 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, autoJoinDomains: List = emptyList(), ): WorkspaceRecord @@ -37,12 +37,11 @@ interface WorkspaceDirectory { autoJoinDomains: List = emptyList(), ): WorkspaceRecord - fun updateByAdmin( + fun updatePlatformManaged( workspaceId: UUID, name: String, description: String?, updatedBy: UUID, - managedType: WorkspaceManagedType, autoJoinDomains: List = emptyList(), ): WorkspaceRecord @@ -51,11 +50,11 @@ interface WorkspaceDirectory { name: String, description: String?, requestedBy: UUID, - managedType: WorkspaceManagedType, + managedType: ManagementType, autoJoinDomains: List = emptyList(), ): WorkspaceRecord - fun deleteByAdminBatch( + fun deletePlatformManagedBatch( workspaceIds: Collection, deletedBy: UUID, ) @@ -63,6 +62,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 6a8ab25f1..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, - PLATFORM_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 edfef104f..86075bb69 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,5 +1,6 @@ package io.deck.iam.api +import io.deck.iam.ManagementType import java.time.LocalDateTime import java.util.UUID @@ -8,7 +9,7 @@ interface WorkspaceRecord { val name: String val description: String? val autoJoinDomains: List - val managedType: WorkspaceManagedType + val managedType: ManagementType val externalReference: ExternalReferenceRecord? val createdAt: LocalDateTime? val updatedAt: LocalDateTime? 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 4aee5c833..8bd103088 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,6 +1,6 @@ package io.deck.iam.controller -import io.deck.iam.domain.WorkspaceManagedType +import io.deck.iam.ManagementType import io.deck.iam.service.MenuService import io.deck.iam.service.PermissionRegistry import io.deck.iam.service.ProgramRegistry @@ -44,7 +44,7 @@ class MenuController( it.workspace?.let { workspace -> ProgramWorkspacePolicyDto( required = workspace.required, - managedType = workspace.managedType, + requiredManagedType = workspace.requiredManagedType, ) }, ) @@ -96,7 +96,7 @@ class MenuController( namesI18n = request.namesI18n, icon = request.icon, programType = request.program, - managementType = request.managementType ?: WorkspaceManagedType.USER_MANAGED, + managementType = request.managementType ?: ManagementType.USER_MANAGED, ) return ResponseEntity.ok(menu.toDto(programRegistry)) } @@ -119,7 +119,7 @@ class MenuController( namesI18n = request.namesI18n, icon = request.icon, programType = request.program, - managementType = request.managementType ?: WorkspaceManagedType.USER_MANAGED, + managementType = request.managementType ?: ManagementType.USER_MANAGED, ) return ResponseEntity.ok(menu.toDto(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 370ca7939..f5073a244 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,7 @@ data class ProgramDto( data class ProgramWorkspacePolicyDto( val required: Boolean, - val managedType: WorkspaceManagedType? = null, + val requiredManagedType: ManagementType? = null, ) data class PermissionDto( @@ -26,7 +26,7 @@ data class MenuDto( val namesI18n: Map?, val icon: String?, val program: String, - val managementType: WorkspaceManagedType, + val managementType: ManagementType, val permissions: Set, ) @@ -36,7 +36,7 @@ data class MenuTreeDto( val namesI18n: Map?, val icon: String?, val program: String, - val managementType: WorkspaceManagedType, + val managementType: ManagementType, val permissions: Set, val children: List, ) @@ -46,7 +46,7 @@ data class CreateMenuRequest( val namesI18n: Map? = null, val icon: String? = null, val program: String, - val managementType: WorkspaceManagedType? = null, + val managementType: ManagementType? = null, ) data class UpdateMenuRequest( @@ -54,7 +54,7 @@ data class UpdateMenuRequest( val namesI18n: Map? = null, val icon: String?, val program: String, - val managementType: WorkspaceManagedType? = null, + val managementType: ManagementType? = null, val permissions: Set = emptySet(), ) diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt index fb060d532..1d78334b4 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingController.kt @@ -237,6 +237,7 @@ private fun PlatformSettingEntity.toDto(baseUrl: String): PlatformSettingDto = WorkspacePolicyDto( useUserManaged = it.useUserManaged, usePlatformManaged = it.usePlatformManaged, + useExternalSync = it.useExternalSync, useSelector = it.useSelector, ) }, diff --git a/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt index 8419a946a..878ecffca 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/controller/PlatformSettingDtos.kt @@ -54,12 +54,14 @@ data class UpdateGlobalizationPolicyRequest( data class WorkspacePolicyDto( val useUserManaged: Boolean, val usePlatformManaged: Boolean, + val useExternalSync: Boolean, val useSelector: Boolean, ) { fun toDomain(): WorkspacePolicy = WorkspacePolicy( useUserManaged = useUserManaged, usePlatformManaged = usePlatformManaged, + useExternalSync = useExternalSync, useSelector = useSelector, ) } 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..78221a88d --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationSync.kt @@ -0,0 +1,11 @@ +package io.deck.iam.domain + +sealed interface ExternalOrganizationSync { + data object NoSync : ExternalOrganizationSync + + data object Unavailable : ExternalOrganizationSync + + data class AuthoritativeSnapshot( + val organizations: List, + ) : ExternalOrganizationSync +} 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 3229fb44f..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,6 +1,7 @@ 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 @@ -53,7 +54,7 @@ class MenuEntity( var programType: String, @Enumerated(EnumType.STRING) @Column(name = "managed_type", nullable = false, updatable = true, length = 30) - var managementType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, + var managementType: ManagementType = ManagementType.USER_MANAGED, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") var parent: MenuEntity? = null, @@ -87,7 +88,7 @@ class MenuEntity( namesI18n: Map?, icon: String?, programType: String, - managementType: WorkspaceManagedType, + managementType: ManagementType, permissions: Set, ) { this.name = name diff --git a/backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt index f9162cfa8..234992cf8 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/PlatformSettingEntity.kt @@ -117,6 +117,12 @@ class PlatformSettingEntity( this.workspacePolicy = workspacePolicy?.normalizedOrNull() } + fun normalizeEmbeddedPolicies() { + workspacePolicy = workspacePolicy?.normalizedOrNull() + countryPolicy = countryPolicy.normalized() + currencyPolicy = currencyPolicy.normalized() + } + fun updateCountryPolicy(countryPolicy: CountryPolicy) { this.countryPolicy = countryPolicy.normalized() } 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 baa1827d7..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,6 +2,7 @@ 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 @@ -28,13 +29,14 @@ class WorkspaceEntity( 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 { autoJoinDomains = autoJoinDomains.normalizeAllowedDomains() + enforceExternalInvariant(managedType) } val isExternal: Boolean @@ -44,13 +46,30 @@ class WorkspaceEntity( name: String, description: String?, autoJoinDomains: List = this.autoJoinDomains, - managedType: WorkspaceManagedType = this.managedType, + managedType: ManagementType = this.managedType, ) { + enforceExternalInvariant(managedType) this.name = name this.description = description 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/WorkspaceManagedType.kt b/backend/iam/src/main/kotlin/io/deck/iam/domain/WorkspaceManagedType.kt deleted file mode 100644 index d2cf87d68..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, - PLATFORM_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 e9cb2df82..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 @@ -9,8 +9,26 @@ data class WorkspacePolicy( var useUserManaged: 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 || usePlatformManaged } + 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/registry/IamProgramRegistrar.kt b/backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt index 445c94ae4..3984f0939 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,9 @@ 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.domain.MenuEntity -import io.deck.iam.domain.WorkspaceManagedType import org.springframework.stereotype.Component @Component @@ -33,7 +33,7 @@ class IamProgramRegistrar : ProgramRegistrar { workspace = ProgramDefinition.WorkspacePolicy( required = true, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, + requiredManagedType = ManagementType.PLATFORM_MANAGED, ), ), ProgramDefinition( @@ -43,7 +43,7 @@ class IamProgramRegistrar : ProgramRegistrar { workspace = ProgramDefinition.WorkspacePolicy( required = true, - managedType = null, + requiredManagedType = null, ), ), ) 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 2ec8e48ee..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 @@ -161,26 +161,15 @@ class OAuth2AuthenticationSuccessHandler( // 2. AuthService를 통한 OAuth 인증 when ( val result = - if (userInfo.externalOrganizations.isEmpty()) { - authService.authenticateWithOAuth( - provider = authProvider, - providerUserId = userInfo.sub, - email = userInfo.email, - name = userInfo.name, - ipAddress = ipAddress, - userAgent = userAgent, - ) - } else { - authService.authenticateWithOAuth( - provider = authProvider, - providerUserId = userInfo.sub, - email = userInfo.email, - name = userInfo.name, - ipAddress = ipAddress, - userAgent = userAgent, - externalOrganizations = userInfo.externalOrganizations, - ) - } + authService.authenticateWithOAuth( + provider = authProvider, + providerUserId = userInfo.sub, + email = userInfo.email, + name = userInfo.name, + ipAddress = ipAddress, + userAgent = userAgent, + externalOrganizationSync = userInfo.externalOrganizationSync, + ) ) { is AuthResult.Failure -> { val encodedMessage = URLEncoder.encode(result.message, "UTF-8") 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 f27bbbaea..28ddacfb5 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 @@ -2,6 +2,7 @@ package io.deck.iam.security import io.deck.iam.domain.AuthProvider import io.deck.iam.domain.ExternalOrganizationClaim +import io.deck.iam.domain.ExternalOrganizationSync import org.slf4j.LoggerFactory import org.springframework.security.oauth2.core.oidc.user.OidcUser import org.springframework.security.oauth2.core.user.OAuth2User @@ -13,7 +14,7 @@ data class OAuth2UserInfo( val email: String, val sub: String, val name: String, - val externalOrganizations: List = emptyList(), + val externalOrganizationSync: ExternalOrganizationSync = ExternalOrganizationSync.NoSync, ) /** @@ -124,20 +125,60 @@ private data object AipExtractor : OAuth2UserInfoExtractor { ?: throw IllegalStateException("Email not found from AIP") val sub = oidcUser.subject val name = oidcUser.fullName ?: oidcUser.preferredUsername ?: email - val externalOrganizations = + 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] } - .flatMap { claimsToOrganizations(it).asSequence() } - .distinctBy { it.externalId } .toList() - return OAuth2UserInfo(email, sub, name, externalOrganizations) + + 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( + organizations = flattenedOrganizations, + ) } - private fun claimsToOrganizations(value: Any): List = + private fun claimsToOrganizations(value: Any): ParsedOrganizations? = when (value) { - is Collection<*> -> value.mapNotNull(::toOrganizationClaim) - is Array<*> -> value.mapNotNull(::toOrganizationClaim) - else -> emptyList() + 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? = @@ -166,3 +207,8 @@ private data object AipExtractor : OAuth2UserInfoExtractor { } } } + +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 ecee5fb32..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 @@ -5,6 +5,7 @@ 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 @@ -30,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, @@ -387,52 +389,33 @@ class AuthService( name: String, ipAddress: String, userAgent: String?, - externalOrganizations: List = emptyList(), + externalOrganizationSync: ExternalOrganizationSync = ExternalOrganizationSync.NoSync, ): AuthResult { - // 1. 사용자 조회 또는 생성 - val user = - userService.findOrCreateByOAuth( + val provisioningResult = + oAuthLoginProvisioningService.resolveUser( provider = provider, providerUserId = providerUserId, email = email, name = name, - externalOrganizations = externalOrganizations, - syncExternalOrganizationsOnReturn = false, + 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) - } - - if (provider == AuthProvider.AIP && externalOrganizations.isNotEmpty()) { - userService.syncExternalOrganizationsForOAuthUser(user, externalOrganizations) + 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/ExternalWorkspaceSyncService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt new file mode 100644 index 000000000..0bcfed7e8 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt @@ -0,0 +1,182 @@ +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.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.WorkspaceRepository +import org.springframework.context.ApplicationEventPublisher +import org.springframework.dao.DataIntegrityViolationException +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(), +) + +@Service +@Transactional(readOnly = true) +class ExternalWorkspaceSyncService( + private val workspaceRepository: WorkspaceRepository, + private val workspaceMemberRepository: WorkspaceMemberRepository, + private val eventPublisher: ApplicationEventPublisher, +) { + @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 externalOrganizations = externalOrganizationSnapshot.organizations + val desiredWorkspaces = externalOrganizations.map(::upsertWorkspace) + val membershipDelta = + if (mode == ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT) { + reconcileMemberships(user.id, 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(organization: ExternalOrganizationClaim): WorkspaceEntity = + findExternalWorkspace(organization.externalId)?.let { workspace -> + syncWorkspaceIdentity(workspace, organization) + } ?: createExternalWorkspace(organization) + + private fun createExternalWorkspace(organization: ExternalOrganizationClaim): WorkspaceEntity { + val newWorkspace = + WorkspaceEntity( + name = organization.name ?: organization.externalId, + description = organization.description, + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(organization.externalId), + ) + + return try { + workspaceRepository.save(newWorkspace) + } catch (_: DataIntegrityViolationException) { + findExternalWorkspace(organization.externalId)?.let { workspace -> + syncWorkspaceIdentity(workspace, organization) + } ?: throw DataIntegrityViolationException("External workspace upsert failed for ${organization.externalId}") + } + } + + private fun findExternalWorkspace(externalId: String): WorkspaceEntity? = workspaceRepository.findByExternalReferenceExternalId(externalId) + + private fun syncWorkspaceIdentity( + workspace: WorkspaceEntity, + organization: ExternalOrganizationClaim, + ): WorkspaceEntity { + workspace.syncExternalIdentity( + name = organization.name ?: workspace.name, + description = organization.description ?: workspace.description, + ) + return workspace + } + + private fun reconcileMemberships( + userId: java.util.UUID, + desiredWorkspaces: List, + reason: String, + ): ExternalWorkspaceMembershipDelta { + val desiredWorkspaceIds = desiredWorkspaces.map { it.id }.toSet() + val currentExternalWorkspaces = + workspaceRepository + .findAllByMemberUserId(userId) + .filter { it.isExternal } + 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 try { + workspaceMemberRepository.save( + WorkspaceMemberEntity( + workspaceId = workspaceId, + userId = userId, + isOwner = false, + ), + ) + true + } catch (_: DataIntegrityViolationException) { + if (workspaceMemberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId)) { + return false + } + throw DataIntegrityViolationException("External workspace membership upsert failed for workspace=$workspaceId user=$userId") + } + } +} + +private data class ExternalWorkspaceMembershipDelta( + val addedWorkspaceIds: Set = emptySet(), + val removedWorkspaceIds: Set = emptySet(), +) 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 9fe8d6d36..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 @@ -8,8 +9,6 @@ import io.deck.iam.repository.MenuRepository import io.deck.iam.repository.RoleRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Service -import io.deck.iam.api.WorkspaceManagedType as ApiWorkspaceManagedType -import io.deck.iam.domain.WorkspaceManagedType as DomainWorkspaceManagedType @Service class MenuSeedCommandImpl( @@ -233,4 +232,4 @@ class MenuSeedCommandImpl( } } -private fun ApiWorkspaceManagedType.toDomain(): DomainWorkspaceManagedType = DomainWorkspaceManagedType.valueOf(name) +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 0af112573..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,10 +5,10 @@ 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 -import io.deck.iam.domain.WorkspaceManagedType import io.deck.iam.event.IamActivityLogType import io.deck.iam.repository.MenuRepository import io.deck.iam.repository.RoleRepository @@ -62,7 +62,7 @@ class MenuService( namesI18n: Map? = null, icon: String? = null, programType: String, - managementType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, + managementType: ManagementType = ManagementType.USER_MANAGED, ): MenuEntity { validateNamesI18n(namesI18n) val sortOrder = (menuRepository.findMaxSortOrderByRoleIdForRoot(roleId) ?: -1) + 1 @@ -99,7 +99,7 @@ class MenuService( namesI18n: Map? = null, icon: String? = null, programType: String, - managementType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, + managementType: ManagementType = ManagementType.USER_MANAGED, ): MenuEntity { validateNamesI18n(namesI18n) val parent = @@ -142,7 +142,7 @@ class MenuService( namesI18n: Map? = null, icon: String?, programType: String, - managementType: WorkspaceManagedType? = null, + managementType: ManagementType? = null, permissions: Set, ): MenuEntity { validateNamesI18n(namesI18n) 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..359672877 --- /dev/null +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthLoginProvisioningService.kt @@ -0,0 +1,87 @@ +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 externalOrganizations = + when (externalOrganizationSync) { + is ExternalOrganizationSync.AuthoritativeSnapshot -> externalOrganizationSync.organizations + + ExternalOrganizationSync.NoSync, + ExternalOrganizationSync.Unavailable, + -> emptyList() + } + val user = + userService.findOrCreateByOAuth( + provider = provider, + providerUserId = providerUserId, + email = email, + name = name, + externalOrganizations = externalOrganizations, + ) + + val statusError = user.toOAuthLoginStatusError() + if (statusError == null && externalOrganizationSync is ExternalOrganizationSync.AuthoritativeSnapshot) { + userService.syncExternalOrganizationsForOAuthUser(user, externalOrganizationSync) + } + + 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/PlatformSettingService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt index b311c75a8..7f3818650 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/PlatformSettingService.kt @@ -212,7 +212,9 @@ class PlatformSettingService( @Transactional @Cacheable(CACHE_NAME) fun getSettings(): PlatformSettingEntity = - platformSettingRepository.findFirst() + platformSettingRepository + .findFirst() + ?.apply { normalizeEmbeddedPolicies() } ?: platformSettingRepository.save(PlatformSettingEntity()) override fun getDefaultCountryCode(): String = getSettings().countryPolicy.normalized().defaultCountryCode @@ -262,6 +264,7 @@ class PlatformSettingService( mapOf( "useUserManaged" to saved.workspacePolicy?.useUserManaged, "usePlatformManaged" to saved.workspacePolicy?.usePlatformManaged, + "useExternalSync" to saved.workspacePolicy?.useExternalSync, "useSelector" to saved.workspacePolicy?.useSelector, ), ) 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 42996bf6c..f4464c1e0 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,18 +9,18 @@ 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.ExternalReference +import io.deck.iam.domain.ExternalOrganizationSync import io.deck.iam.domain.LocaleType import io.deck.iam.domain.TimezoneType 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.event.RoleInfo import io.deck.iam.event.UserActivityEvent @@ -73,6 +73,7 @@ class UserService( 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) @@ -212,36 +213,30 @@ class UserService( * 주의: 호출 전에 validateOAuthLogin()으로 유효성 검사 필요 */ @Transactional - fun findOrCreateByOAuth( + internal fun findOrCreateByOAuth( provider: AuthProvider, providerUserId: String, email: String, name: String, externalOrganizations: List = emptyList(), - syncExternalOrganizationsOnReturn: Boolean = true, ): UserEntity { val normalizedExternalOrganizations = externalOrganizations.normalizeExternalOrganizations() - val platformManagedWorkspaceEnabled = isPlatformManagedWorkspaceEnabled() + val externalWorkspaceSyncEnabled = isExternalWorkspaceSyncEnabled() // 1. 기존 Identity 확인 val existingIdentity = identityService.findByProviderAndProviderUserId(provider, providerUserId) if (existingIdentity != null) { - // 기존 사용자 반환 (사용자 정보는 유지, Primary 변경 시에만 email 동기화) - if (syncExternalOrganizationsOnReturn) { - syncExternalOrganizationsForOAuthUser( - user = existingIdentity.user, - externalOrganizations = normalizedExternalOrganizations, - ) - } - return existingIdentity.user + return normalizeExistingOAuthUser( + user = existingIdentity.user, + provider = provider, + externalOrganizations = normalizedExternalOrganizations, + externalWorkspaceSyncEnabled = externalWorkspaceSyncEnabled, + ) } val allowedDomain = extractEmailDomain(email) val matchedWorkspaces = allowedDomain?.let(workspaceRepository::findAllByAllowedDomain).orEmpty() - val autoApprovedByAip = - provider == AuthProvider.AIP && - normalizedExternalOrganizations.isNotEmpty() && - platformManagedWorkspaceEnabled + val autoApprovedByAip = shouldAutoApproveByAip(provider, normalizedExternalOrganizations, externalWorkspaceSyncEnabled) val autoApproved = matchedWorkspaces.isNotEmpty() || autoApprovedByAip // 2. 새 사용자 생성 @@ -270,13 +265,6 @@ class UserService( addUserToMatchedWorkspaces(savedUser.id, allowedDomain.orEmpty(), matchedWorkspaces) } - if (syncExternalOrganizationsOnReturn && provider == AuthProvider.AIP && normalizedExternalOrganizations.isNotEmpty()) { - syncExternalOrganizationsForOAuthUser( - user = savedUser, - externalOrganizations = normalizedExternalOrganizations, - ) - } - if (!autoApproved) { // 4. 승인 대기 알림 이벤트 eventPublisher.publishEvent( @@ -292,24 +280,55 @@ class UserService( return savedUser } - @Transactional - fun syncExternalOrganizationsForOAuthUser( + private fun normalizeExistingOAuthUser( user: UserEntity, + provider: AuthProvider, + externalOrganizations: List, + externalWorkspaceSyncEnabled: Boolean, + ): UserEntity { + if (!shouldAutoApprovePendingAipUser(user, provider, externalOrganizations, externalWorkspaceSyncEnabled)) { + return user + } + + user.changeStatus(UserStatus.ACTIVE, AIP_EXTERNAL_ORGANIZATION_REASON) + return userRepository.save(user) + } + + private fun shouldAutoApproveByAip( + provider: AuthProvider, + externalOrganizations: List, + externalWorkspaceSyncEnabled: Boolean, + ): Boolean = provider == AuthProvider.AIP && externalOrganizations.isNotEmpty() && externalWorkspaceSyncEnabled + + private fun shouldAutoApprovePendingAipUser( + user: UserEntity, + provider: AuthProvider, externalOrganizations: List, + externalWorkspaceSyncEnabled: Boolean, + ): Boolean = user.status == UserStatus.PENDING && shouldAutoApproveByAip(provider, externalOrganizations, externalWorkspaceSyncEnabled) + + @Transactional + internal fun syncExternalOrganizationsForOAuthUser( + user: UserEntity, + externalOrganizationSync: ExternalOrganizationSync.AuthoritativeSnapshot, ): List { - val normalizedExternalOrganizations = externalOrganizations.normalizeExternalOrganizations() - val syncedWorkspaces = - syncExternalOrganizations( + val normalizedExternalOrganizations = externalOrganizationSync.organizations.normalizeExternalOrganizations() + val syncResult = + externalWorkspaceSyncService.syncForUser( user = user, - externalOrganizations = normalizedExternalOrganizations, - enabled = isPlatformManagedWorkspaceEnabled(), + externalOrganizationSnapshot = + ExternalOrganizationSync.AuthoritativeSnapshot( + normalizedExternalOrganizations, + ), + enabled = isExternalWorkspaceSyncEnabled(), + reason = AIP_EXTERNAL_ORGANIZATION_REASON, ) - if (syncedWorkspaces.isNotEmpty()) { - publishExternalOrganizationApprovedEvent(user, syncedWorkspaces) + if (syncResult.addedWorkspaces.isNotEmpty()) { + publishExternalOrganizationApprovedEvent(user, syncResult.addedWorkspaces) } - return syncedWorkspaces + return syncResult.workspaces } // ========== 사용자 정보 수정 ========== @@ -899,53 +918,7 @@ class UserService( } } - private fun syncExternalOrganizations( - user: UserEntity, - externalOrganizations: List, - enabled: Boolean, - ): List = - if (!enabled) { - emptyList() - } else { - externalOrganizations.map { organization -> - val workspace = - workspaceRepository.findByExternalReferenceExternalId(organization.externalId)?.also { - it.name = organization.name ?: it.name - it.description = organization.description ?: it.description - it.managedType = WorkspaceManagedType.PLATFORM_MANAGED - } - ?: workspaceRepository.save( - WorkspaceEntity( - name = organization.name ?: organization.externalId, - description = organization.description, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, - externalReference = ExternalReference(organization.externalId), - ), - ) - - if (!workspaceMemberRepository.existsByWorkspaceIdAndUserId(workspace.id, user.id)) { - workspaceMemberRepository.save( - WorkspaceMemberEntity( - workspaceId = workspace.id, - userId = user.id, - isOwner = false, - ), - ) - eventPublisher.publishEvent( - WorkspaceMemberAddedEvent( - workspaceId = workspace.id, - memberId = UserId(user.id), - addedBy = UserId(SYSTEM_ACTOR_ID), - reason = AIP_EXTERNAL_ORGANIZATION_REASON, - ), - ) - } - - workspace - } - } - - private fun isPlatformManagedWorkspaceEnabled(): Boolean = platformSettingService.getSettings().workspacePolicy?.usePlatformManaged == true + private fun isExternalWorkspaceSyncEnabled(): Boolean = platformSettingService.getSettings().workspacePolicy?.useExternalSync == true private fun findOwnedWorkspaceMemberships(userId: UUID): List = workspaceMemberRepository.findActiveOwnerMembershipsByUserId(userId) 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 927ff5d20..ab8cc123f 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,8 +1,8 @@ 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.LocalDateTime @@ -12,13 +12,13 @@ import java.util.UUID class WorkspaceDirectoryImpl( private val workspaceService: WorkspaceService, ) : WorkspaceDirectory { - override fun ensureManagedTypeEnabled(managedType: WorkspaceManagedType) { + override fun ensureManagedTypeEnabled(managedType: ManagementType) { workspaceService.ensureManagedTypeEnabled(managedType.toDomain()) } override fun ensureAccessible( workspaceId: UUID, - managedType: WorkspaceManagedType?, + managedType: ManagementType?, ) { workspaceService.ensureWorkspaceAccessible(workspaceId, managedType?.toDomain()) } @@ -26,30 +26,28 @@ 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, autoJoinDomains: List, ): WorkspaceRecord = workspaceService - .create( + .createPlatformManaged( name = name, description = description, initialOwnerId = initialOwnerId, - managedType = managedType.toDomain(), autoJoinDomains = autoJoinDomains, ).toRecord() @@ -60,21 +58,19 @@ class WorkspaceDirectoryImpl( 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, autoJoinDomains: List, ): WorkspaceRecord = workspaceService - .updateByAdmin( + .updatePlatformManaged( workspaceId = workspaceId, name = name, description = description, updatedBy = updatedBy, - managedType = managedType.toDomain(), autoJoinDomains = autoJoinDomains, ).toRecord() @@ -83,7 +79,7 @@ class WorkspaceDirectoryImpl( name: String, description: String?, requestedBy: UUID, - managedType: WorkspaceManagedType, + managedType: ManagementType, autoJoinDomains: List, ): WorkspaceRecord = workspaceService @@ -96,17 +92,17 @@ class WorkspaceDirectoryImpl( 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()) } @@ -117,7 +113,7 @@ private data class WorkspaceRecordView( override val name: String, override val description: String?, override val autoJoinDomains: List, - override val managedType: WorkspaceManagedType, + override val managedType: ManagementType, override val externalReference: ExternalReferenceRecord?, override val createdAt: LocalDateTime?, override val updatedAt: LocalDateTime?, @@ -135,8 +131,6 @@ private fun io.deck.iam.domain.WorkspaceEntity.toRecord(): WorkspaceRecord = updatedAt = updatedAt, ) -private fun io.deck.iam.domain.WorkspaceManagedType.toApi(): WorkspaceManagedType = WorkspaceManagedType.valueOf(name) +private fun ManagementType.toApi(): ManagementType = this -private fun WorkspaceManagedType.toDomain(): io.deck.iam.domain.WorkspaceManagedType = - io.deck.iam.domain.WorkspaceManagedType - .valueOf(name) +private fun ManagementType.toDomain(): ManagementType = this 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 d1a415d9c..8141e0341 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 @@ -16,7 +16,6 @@ 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 org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -35,26 +34,37 @@ data class WorkspaceInviteValidation( override val hasAccount: Boolean, ) : WorkspaceInviteValidationResult +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 fun requireInternalWorkspace(workspaceId: UUID) = - workspaceRepository - .findById(workspaceId) - .orElseThrow { NotFoundException("iam.workspace.not_found") } - .also { workspace -> - if (workspace.isExternal) { - throw BadRequestException(messageCode = "iam.workspace.external_locked") - } - } + private fun requireMutableWorkspace(workspaceId: UUID) = workspaceService.findMutableById(workspaceId) fun findAllByWorkspace(workspaceId: UUID): List = inviteRepository.findAllByWorkspaceId(workspaceId) @@ -62,30 +72,9 @@ class WorkspaceInviteService( 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) - val isExternalWorkspace = workspace?.isExternal == true + ?: return invalidInviteValidation() - return WorkspaceInviteValidation( - valid = invite.isValid() && !alreadyMember && !isExternalWorkspace, - email = invite.email, - workspaceName = workspace?.name, - expired = invite.isExpired(), - alreadyMember = alreadyMember, - hasAccount = user != null, - ) + return loadInviteValidationState(invite).toResponse() } @Transactional @@ -95,7 +84,7 @@ class WorkspaceInviteService( message: String?, invitedBy: UUID, ): WorkspaceInviteEntity { - val workspace = requireInternalWorkspace(workspaceId) + val workspace = requireMutableWorkspace(workspaceId) val existingUser = userRepository.findByEmail(email) if (existingUser != null && memberService.isMember(workspaceId, existingUser.id)) { throw BadRequestException("iam.workspace_member.already_member", "ALREADY_MEMBER") @@ -125,7 +114,9 @@ class WorkspaceInviteService( tokenHash = tokenHash, expiresAt = Instant.now().plus(INVITE_EXPIRY_DAYS, ChronoUnit.DAYS), message = message, - ) + ).apply { + createdBy = invitedBy + } val saved = inviteRepository.save(invite) val inviter = userRepository.findById(invitedBy).orElse(null) @@ -156,7 +147,7 @@ class WorkspaceInviteService( ?: throw BadRequestException("iam.workspace_invite.invalid_token") require(invite.isValid()) { "Invite is not valid" } - requireInternalWorkspace(invite.workspaceId) + requireMutableWorkspace(invite.workspaceId) val user = userRepository.findByEmail(invite.email) val resolvedUser = @@ -169,7 +160,7 @@ class WorkspaceInviteService( name = resolvedName, email = invite.email, roleIds = emptySet(), - createdBy = UserId(invite.workspaceId), + createdBy = resolveInviteActorId(invite), contactProfile = null, ) } @@ -196,7 +187,7 @@ class WorkspaceInviteService( workspaceId: UUID, ) { val invite = findInviteBelongingTo(inviteId, workspaceId) - requireInternalWorkspace(workspaceId) + requireMutableWorkspace(workspaceId) invite.cancel() inviteRepository.save(invite) eventPublisher.publishEvent( @@ -226,7 +217,7 @@ class WorkspaceInviteService( workspaceId: UUID, ) { val invite = findInviteBelongingTo(inviteId, workspaceId) - val workspace = requireInternalWorkspace(invite.workspaceId) + val workspace = requireMutableWorkspace(invite.workspaceId) require(invite.status == InviteStatus.PENDING) { "Only PENDING invites can be resent" } @@ -273,6 +264,48 @@ class WorkspaceInviteService( return invite } + 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 resolveInviteActorId(invite: WorkspaceInviteEntity): UserId = UserId(invite.updatedBy ?: invite.createdBy ?: SYSTEM_ACTOR_ID) + private fun generateToken(): String { val bytes = ByteArray(TOKEN_BYTES) SecureRandom().nextBytes(bytes) 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 06eb3c69f..a126a4dcc 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,17 +20,11 @@ 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 requireInternalWorkspace(workspaceId: UUID) { - val workspace = - workspaceRepository - .findById(workspaceId) - .orElseThrow { NotFoundException("iam.workspace.not_found") } - if (workspace.isExternal) { - throw BadRequestException(messageCode = "iam.workspace.external_locked") - } + private fun requireMutableWorkspace(workspaceId: UUID) { + workspaceService.findMutableById(workspaceId) } fun findMembers(workspaceId: UUID): List = memberRepository.findAllByWorkspaceId(workspaceId) @@ -87,7 +80,7 @@ class WorkspaceMemberService( userId: UUID, addedBy: UUID, ): WorkspaceMemberEntity { - requireInternalWorkspace(workspaceId) + requireMutableWorkspace(workspaceId) if (memberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId)) { throw BadRequestException("iam.workspace_member.already_member", "ALREADY_MEMBER") } @@ -114,7 +107,7 @@ class WorkspaceMemberService( workspaceId: UUID, userId: UUID, ) { - requireInternalWorkspace(workspaceId) + requireMutableWorkspace(workspaceId) val member = memberRepository.findByWorkspaceIdAndUserId(workspaceId, userId) @@ -130,7 +123,7 @@ class WorkspaceMemberService( userId: UUID, removedBy: UUID, ) { - requireInternalWorkspace(workspaceId) + requireMutableWorkspace(workspaceId) val member = memberRepository.findByWorkspaceIdAndUserId(workspaceId, userId) ?: throw NotFoundException("iam.workspace_member.not_found") @@ -152,7 +145,7 @@ class WorkspaceMemberService( userIds: Collection, removedBy: UUID, ) { - requireInternalWorkspace(workspaceId) + requireMutableWorkspace(workspaceId) val targetUserIds = userIds.distinct() if (targetUserIds.isEmpty()) { return @@ -183,7 +176,7 @@ class WorkspaceMemberService( workspaceId: UUID, ownerUserIds: Collection, ) { - requireInternalWorkspace(workspaceId) + 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..956cc3904 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.createForUserIfEnabled( 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 d400b8ce1..9e101dd6f 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,10 +2,10 @@ 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.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 @@ -34,29 +34,43 @@ class WorkspaceService( fun findByUser(userId: UUID): List = workspaceRepository.findAllByMemberUserId(userId) - private fun requireInternalWorkspace(workspace: WorkspaceEntity) { + 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 = platformSettingService.getSettings().workspacePolicy ?: return emptyList() return workspaceRepository.findAllByMemberUserId(userId).filter { isManagedTypeEnabled(workspacePolicy, it.managedType) } } - fun ensureManagedTypeEnabled(managedType: WorkspaceManagedType) { - val workspacePolicy = platformSettingService.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, @@ -64,21 +78,42 @@ class WorkspaceService( initialOwnerId: UUID, 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, autoJoinDomains) + 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 create( + fun createPlatformManaged( name: String, description: String?, initialOwnerId: UUID, - managedType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED, autoJoinDomains: List = emptyList(), + ): WorkspaceEntity { + ensureManagedTypeEnabled(ManagementType.PLATFORM_MANAGED) + return saveWorkspace(name, description, initialOwnerId, ManagementType.PLATFORM_MANAGED, autoJoinDomains) + } + + private fun saveWorkspace( + name: String, + description: String?, + initialOwnerId: UUID, + managedType: ManagementType, + autoJoinDomains: List, ): WorkspaceEntity { validateAllowedDomains(autoJoinDomains) val workspace = @@ -115,10 +150,9 @@ class WorkspaceService( description: String?, requestedBy: UUID, autoJoinDomains: List? = null, - managedType: WorkspaceManagedType? = null, + managedType: ManagementType? = null, ): WorkspaceEntity { - val workspace = findPolicyAccessibleWorkspace(workspaceId, managedType) - requireInternalWorkspace(workspace) + val workspace = ensureMutable(findPolicyAccessibleWorkspace(workspaceId, managedType)) val effectiveAllowedDomains = autoJoinDomains ?: workspace.autoJoinDomains val effectiveManagedType = managedType ?: workspace.managedType ensureOwner(workspace, requestedBy) @@ -138,10 +172,9 @@ class WorkspaceService( fun delete( workspaceId: UUID, deletedBy: UUID, - managedType: WorkspaceManagedType? = null, + managedType: ManagementType? = null, ) { - val workspace = findPolicyAccessibleWorkspace(workspaceId, managedType) - requireInternalWorkspace(workspace) + val workspace = ensureMutable(findPolicyAccessibleWorkspace(workspaceId, managedType)) ensureOwner(workspace, deletedBy) workspace.softDelete(deletedBy) @@ -158,30 +191,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, autoJoinDomains: List? = null, - managedType: WorkspaceManagedType? = null, ): WorkspaceEntity { - val workspace = findById(workspaceId) - requireInternalWorkspace(workspace) + val workspace = ensureMutable(findPlatformManagedById(workspaceId)) val effectiveAllowedDomains = autoJoinDomains ?: workspace.autoJoinDomains - val effectiveManagedType = managedType ?: workspace.managedType validateAllowedDomains(effectiveAllowedDomains) - workspace.update(name, description, effectiveAllowedDomains, effectiveManagedType) + workspace.update(name, description, effectiveAllowedDomains, ManagementType.PLATFORM_MANAGED) eventPublisher.publishEvent( WorkspaceUpdatedEvent( workspaceId = workspace.id, @@ -192,12 +220,11 @@ class WorkspaceService( } @Transactional - fun deleteByAdmin( + fun deletePlatformManaged( workspaceId: UUID, deletedBy: UUID, ) { - val workspace = findById(workspaceId) - requireInternalWorkspace(workspace) + val workspace = ensureMutable(findPlatformManagedById(workspaceId)) workspace.softDelete(deletedBy) eventPublisher.publishEvent( WorkspaceDeletedEvent( @@ -208,19 +235,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) } @@ -248,7 +275,7 @@ class WorkspaceService( private fun findPolicyAccessibleWorkspace( workspaceId: UUID, - expectedManagedType: WorkspaceManagedType?, + expectedManagedType: ManagementType?, ): WorkspaceEntity { val workspace = findById(workspaceId) if (expectedManagedType != null && workspace.managedType != expectedManagedType) { @@ -260,11 +287,11 @@ class WorkspaceService( private fun isManagedTypeEnabled( workspacePolicy: WorkspacePolicy, - managedType: WorkspaceManagedType, + managedType: ManagementType, ): Boolean = when (managedType) { - WorkspaceManagedType.USER_MANAGED -> workspacePolicy.useUserManaged - WorkspaceManagedType.PLATFORM_MANAGED -> workspacePolicy.usePlatformManaged + 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/test/kotlin/io/deck/iam/controller/MenuControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/MenuControllerTest.kt index cc5fcc70e..a09482434 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,7 +46,7 @@ class MenuControllerTest : namesI18n = mapOf("en" to "Profile", "ko" to "프로필"), icon = "user", programType = "BOOKING_PROFILE", - managementType = WorkspaceManagedType.PLATFORM_MANAGED, + managementType = ManagementType.PLATFORM_MANAGED, sortOrder = 0, ) val root = @@ -57,7 +57,7 @@ class MenuControllerTest : namesI18n = mapOf("en" to "Booking", "ko" to "예약"), icon = "calendar", programType = MenuEntity.NONE_PROGRAM_CODE, - managementType = WorkspaceManagedType.USER_MANAGED, + managementType = ManagementType.USER_MANAGED, sortOrder = 0, children = mutableListOf(child), ) @@ -70,7 +70,7 @@ class MenuControllerTest : val response = controller.getMenuTree(roleId) response.body?.first()?.name shouldBe "예약" - response.body?.first()?.managementType shouldBe WorkspaceManagedType.USER_MANAGED + response.body?.first()?.managementType shouldBe ManagementType.USER_MANAGED response.body ?.first() ?.children @@ -80,7 +80,7 @@ class MenuControllerTest : ?.first() ?.children ?.first() - ?.managementType shouldBe WorkspaceManagedType.PLATFORM_MANAGED + ?.managementType shouldBe ManagementType.PLATFORM_MANAGED } it("console program 메뉴는 PLATFORM_MANAGED를 반환한다") { @@ -92,7 +92,7 @@ class MenuControllerTest : namesI18n = mapOf("en" to "Users", "ko" to "사용자"), icon = "users", programType = "USER_MANAGEMENT", - managementType = WorkspaceManagedType.PLATFORM_MANAGED, + managementType = ManagementType.PLATFORM_MANAGED, sortOrder = 0, ) @@ -100,7 +100,7 @@ class MenuControllerTest : val response = controller.getMenuTree(roleId) - response.body?.first()?.managementType shouldBe WorkspaceManagedType.PLATFORM_MANAGED + response.body?.first()?.managementType shouldBe ManagementType.PLATFORM_MANAGED } } @@ -116,7 +116,7 @@ class MenuControllerTest : workspace = ProgramDefinition.WorkspacePolicy( required = true, - managedType = WorkspaceManagedType.USER_MANAGED, + requiredManagedType = ManagementType.USER_MANAGED, ), ), ) @@ -134,7 +134,7 @@ class MenuControllerTest : workspace = ProgramWorkspacePolicyDto( required = true, - managedType = WorkspaceManagedType.USER_MANAGED, + requiredManagedType = ManagementType.USER_MANAGED, ), ), ) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingControllerTest.kt index 00c85053f..07d1163c6 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingControllerTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/controller/PlatformSettingControllerTest.kt @@ -143,7 +143,13 @@ class PlatformSettingControllerTest : PlatformSettingEntity( brandName = "Deck Next", contactEmail = "privacy@deck.io", - workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false, useSelector = true), + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = false, + useSelector = true, + ), ) every { @@ -179,19 +185,32 @@ class PlatformSettingControllerTest : WorkspacePolicyDto( useUserManaged = true, usePlatformManaged = false, + useExternalSync = false, useSelector = true, ), ) val settings = PlatformSettingEntity( brandName = "Deck Next", - workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false, useSelector = true), + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = false, + useSelector = true, + ), ) every { platformSettingService.updateWorkspacePolicy( userId = ownerId, - workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false, useSelector = true), + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = false, + useSelector = true, + ), ) } returns settings @@ -204,7 +223,13 @@ class PlatformSettingControllerTest : verify { platformSettingService.updateWorkspacePolicy( userId = ownerId, - workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false, useSelector = true), + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = false, + useExternalSync = false, + useSelector = true, + ), ) } } 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 84fd42166..30bb19f0c 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,7 @@ package io.deck.iam.domain +import io.deck.iam.ManagementType +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -9,7 +11,7 @@ class WorkspaceEntityTest : it("기본값은 USER_MANAGED다") { val workspace = WorkspaceEntity(name = "기본 워크스페이스") - workspace.managedType shouldBe WorkspaceManagedType.USER_MANAGED + workspace.managedType shouldBe ManagementType.USER_MANAGED } } @@ -18,13 +20,23 @@ class WorkspaceEntityTest : val workspace = WorkspaceEntity( name = "AIP Workspace", - managedType = WorkspaceManagedType.PLATFORM_MANAGED, + managedType = ManagementType.PLATFORM_MANAGED, externalReference = ExternalReference(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(externalId = "aip-org-1"), + ) + } + } } describe("update") { @@ -38,12 +50,29 @@ class WorkspaceEntityTest : workspace.update( name = "새 이름", description = "새 설명", - managedType = WorkspaceManagedType.PLATFORM_MANAGED, + managedType = ManagementType.PLATFORM_MANAGED, ) workspace.name shouldBe "새 이름" workspace.description shouldBe "새 설명" - workspace.managedType shouldBe WorkspaceManagedType.PLATFORM_MANAGED + workspace.managedType shouldBe ManagementType.PLATFORM_MANAGED + } + + it("external workspace를 USER_MANAGED로 바꾸려 하면 거부해야 한다") { + val workspace = + WorkspaceEntity( + name = "AIP Workspace", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(externalId = "aip-org-1"), + ) + + shouldThrow { + workspace.update( + name = "새 이름", + description = "새 설명", + managedType = ManagementType.USER_MANAGED, + ) + } } } }) 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 cb372ee50..317cc76e2 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 @@ -4,6 +4,7 @@ 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.UserEntity import io.deck.iam.domain.UserStatus import io.deck.iam.service.AuthErrorType @@ -194,6 +195,7 @@ class OAuth2LinkingTest : ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta", description = "Beta team"), ) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(externalOrganizations) every { userService.validateOAuthLogin(AuthProvider.AIP, "aip-sub-123", "aip@example.com") } returns null every { @@ -204,7 +206,7 @@ class OAuth2LinkingTest : name = "AIP User", ipAddress = "127.0.0.1", userAgent = "TestAgent", - externalOrganizations = externalOrganizations, + externalOrganizationSync = authoritativeSnapshot, ) } returns AuthResult.Success(user, "jwt-token") @@ -218,7 +220,7 @@ class OAuth2LinkingTest : name = "AIP User", ipAddress = "127.0.0.1", userAgent = "TestAgent", - externalOrganizations = externalOrganizations, + externalOrganizationSync = authoritativeSnapshot, ) } verify { response.sendRedirect("/") } @@ -241,6 +243,7 @@ class OAuth2LinkingTest : name = "OAuth User", ipAddress = "127.0.0.1", userAgent = "TestAgent", + externalOrganizationSync = ExternalOrganizationSync.NoSync, ) } returns AuthResult.Success(user, "jwt-token") @@ -256,6 +259,7 @@ class OAuth2LinkingTest : name = "OAuth User", ipAddress = "127.0.0.1", userAgent = "TestAgent", + externalOrganizationSync = ExternalOrganizationSync.NoSync, ) } verify { response.sendRedirect("/") } @@ -270,7 +274,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 @@ -296,6 +300,7 @@ class OAuth2LinkingTest : name = "OAuth User", ipAddress = "127.0.0.1", userAgent = "TestAgent", + externalOrganizationSync = ExternalOrganizationSync.NoSync, ) } returns AuthResult.TwoFactorRequired(user, "two-factor-token") @@ -329,7 +334,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("예외 발생 시 실패 로그 기록 후 에러 페이지로 리다이렉트") { @@ -341,7 +346,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 @@ -397,7 +402,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로 리다이렉트한다") { @@ -457,7 +462,7 @@ 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, @@ -484,6 +489,7 @@ class OAuth2LinkingTest : name = "OAuth User", ipAddress = "127.0.0.1", userAgent = "TestAgent", + externalOrganizationSync = ExternalOrganizationSync.NoSync, ) } returns AuthResult.Success(user, "jwt-token") @@ -491,7 +497,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..65abebc3a --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2UserInfoExtractorTest.kt @@ -0,0 +1,66 @@ +package io.deck.iam.security + +import io.deck.iam.domain.AuthProvider +import io.deck.iam.domain.ExternalOrganizationSync +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( + organizations = emptyList(), + ) + } + } + }) 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 90420ad5f..31e442427 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 @@ -5,6 +5,7 @@ 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.IdentityEntity import io.deck.iam.domain.SessionType import io.deck.iam.domain.UserEntity @@ -26,6 +27,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 @@ -68,6 +70,7 @@ class AuthServiceTest : beforeEach { userService = mockk() + oAuthLoginProvisioningService = mockk() identityService = mockk() loginHistoryService = mockk(relaxed = true) sessionService = mockk(relaxed = true) @@ -83,6 +86,7 @@ class AuthServiceTest : authService = AuthService( userService = userService, + oAuthLoginProvisioningService = oAuthLoginProvisioningService, identityService = identityService, loginHistoryService = loginHistoryService, sessionService = sessionService, @@ -298,7 +302,7 @@ class AuthServiceTest : } describe("authenticateWithOAuth") { - it("AIP external organization claims를 UserService로 전달한다") { + it("AIP external organization claims를 OAuth provisioning service로 전달한다") { val user = createUser() val sessionId = UUID.randomUUID() val session = createSession(sessionId = sessionId, userId = user.id) @@ -313,23 +317,17 @@ class AuthServiceTest : ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta", description = "Beta team"), ) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(externalOrganizations) every { - userService.findOrCreateByOAuth( + oAuthLoginProvisioningService.resolveUser( AuthProvider.AIP, "aip-sub", "oauth@example.com", "OAuth User", - externalOrganizations, - false, + authoritativeSnapshot, ) - } returns user - every { - userService.syncExternalOrganizationsForOAuthUser( - user, - externalOrganizations, - ) - } returns emptyList() + } returns OAuthLoginProvisioningResult(user = user) every { sessionService.createPending( sessionId = any(), @@ -354,24 +352,78 @@ class AuthServiceTest : name = "OAuth User", ipAddress = "192.168.1.1", userAgent = "Mozilla/5.0", - externalOrganizations = externalOrganizations, + externalOrganizationSync = authoritativeSnapshot, ) result.shouldBeInstanceOf() verify { - userService.findOrCreateByOAuth( + oAuthLoginProvisioningService.resolveUser( AuthProvider.AIP, "aip-sub", "oauth@example.com", "OAuth User", - externalOrganizations, - false, + 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(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 { - userService.syncExternalOrganizationsForOAuthUser( - user, - externalOrganizations, + oAuthLoginProvisioningService.resolveUser( + AuthProvider.AIP, + "aip-empty-sub", + "oauth@example.com", + "OAuth User", + authoritativeEmptySnapshot, ) } } @@ -388,15 +440,14 @@ class AuthServiceTest : ) every { - userService.findOrCreateByOAuth( + oAuthLoginProvisioningService.resolveUser( AuthProvider.GOOGLE, "google-sub", "oauth@example.com", "OAuth User", - emptyList(), - false, + ExternalOrganizationSync.NoSync, ) - } returns user + } returns OAuthLoginProvisioningResult(user = user) every { sessionService.createPending( sessionId = any(), @@ -432,15 +483,18 @@ class AuthServiceTest : it("잠긴 OAuth 사용자는 세션을 만들지 않고 ACCOUNT_LOCKED를 반환한다") { val user = createUser(status = UserStatus.LOCKED) every { - userService.findOrCreateByOAuth( + oAuthLoginProvisioningService.resolveUser( AuthProvider.GOOGLE, "google-sub", "oauth@example.com", "OAuth User", - emptyList(), - false, + ExternalOrganizationSync.NoSync, + ) + } returns + OAuthLoginProvisioningResult( + user = user, + statusError = OAuthLoginStatusError("ACCOUNT_LOCKED", AuthErrorType.ACCOUNT_LOCKED, "Account is locked"), ) - } returns user val result = authService.authenticateWithOAuth( @@ -463,17 +517,21 @@ class AuthServiceTest : listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(externalOrganizations) every { - userService.findOrCreateByOAuth( + oAuthLoginProvisioningService.resolveUser( AuthProvider.AIP, "aip-sub-locked", "oauth@example.com", "OAuth User", - externalOrganizations, - false, + authoritativeSnapshot, + ) + } returns + OAuthLoginProvisioningResult( + user = user, + statusError = OAuthLoginStatusError("ACCOUNT_LOCKED", AuthErrorType.ACCOUNT_LOCKED, "Account is locked"), ) - } returns user val result = authService.authenticateWithOAuth( @@ -483,17 +541,11 @@ class AuthServiceTest : name = "OAuth User", ipAddress = "192.168.1.1", userAgent = "Mozilla/5.0", - externalOrganizations = externalOrganizations, + externalOrganizationSync = authoritativeSnapshot, ) result.shouldBeInstanceOf() result.errorType shouldBe AuthErrorType.ACCOUNT_LOCKED - verify(exactly = 0) { - userService.syncExternalOrganizationsForOAuthUser( - any(), - any(), - ) - } verify(exactly = 0) { sessionService.createPending(any(), any(), any(), any(), any(), any(), any(), any(), any()) } } @@ -503,15 +555,18 @@ class AuthServiceTest : deletedAt = Instant.now() } every { - userService.findOrCreateByOAuth( + oAuthLoginProvisioningService.resolveUser( AuthProvider.GOOGLE, "google-sub", "oauth@example.com", "OAuth User", - emptyList(), - false, + ExternalOrganizationSync.NoSync, + ) + } returns + OAuthLoginProvisioningResult( + user = user, + statusError = OAuthLoginStatusError("ACCOUNT_DELETED", AuthErrorType.ACCOUNT_INACTIVE, "Account is deleted"), ) - } returns user 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..c1464815b --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/ExternalWorkspaceSyncServiceTest.kt @@ -0,0 +1,286 @@ +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.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.WorkspaceRepository +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.slot +import io.mockk.verify +import org.springframework.dao.DataIntegrityViolationException + +class ExternalWorkspaceSyncServiceTest : + DescribeSpec({ + val workspaceRepository = mockk() + val workspaceMemberRepository = mockk() + val eventPublisher = mockk(relaxed = true) + val service = ExternalWorkspaceSyncService(workspaceRepository, workspaceMemberRepository, eventPublisher) + + fun user(email: String = "aip@example.com") = + UserEntity( + name = "AIP User", + email = email, + ) + + beforeEach { + io.mockk.clearMocks(workspaceRepository, workspaceMemberRepository, eventPublisher) + } + + describe("syncForUser") { + it("claim에서 사라진 external workspace membership을 제거한다") { + val user = user() + val activeWorkspace = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-1"), + ) + val staleWorkspace = + WorkspaceEntity( + name = "Legacy Org", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-stale"), + ) + val staleMembership = + WorkspaceMemberEntity( + workspaceId = staleWorkspace.id, + userId = user.id, + isOwner = false, + ) + + every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns activeWorkspace + every { workspaceRepository.findAllByMemberUserId(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( + 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("새 external workspace를 PLATFORM_MANAGED로 생성하고 membership을 연결한다") { + val user = user() + val savedWorkspace = slot() + val membership = slot() + + every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns null + every { workspaceRepository.save(capture(savedWorkspace)) } answers { savedWorkspace.captured } + every { workspaceRepository.findAllByMemberUserId(user.id) } returns emptyList() + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(any(), user.id) } returns false + every { workspaceMemberRepository.save(capture(membership)) } answers { membership.captured } + + val result = + service.syncForUser( + user = user, + externalOrganizationSnapshot = + ExternalOrganizationSync.AuthoritativeSnapshot( + listOf( + ExternalOrganizationClaim( + externalId = "aip-org-1", + name = "Acme", + description = "Imported workspace", + ), + ), + ), + enabled = true, + reason = "aip_external_organization", + ) + + result.workspaces.single().managedType shouldBe ManagementType.PLATFORM_MANAGED + result.workspaces + .single() + .externalReference + ?.externalId shouldBe "aip-org-1" + result.addedWorkspaces shouldBe result.workspaces + result.removedWorkspaceIds shouldBe emptySet() + savedWorkspace.captured.managedType shouldBe ManagementType.PLATFORM_MANAGED + membership.captured.workspaceId shouldBe result.workspaces.single().id + membership.captured.userId shouldBe user.id + membership.captured.isOwner shouldBe false + verify(exactly = 1) { + eventPublisher.publishEvent( + match { + it is WorkspaceMemberAddedEvent && + it.workspaceId == result.workspaces.single().id && + it.memberId.value == user.id && + it.addedBy.value == SYSTEM_ACTOR_ID && + it.reason == "aip_external_organization" + }, + ) + } + } + + it("existing external workspace sync에서 description claim이 비어 있으면 기존 description을 유지한다") { + val user = user() + val existingWorkspace = + WorkspaceEntity( + name = "Acme", + description = "Existing description", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-1"), + ) + + every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns existingWorkspace + every { workspaceRepository.findAllByMemberUserId(user.id) } returns listOf(existingWorkspace) + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(existingWorkspace.id, user.id) } returns true + + val result = + service.syncForUser( + user = user, + externalOrganizationSnapshot = + ExternalOrganizationSync.AuthoritativeSnapshot( + listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Renamed Acme")), + ), + enabled = true, + reason = "aip_external_organization", + ) + + result.workspaces.single().name shouldBe "Renamed Acme" + result.workspaces.single().description shouldBe "Existing description" + result.addedWorkspaces shouldBe emptyList() + } + + it("동시 생성 경합으로 insert가 실패하면 기존 external workspace를 다시 조회해 재사용한다") { + val user = user() + val existingWorkspace = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-1"), + ) + + every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returnsMany listOf(null, existingWorkspace) + every { workspaceRepository.save(any()) } throws DataIntegrityViolationException("duplicate key") + every { workspaceRepository.findAllByMemberUserId(user.id) } returns listOf(existingWorkspace) + every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(existingWorkspace.id, user.id) } returns true + + val result = + service.syncForUser( + user = user, + externalOrganizationSnapshot = + ExternalOrganizationSync.AuthoritativeSnapshot( + 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 = 2) { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } + } + + it("empty snapshot이면 기존 external membership을 모두 제거한다") { + val user = user() + val staleWorkspace = + WorkspaceEntity( + name = "Legacy Org", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-stale"), + ) + val staleMembership = + WorkspaceMemberEntity( + workspaceId = staleWorkspace.id, + userId = user.id, + isOwner = false, + ) + + every { workspaceRepository.findAllByMemberUserId(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(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가 실패해도 기존 membership이 생겼으면 재사용한다") { + val user = user() + val existingWorkspace = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-1"), + ) + + every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns existingWorkspace + every { workspaceRepository.findAllByMemberUserId(user.id) } returns emptyList() + every { + workspaceMemberRepository.existsByWorkspaceIdAndUserId(existingWorkspace.id, user.id) + } returnsMany listOf(false, true) + every { + workspaceMemberRepository.save(any()) + } throws DataIntegrityViolationException("duplicate key") + + val result = + service.syncForUser( + user = user, + externalOrganizationSnapshot = + ExternalOrganizationSync.AuthoritativeSnapshot( + 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 = 2) { + workspaceMemberRepository.existsByWorkspaceIdAndUserId(existingWorkspace.id, user.id) + } + 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 9fcbdddaf..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,9 +1,9 @@ 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 -import io.deck.iam.domain.WorkspaceManagedType import io.deck.iam.repository.MenuRepository import io.deck.iam.repository.RoleRepository import io.kotest.core.spec.style.DescribeSpec @@ -16,7 +16,6 @@ import io.mockk.runs import io.mockk.slot import io.mockk.verify import java.util.UUID -import io.deck.iam.api.WorkspaceManagedType as ApiWorkspaceManagedType class MenuSeedCommandImplTest : DescribeSpec({ @@ -100,21 +99,21 @@ class MenuSeedCommandImplTest : SeedMenuDefinition( name = "Platform", programType = "NONE", - managementType = ApiWorkspaceManagedType.PLATFORM_MANAGED, + managementType = ManagementType.PLATFORM_MANAGED, children = listOf( SeedMenuDefinition( name = "Users", programType = "USER_MANAGEMENT", - managementType = ApiWorkspaceManagedType.PLATFORM_MANAGED, + managementType = ManagementType.PLATFORM_MANAGED, ), ), ), ), ) - savedMenus[0].managementType shouldBe WorkspaceManagedType.PLATFORM_MANAGED - savedMenus[1].managementType shouldBe WorkspaceManagedType.PLATFORM_MANAGED + savedMenus[0].managementType shouldBe ManagementType.PLATFORM_MANAGED + savedMenus[1].managementType shouldBe ManagementType.PLATFORM_MANAGED } it("이미 존재하는 메뉴는 중복 생성하지 않는다") { @@ -304,7 +303,7 @@ class MenuSeedCommandImplTest : icon = "folder", programType = "NONE", sortOrder = 0, - managementType = WorkspaceManagedType.USER_MANAGED, + managementType = ManagementType.USER_MANAGED, ) every { roleRepository.findAllOrderBySortOrder() } returns listOf(role) @@ -317,12 +316,12 @@ class MenuSeedCommandImplTest : name = "Platform", icon = "shield", programType = "NONE", - managementType = ApiWorkspaceManagedType.PLATFORM_MANAGED, + managementType = ManagementType.PLATFORM_MANAGED, ), ), ) - existingRoot.managementType shouldBe WorkspaceManagedType.PLATFORM_MANAGED + existingRoot.managementType shouldBe ManagementType.PLATFORM_MANAGED } } 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 a1d9f5a1c..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 @@ -175,7 +175,7 @@ class MenuServiceTest : ) result.programType shouldBe "NONE" - result.managementType shouldBe io.deck.iam.domain.WorkspaceManagedType.USER_MANAGED + result.managementType shouldBe io.deck.iam.ManagementType.USER_MANAGED verify(exactly = 1) { menuRepository.save(any()) } } } @@ -192,7 +192,7 @@ class MenuServiceTest : namesI18n = mapOf("en" to "Old"), icon = "old-icon", programType = "OLD", - managementType = io.deck.iam.domain.WorkspaceManagedType.USER_MANAGED, + managementType = io.deck.iam.ManagementType.USER_MANAGED, sortOrder = 0, permissions = setOf("old"), ) @@ -207,7 +207,7 @@ class MenuServiceTest : namesI18n = mapOf("en" to "New", "ko" to "새 이름"), icon = "new-icon", programType = "NEW", - managementType = io.deck.iam.domain.WorkspaceManagedType.PLATFORM_MANAGED, + managementType = io.deck.iam.ManagementType.PLATFORM_MANAGED, permissions = setOf("read", "write"), ) @@ -215,7 +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.domain.WorkspaceManagedType.PLATFORM_MANAGED + result.managementType shouldBe io.deck.iam.ManagementType.PLATFORM_MANAGED result.permissions shouldBe setOf("read", "write") verify(exactly = 1) { menuRepository.findById(menuId) } @@ -251,7 +251,7 @@ class MenuServiceTest : name = "Booking", namesI18n = mapOf("en" to "Booking", "ko" to "예약"), programType = MenuEntity.NONE_PROGRAM_CODE, - managementType = io.deck.iam.domain.WorkspaceManagedType.PLATFORM_MANAGED, + managementType = io.deck.iam.ManagementType.PLATFORM_MANAGED, sortOrder = 0, ) @@ -262,7 +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.domain.WorkspaceManagedType.PLATFORM_MANAGED + 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..f6d541a7f --- /dev/null +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthLoginProvisioningServiceTest.kt @@ -0,0 +1,123 @@ +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.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, + ) + } returns user + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(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, + ) + } returns user + + val result = + provisioningService.resolveUser( + provider = AuthProvider.AIP, + providerUserId = "aip-sub", + email = "oauth@example.com", + name = "OAuth User", + externalOrganizationSync = ExternalOrganizationSync.AuthoritativeSnapshot(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(), + ) + } 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()) } + } + } + }) diff --git a/backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceTest.kt index 69092d347..4b2f6064b 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/PlatformSettingServiceTest.kt @@ -86,6 +86,35 @@ class PlatformSettingServiceTest : 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("시스템 설정이 존재하지 않는 경우") { 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 fbe5ed0ab..b3c2de7fd 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 @@ -18,6 +19,7 @@ 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.IdentityEntity import io.deck.iam.domain.LocaleType @@ -27,7 +29,6 @@ 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 @@ -95,6 +96,7 @@ class UserServiceTest : lateinit var contactPhoneNumberNormalizer: ContactPhoneNumberNormalizer lateinit var partyCommand: PartyCommand lateinit var partyQuery: PartyQuery + lateinit var externalWorkspaceSyncService: ExternalWorkspaceSyncService lateinit var userService: UserService fun createTestUser( @@ -131,6 +133,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", @@ -297,6 +309,7 @@ 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() @@ -304,6 +317,15 @@ class UserServiceTest : 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( @@ -323,6 +345,7 @@ class UserServiceTest : contactPhoneNumberNormalizer = contactPhoneNumberNormalizer, partyCommand = partyCommand, partyQuery = partyQuery, + externalWorkspaceSyncService = externalWorkspaceSyncService, ) } @@ -1025,94 +1048,199 @@ class UserServiceTest : describe("findOrCreateByOAuth") { describe("기존 Identity가 있는 경우") { - it("AIP 재로그인 시 같은 externalId workspace를 재사용하고 누락된 membership만 추가한다") { + it("syncExternalOrganizationsForOAuthUser는 같은 externalId workspace를 재사용하고 누락된 membership만 추가한다") { val user = createTestUser(name = "AIP User", email = "aip@example.com") - val existingIdentity = createOAuthIdentity(user, provider = AuthProvider.AIP, providerUserId = "aip-sub-123", email = "aip@example.com") val workspaceA = WorkspaceEntity( name = "Acme", - managedType = WorkspaceManagedType.PLATFORM_MANAGED, + managedType = ManagementType.PLATFORM_MANAGED, externalReference = ExternalReference("aip-org-1"), ) val workspaceB = WorkspaceEntity( name = "Beta", - managedType = WorkspaceManagedType.PLATFORM_MANAGED, + managedType = ManagementType.PLATFORM_MANAGED, externalReference = ExternalReference("aip-org-2"), ) every { - identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-sub-123") - } returns existingIdentity - every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns workspaceA - every { workspaceRepository.findByExternalReferenceExternalId("aip-org-2") } returns workspaceB - every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(workspaceA.id, user.id) } returns true - every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(workspaceB.id, user.id) } returns false - every { workspaceMemberRepository.save(any()) } answers { firstArg() } + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( + 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.findOrCreateByOAuth( - provider = AuthProvider.AIP, - providerUserId = "aip-sub-123", - email = "aip@example.com", - name = "AIP User", - externalOrganizations = + userService.syncExternalOrganizationsForOAuthUser( + user = user, + externalOrganizationSync = + ExternalOrganizationSync.AuthoritativeSnapshot( + 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( listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta"), ), + ), + true, + "AIP_EXTERNAL_ORGANIZATION", + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, ) + } + } - result shouldBe user - verify(exactly = 0) { userRepository.save(any()) } - verify(exactly = 0) { workspaceRepository.save(any()) } + 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("aip-org-1"), + ) + + every { + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( + 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( + listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ), + ), + ) + + result shouldBe listOf(activeWorkspace) verify(exactly = 1) { - workspaceMemberRepository.save( - match { - it.workspaceId == workspaceB.id && - it.userId == user.id && - !it.isOwner - }, + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( + listOf( + ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), + ), + ), + true, + "AIP_EXTERNAL_ORGANIZATION", + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, ) } } - it("AIP 재로그인이라도 platform-managed policy가 꺼져 있으면 external sync를 건너뛴다") { + it("syncExternalOrganizationsForOAuthUser는 membership 변화가 없으면 승인 이벤트를 다시 발행하지 않는다") { val user = createTestUser(name = "AIP User", email = "aip@example.com") - val existingIdentity = - createOAuthIdentity( + val workspace = + WorkspaceEntity( + name = "Acme", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference("aip-org-1"), + ) + + every { + externalWorkspaceSyncService.syncForUser( user, - provider = AuthProvider.AIP, - providerUserId = "aip-sub-locked", - email = "aip@example.com", + ExternalOrganizationSync.AuthoritativeSnapshot( + 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( + 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 = false)) - every { - identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-sub-locked") - } returns existingIdentity - every { workspaceRepository.findByExternalReferenceExternalId(any()) } returns null - every { workspaceRepository.save(any()) } answers { firstArg() } - every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(any(), any()) } returns false - every { workspaceMemberRepository.save(any()) } answers { firstArg() } + PlatformSettingEntity( + workspacePolicy = + WorkspacePolicy( + useUserManaged = true, + usePlatformManaged = true, + useExternalSync = false, + ), + ) val result = - userService.findOrCreateByOAuth( - provider = AuthProvider.AIP, - providerUserId = "aip-sub-locked", - email = "aip@example.com", - name = "AIP User", - externalOrganizations = + userService.syncExternalOrganizationsForOAuthUser( + user = user, + externalOrganizationSync = + ExternalOrganizationSync.AuthoritativeSnapshot( + listOf( + ExternalOrganizationClaim(externalId = "aip-org-locked", name = "Locked Org"), + ), + ), + ) + + result shouldBe emptyList() + verify(exactly = 1) { + externalWorkspaceSyncService.syncForUser( + user, + ExternalOrganizationSync.AuthoritativeSnapshot( listOf( ExternalOrganizationClaim(externalId = "aip-org-locked", name = "Locked Org"), ), + ), + false, + "AIP_EXTERNAL_ORGANIZATION", + ExternalWorkspaceSyncMode.AUTHORITATIVE_FULL_SNAPSHOT, ) - - result shouldBe user - verify(exactly = 0) { workspaceRepository.findByExternalReferenceExternalId(any()) } - verify(exactly = 0) { workspaceRepository.save(any()) } - verify(exactly = 0) { workspaceMemberRepository.save(any()) } + } } it("기존 사용자 정보를 유지하고 반환한다 (OAuth 정보로 덮어쓰지 않음)") { @@ -1275,24 +1403,13 @@ class UserServiceTest : } describe("OAuth 신규 사용자 PENDING 상태") { - it("AIP 조직 claim이 있으면 external workspace를 auto-provision하고 ACTIVE로 생성한다") { + it("AIP 조직 claim이 있으면 ACTIVE로 생성한다") { val savedUser = slot() - val savedWorkspaces = mutableListOf() - val savedMemberships = mutableListOf() every { identityService.findByProviderAndProviderUserId(AuthProvider.AIP, "aip-sub-new") } returns null - every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns null - every { workspaceRepository.findByExternalReferenceExternalId("aip-org-2") } returns null every { userRepository.save(capture(savedUser)) } answers { savedUser.captured } - every { workspaceRepository.save(any()) } answers { - firstArg().also(savedWorkspaces::add) - } - every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(any(), any()) } returns false - every { workspaceMemberRepository.save(any()) } answers { - firstArg().also(savedMemberships::add) - } val result = userService.findOrCreateByOAuth( @@ -1308,27 +1425,25 @@ class UserServiceTest : ) result.status shouldBe UserStatus.ACTIVE - savedWorkspaces shouldHaveSize 2 - savedWorkspaces.map { it.externalReference?.externalId } shouldBe listOf("aip-org-1", "aip-org-2") - savedWorkspaces.map { it.managedType }.distinct() shouldBe listOf(WorkspaceManagedType.PLATFORM_MANAGED) - savedMemberships shouldHaveSize 2 - savedMemberships.map { it.userId }.distinct() shouldBe listOf(result.id) - savedMemberships.all { !it.isOwner } shouldBe true + verify(exactly = 0) { externalWorkspaceSyncService.syncForUser(any(), any(), any(), any(), any()) } } - it("platform-managed policy가 꺼져 있으면 AIP 조직 claim이 있어도 external workspace를 만들지 않고 PENDING으로 남긴다") { + it("external sync policy가 꺼져 있으면 AIP 조직 claim이 있어도 PENDING으로 남긴다") { val savedUser = slot() every { platformSettingService.getSettings() } returns - PlatformSettingEntity(workspacePolicy = WorkspacePolicy(useUserManaged = true, usePlatformManaged = false)) + 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 } - every { workspaceRepository.findByExternalReferenceExternalId(any()) } returns null - every { workspaceRepository.save(any()) } answers { firstArg() } - every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(any(), any()) } returns false - every { workspaceMemberRepository.save(any()) } answers { firstArg() } val result = userService.findOrCreateByOAuth( @@ -1343,9 +1458,7 @@ class UserServiceTest : ) result.status shouldBe UserStatus.PENDING - verify(exactly = 0) { workspaceRepository.findByExternalReferenceExternalId(any()) } - verify(exactly = 0) { workspaceRepository.save(any()) } - verify(exactly = 0) { workspaceMemberRepository.save(any()) } + verify(exactly = 0) { externalWorkspaceSyncService.syncForUser(any(), any(), any(), any(), any()) } } it("신규 OAuth 사용자는 PENDING 상태로 생성된다") { @@ -1373,8 +1486,8 @@ class UserServiceTest : it("auto join domain과 매칭되면 ACTIVE로 생성하고 매칭된 모든 workspace에 member로 추가한다") { // given val savedUser = slot() - val workspaceA = WorkspaceEntity(name = "Acme", managedType = WorkspaceManagedType.PLATFORM_MANAGED, autoJoinDomains = listOf("acme.com")) - val workspaceB = WorkspaceEntity(name = "Dev", managedType = WorkspaceManagedType.PLATFORM_MANAGED, autoJoinDomains = 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 { @@ -1492,6 +1605,31 @@ 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"), + ), + ) + + 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 63e03bbcb..5e90f95e2 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 @@ -16,7 +16,6 @@ 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.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -27,8 +26,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.verify import org.springframework.context.ApplicationEventPublisher -import java.time.Instant -import java.time.temporal.ChronoUnit +import java.time.LocalDateTime import java.util.Optional import java.util.UUID @@ -36,7 +34,7 @@ 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 @@ -59,7 +57,9 @@ class WorkspaceInviteServiceTest : email: String = "invite@example.com", tokenHash: String = HashUtils.sha3("token"), status: InviteStatus = InviteStatus.PENDING, - expiresAt: Instant = Instant.now().plus(1, ChronoUnit.DAYS), + expiresAt: LocalDateTime = LocalDateTime.now().plusDays(1), + createdBy: UUID = UUID.randomUUID(), + updatedBy: UUID? = null, ) = WorkspaceInviteEntity( workspaceId = workspaceId, email = email, @@ -67,22 +67,36 @@ class WorkspaceInviteServiceTest : status = status, expiresAt = expiresAt, message = "메시지", - ) + ).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) - every { workspaceRepository.findById(any()) } answers { - Optional.of( - io.deck.iam.domain - .WorkspaceEntity(name = "워크스페이스", id = firstArg()), + workspaceInviteService = + WorkspaceInviteService( + inviteRepository, + userRepository, + workspaceService, + memberRepository, + userService, + memberService, + eventPublisher, ) + 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()) } } @@ -99,6 +113,27 @@ class WorkspaceInviteServiceTest : } 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" @@ -106,15 +141,13 @@ class WorkspaceInviteServiceTest : val invite = createInvite(workspaceId = workspaceId, email = "invite@example.com", tokenHash = tokenHash) every { inviteRepository.findByTokenHash(tokenHash) } returns invite - every { - workspaceRepository.findById(workspaceId) - } returns Optional.of( + 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("aip-org-1"), - ), - ) + ) every { userRepository.findByEmail("invite@example.com") } returns null val result = workspaceInviteService.validateToken(token) @@ -135,7 +168,7 @@ 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) @@ -169,10 +202,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 @@ -187,13 +219,8 @@ class WorkspaceInviteServiceTest : it("external workspace에는 초대를 생성할 수 없다") { val workspaceId = UUID.randomUUID() - every { workspaceRepository.findById(workspaceId) } returns Optional.of( - io.deck.iam.domain.WorkspaceEntity( - name = "AIP Workspace", - id = workspaceId, - externalReference = ExternalReference("aip-org-1"), - ), - ) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") shouldThrow { workspaceInviteService.invite(workspaceId, "invite@example.com", null, UUID.randomUUID()) @@ -203,10 +230,9 @@ class WorkspaceInviteServiceTest : 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) @@ -227,10 +253,9 @@ class WorkspaceInviteServiceTest : 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) @@ -301,7 +326,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 @@ -313,7 +345,7 @@ class WorkspaceInviteServiceTest : name = "새 사용자", email = "new@example.com", roleIds = emptySet(), - createdBy = UserId(workspaceId), + createdBy = UserId(invitedBy), contactProfile = null, ) } returns createdUser @@ -332,7 +364,56 @@ 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, + updatedBy = 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, ) } @@ -344,13 +425,8 @@ class WorkspaceInviteServiceTest : val workspaceId = UUID.randomUUID() val invite = createInvite(workspaceId = workspaceId, email = "join@example.com", tokenHash = tokenHash) every { inviteRepository.findByTokenHash(tokenHash) } returns invite - every { workspaceRepository.findById(workspaceId) } returns Optional.of( - io.deck.iam.domain.WorkspaceEntity( - name = "AIP Workspace", - id = workspaceId, - externalReference = ExternalReference("aip-org-1"), - ), - ) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") shouldThrow { workspaceInviteService.accept(token) @@ -423,13 +499,8 @@ class WorkspaceInviteServiceTest : val inviteId = UUID.randomUUID() val invite = createInvite(workspaceId = workspaceId) every { inviteRepository.findById(inviteId) } returns Optional.of(invite) - every { workspaceRepository.findById(workspaceId) } returns Optional.of( - io.deck.iam.domain.WorkspaceEntity( - name = "AIP Workspace", - id = workspaceId, - externalReference = ExternalReference("aip-org-1"), - ), - ) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") shouldThrow { workspaceInviteService.cancel(inviteId, workspaceId) @@ -444,10 +515,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 = "재발송자")) @@ -475,10 +545,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 { @@ -503,10 +572,9 @@ 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() } @@ -524,13 +592,8 @@ class WorkspaceInviteServiceTest : val inviteId = UUID.randomUUID() val invite = createInvite(workspaceId = workspaceId, email = "resend@example.com") every { inviteRepository.findById(inviteId) } returns Optional.of(invite) - every { workspaceRepository.findById(workspaceId) } returns Optional.of( - io.deck.iam.domain.WorkspaceEntity( - name = "AIP Workspace", - id = workspaceId, - externalReference = ExternalReference("aip-org-1"), - ), - ) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") shouldThrow { workspaceInviteService.resend(inviteId, UUID.randomUUID(), workspaceId) 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 5256fb41a..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 @@ -2,14 +2,12 @@ package io.deck.iam.service import io.deck.common.exception.BadRequestException import io.deck.common.exception.NotFoundException -import io.deck.iam.domain.ExternalReference import io.deck.iam.domain.WorkspaceEntity import io.deck.iam.domain.WorkspaceMemberEntity 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 @@ -20,24 +18,23 @@ 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 { workspaceRepository.findById(any()) } answers { - Optional.of(WorkspaceEntity(name = "워크스페이스", id = firstArg())) + every { workspaceService.findMutableById(any()) } answers { + WorkspaceEntity(name = "워크스페이스", id = firstArg()) } } @@ -135,7 +132,7 @@ class WorkspaceMemberServiceTest : val userId = UUID.randomUUID() val addedBy = UUID.randomUUID() val memberSlot = io.mockk.slot() - every { workspaceRepository.findById(workspaceId) } returns Optional.of(WorkspaceEntity(name = "워크스페이스", id = workspaceId)) + every { workspaceService.findMutableById(workspaceId) } returns WorkspaceEntity(name = "워크스페이스", id = workspaceId) every { memberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId) } returns false every { memberRepository.save(capture(memberSlot)) } answers { firstArg() } @@ -173,13 +170,8 @@ class WorkspaceMemberServiceTest : it("external workspace에는 멤버를 추가할 수 없다") { val workspaceId = UUID.randomUUID() val userId = UUID.randomUUID() - every { workspaceRepository.findById(workspaceId) } returns Optional.of( - WorkspaceEntity( - name = "AIP Workspace", - id = workspaceId, - externalReference = ExternalReference("aip-org-1"), - ), - ) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") shouldThrow { workspaceMemberService.addMember(workspaceId, userId, UUID.randomUUID()) @@ -193,7 +185,7 @@ class WorkspaceMemberServiceTest : val userId = UUID.randomUUID() val removedBy = UUID.randomUUID() val member = WorkspaceMemberEntity(workspaceId = workspaceId, userId = userId) - every { workspaceRepository.findById(workspaceId) } returns Optional.of(WorkspaceEntity(name = "워크스페이스", id = workspaceId)) + 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 @@ -226,13 +218,8 @@ class WorkspaceMemberServiceTest : it("external workspace의 멤버는 제거할 수 없다") { val workspaceId = UUID.randomUUID() val userId = UUID.randomUUID() - every { workspaceRepository.findById(workspaceId) } returns Optional.of( - WorkspaceEntity( - name = "AIP Workspace", - id = workspaceId, - externalReference = ExternalReference("aip-org-1"), - ), - ) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") shouldThrow { workspaceMemberService.removeMember(workspaceId, userId, UUID.randomUUID()) @@ -247,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 @@ -262,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 @@ -278,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 @@ -292,7 +279,7 @@ 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) @@ -302,13 +289,8 @@ class WorkspaceMemberServiceTest : it("external workspace는 탈퇴할 수 없다") { val workspaceId = UUID.randomUUID() val userId = UUID.randomUUID() - every { workspaceRepository.findById(workspaceId) } returns Optional.of( - WorkspaceEntity( - name = "AIP Workspace", - id = workspaceId, - externalReference = ExternalReference("aip-org-1"), - ), - ) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") shouldThrow { workspaceMemberService.leave(workspaceId, userId) @@ -455,13 +437,8 @@ class WorkspaceMemberServiceTest : it("external workspace는 owner를 교체할 수 없다") { val workspaceId = UUID.randomUUID() - every { workspaceRepository.findById(workspaceId) } returns Optional.of( - WorkspaceEntity( - name = "AIP Workspace", - id = workspaceId, - externalReference = ExternalReference("aip-org-1"), - ), - ) + every { workspaceService.findMutableById(workspaceId) } throws + BadRequestException(messageCode = "iam.workspace.external_locked") shouldThrow { workspaceMemberService.replaceOwners(workspaceId, listOf(UUID.randomUUID())) 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..832552aa0 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.createForUserIfEnabled를 위임한다") { val userId = UUID.randomUUID() command.createPersonalWorkspace( @@ -21,11 +20,10 @@ class WorkspaceProvisioningCommandImplTest : ) verify(exactly = 1) { - workspaceService.create( + workspaceService.createForUserIfEnabled( 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 6c85131ca..dea285c7c 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,10 +2,10 @@ package io.deck.iam.service import io.deck.common.exception.BadRequestException import io.deck.common.exception.NotFoundException +import io.deck.iam.ManagementType import io.deck.iam.domain.ExternalReference 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 @@ -79,8 +79,8 @@ 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.PLATFORM_MANAGED) + val userManaged = WorkspaceEntity(name = "A", managedType = ManagementType.USER_MANAGED) + val ownerManaged = WorkspaceEntity(name = "B", managedType = ManagementType.PLATFORM_MANAGED) every { platformSettingService.getSettings() } returns PlatformSettingEntity( @@ -92,14 +92,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 "설명" @@ -130,11 +130,10 @@ class WorkspaceServiceTest : every { eventPublisher.publishEvent(any()) } just Runs val workspace = - workspaceService.create( + workspaceService.createPlatformManaged( name = "새 워크스페이스", description = null, initialOwnerId = initialOwnerId, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, autoJoinDomains = listOf(" ACME.COM ", "acme.com", "dev.acme.com"), ) @@ -146,11 +145,10 @@ class WorkspaceServiceTest : val exception = shouldThrow { - workspaceService.create( + workspaceService.createPlatformManaged( name = "새 워크스페이스", description = null, initialOwnerId = initialOwnerId, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, autoJoinDomains = listOf("invalid domain"), ) } @@ -213,7 +211,7 @@ class WorkspaceServiceTest : WorkspaceEntity( name = "AIP Workspace", id = workspaceId, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, + managedType = ManagementType.PLATFORM_MANAGED, externalReference = ExternalReference("aip-org-1"), ) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) @@ -224,15 +222,21 @@ class WorkspaceServiceTest : } } - 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 "새 설명" @@ -246,15 +250,26 @@ class WorkspaceServiceTest : WorkspaceEntity( name = "AIP Workspace", id = workspaceId, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, + managedType = ManagementType.PLATFORM_MANAGED, externalReference = ExternalReference("aip-org-1"), ) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) shouldThrow { - workspaceService.updateByAdmin(workspaceId, "변경", null, updatedBy) + 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") { @@ -278,14 +293,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( @@ -305,7 +320,7 @@ class WorkspaceServiceTest : WorkspaceEntity( name = "AIP Workspace", id = workspaceId, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, + managedType = ManagementType.PLATFORM_MANAGED, externalReference = ExternalReference("aip-org-1"), ) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) @@ -322,15 +337,26 @@ class WorkspaceServiceTest : WorkspaceEntity( name = "AIP Workspace", id = workspaceId, - managedType = WorkspaceManagedType.PLATFORM_MANAGED, + managedType = ManagementType.PLATFORM_MANAGED, externalReference = ExternalReference("aip-org-1"), ) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) shouldThrow { - workspaceService.deleteByAdmin(workspaceId, deletedBy) + 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/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 index 00aa40717..3f8303858 100644 --- 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 @@ -42,6 +42,8 @@ completed: - 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: service scope query key는 대화 스펙에 맞춰 `workspace_id`를 canonical로 고정했고, app shell/runtime helper는 deep link 호환을 위해 legacy `workspaceId`도 fallback으로 읽도록 정리했다. route canonicalization과 workspace context resolution 책임은 분리했고, 관련 Vitest(`scope`, `route-descriptor`, `tabs`, `Sidebar`) 63개는 green이다. ## Target Contract @@ -62,12 +64,16 @@ completed: - `Roles` - `Menus` - `Workspace`는 settings 그룹으로 승격하지 않는다. +- shared router는 pathname canonicalization만 담당한다. +- app shell access/sidebar/tabs는 FE workspace context resolver로 `workspace_id`를 읽는다. +- service page/API는 같은 query 우선순위 계약을 각 레이어에서 구현한다. - 공용 `ManagementType` - `USER_MANAGED` - `PLATFORM_MANAGED` - `PLATFORM_MANAGED` menu는 runtime에서 `Platform Admin`만 보고, 메뉴 관리 화면에서는 조회/수정 가능하다. - `Workspace.externalReference.externalId`는 AIP의 실제 조직 ID다. -- `externalReference != null`인 workspace는 external workspace이며, identity/membership은 Deck에서 직접 수정하지 않는다. +- `externalReference != null`인 workspace는 external workspace이며 반드시 `PLATFORM_MANAGED`다. +- external workspace의 identity/membership/invite는 Deck UI와 service 경계에서 직접 수정하지 않는다. - `Workspace.autoJoinDomains`는 empty list면 off로 해석한다. ## File Map @@ -93,9 +99,10 @@ completed: | 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`와 external sync 규칙으로 대체 | -| 21 | `backend/iam/src/main/kotlin/io/deck/iam/service/AuthService.kt` | OAuth 로그인 후 workspace sync 진입점과 연결 | -| 22 | `backend/iam/src/main/kotlin/io/deck/iam/security/OAuth2AuthenticationSuccessHandler.kt` | 실제 AIP 로그인 콜백 경로에서 external workspace sync 트리거 | +| 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 반영 | @@ -163,7 +170,7 @@ completed: 3. `allowedDomains`는 이번 slice에서 **`autoJoinDomains`로 hard rename**한다. compatibility alias는 두지 않는다. 4. `useSystemManaged`는 **`usePlatformManaged`로 hard rename**한다. external workspace는 `PLATFORM_MANAGED`로 해석하고 selector/policy 필터를 그대로 탄다. 아직 릴리즈 전이므로 V1 안에서 DB table도 `platform_settings`로 정리한다. 5. external workspace lock은 엔티티가 아니라 service/use-case 경계에서 강제한다. -6. AIP sync는 `OAuth2AuthenticationSuccessHandler -> AuthService -> UserService` 실제 로그인 플로우에서 시작한다. +6. AIP sync는 `OAuth2AuthenticationSuccessHandler -> AuthService` 실제 로그인 플로우에서 시작한다. `UserService`는 platform policy와 approval event를 조합하고, 실제 external reconciliation은 `ExternalWorkspaceSyncService`가 담당한다. ## Chunk 1: Console Route Reset @@ -589,12 +596,14 @@ completed: - `users/logs/templates/notification-management/notification-channels-refresh` Playwright 묶음 PASS (`28 passed`) 9. reference docs - 핵심 reference 문서는 갱신 PASS - - `docs/reference` 전체 legacy grep cleanup PASS -10. full verification + - 추가 `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 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 index ad6bedce4..1ef9f2e47 100644 --- a/docs/plans/2026-04-03-platform-reset-and-workspace-scope.md +++ b/docs/plans/2026-04-03-platform-reset-and-workspace-scope.md @@ -11,7 +11,7 @@ completed: **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로 제어한다. 메뉴와 워크스페이스는 공용 `ManagementType(USER_MANAGED | PLATFORM_MANAGED)`를 사용하고, 외부(AIP) 연계는 `Workspace.externalReference.externalId` 기반 foundation만 먼저 만든다. +**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는 같은 query 우선순위 계약을 각 레이어에서 해석한다. 메뉴와 워크스페이스는 공용 `ManagementType(USER_MANAGED | PLATFORM_MANAGED)`를 사용하고, 외부(AIP) 연계는 `Workspace.externalReference.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 @@ -80,6 +80,9 @@ completed: - 공통 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는 같은 query 우선순위 계약을 각 레이어에서 구현한다. - `deskpie`는 workspace-aware - `meetpie`는 workspace-free 가능 @@ -93,32 +96,33 @@ completed: - `Workspace.externalReference: ExternalReference?` - `ExternalReference.externalId` - `externalReference != null` 이면 external -- external workspace는 사실상 `PLATFORM_MANAGED` -- Deck에서 identity/membership/invite 수정 불가 +- 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` | `/console/*`, `/settings/platform/*` canonicalization | +| 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/account/setting/workspace-tab.tsx` | `Workspace Policy`를 `Platform Settings` 하위 leaf로 유지 | -| FE entities | `frontend/app/src/entities/system-settings/*` | `platform-settings`로 rename 및 타입/경로 정리 | +| 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/SystemSettingEntity.kt` | `PlatformSettingEntity`로 rename, workspace policy 계속 소유 | -| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt` | `PlatformSettingService`로 rename | -| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt` | `PlatformSettingController`로 rename | -| BE settings | `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingAuthProviderController.kt` | auth provider settings rename | +| 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 수정 잠금, AIP sync foundation | +| 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` | pre-release 기준 canonical rename + workspace external columns | @@ -165,19 +169,19 @@ completed: ### Task 3: backend settings aggregate/controller/service rename **Files:** -- Modify: `backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt` -- Modify: `backend/iam/src/main/kotlin/io/deck/iam/repository/SystemSettingRepository.kt` -- Modify: `backend/iam/src/main/kotlin/io/deck/iam/service/SystemSettingService.kt` -- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingController.kt` -- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingAuthProviderController.kt` -- Modify: `backend/iam/src/main/kotlin/io/deck/iam/controller/SystemSettingDtos.kt` -- Test: `backend/iam/src/test/kotlin/io/deck/iam/controller/SystemSettingControllerTest.kt` -- Test: `backend/iam/src/test/kotlin/io/deck/iam/service/SystemSettingServiceTest.kt` +- 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.SystemSettingControllerTest" --tests "io.deck.iam.service.SystemSettingServiceTest"` + - 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가 유지 @@ -187,20 +191,20 @@ completed: ### Task 4: frontend system-settings를 platform-settings로 rename **Files:** -- Modify: `frontend/app/src/entities/system-settings/api.ts` -- Modify: `frontend/app/src/entities/system-settings/types.ts` -- Modify: `frontend/app/src/entities/system-settings/store.ts` +- 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/account/setting/workspace-tab.tsx` -- Test: `frontend/app/src/entities/system-settings/api.test.ts` +- 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/system-settings/api.test.ts src/pages/settings/settings.page.test.tsx` + - 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: 커밋 @@ -210,7 +214,7 @@ completed: ### Task 5: backend 공용 `ManagementType` 도입 **Files:** -- Create: `backend/iam/src/main/kotlin/io/deck/iam/domain/ManagementType.kt` +- 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` @@ -230,7 +234,7 @@ completed: ### Task 6: frontend도 공용 `ManagementType`로 통일 **Files:** -- Modify: `frontend/app/src/entities/system-settings/types.ts` +- 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` @@ -328,9 +332,9 @@ completed: - 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.controller.SystemSettingControllerTest" --tests "io.deck.iam.service.SystemSettingServiceTest" --tests "io.deck.iam.service.WorkspaceServiceTest" --tests "io.deck.iam.service.UserServiceTest" --tests "io.deck.iam.controller.MenuControllerTest" --tests "io.deck.iam.service.ProgramRegistryTest" :app:test --tests "io.deck.app.migration.AppMigrationMenuPermissionsTest"` + - 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/pages/settings/settings.page.test.tsx src/widgets/sidebar/Sidebar.test.tsx src/entities/system-settings/api.test.ts src/entities/workspace/store.test.ts` + - 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 문서에 체크 diff --git a/docs/reference/frontend/router.md b/docs/reference/frontend/router.md index d65b9a632..5eb643d7e 100644 --- a/docs/reference/frontend/router.md +++ b/docs/reference/frontend/router.md @@ -14,12 +14,22 @@ - `/i/:token` - `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`가 page/menu visibility 계약을 공용으로 계산하고, sidebar visibility와 tab restore/open도 같은 계약을 재사용한다. +- service page/API는 같은 query 우선순위 계약을 따르되, FE resolver 자체를 공유하는 것은 아니다. +- migration 호환을 위해 runtime은 legacy `workspaceId` query도 fallback으로 읽지만, 신규 링크와 문서는 `workspace_id`를 canonical로 사용한다. ## 구성 요소 | 모듈 | 위치 | 역할 | | --- | --- | --- | | `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 | | console page shell | `@/layouts` | canonical 이름은 `ConsoleLayout`이고, 기존 `SystemLayout`은 compatibility alias다 | ## 사용법 @@ -70,6 +80,7 @@ export function ExamplePage() { - `?standalone=true`로 직접 진입한 보호 페이지도 AppShell bootstrap 이후 같은 접근 판정을 사용한다. - `page-registry.ts`는 canonical path와 detail route를 pure하게 해석하고 lazy loader만 반환한다. - 권한/워크스페이스 정책 기반 접근 판단은 `page-access.ts`에서 수행한다. +- `page-access.ts`는 workspace scope resolver를 통해 query context를 읽는다. - detail route 접근 여부는 canonical path 기준으로 평가한다. - 예: `/console/workspaces/ws-1` → `/console/workspaces/` @@ -78,6 +89,7 @@ export function ExamplePage() { - settings leaf는 `/settings/account/*`, `/settings/platform/*`만 canonical path로 사용한다. - legacy `/account/setting`은 호환 redirect일 뿐이고 신규 링크에서는 사용하지 않는다. - `Workspace`는 공통 settings 그룹이 아니다. service가 필요할 때만 `workspace_id` query param으로 scope를 확장한다. +- 이 query param은 canonical route를 바꾸지 않는다. settings/router shell은 pathname만 정규화하고, app shell access/sidebar/tabs는 FE resolver를 사용하며 service page/API는 같은 query 우선순위 계약을 각 레이어에서 구현한다. ## API diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md new file mode 100644 index 000000000..269181159 --- /dev/null +++ b/docs/reference/glossary.md @@ -0,0 +1,97 @@ +# Deck 용어집 + +## 목적 + +platform reset 이후 문서와 코드가 같은 단어를 같은 의미로 사용하도록 공통 용어를 정의한다. + +## 핵심 용어 + +### Platform + +- Deck control-plane 전체를 뜻한다. +- 예전 `System` 용어를 대체한다. +- 전역 설정, 권한, 메뉴, workspace 정책은 모두 platform 범위다. + +### Console + +- 보호된 운영/업무 화면 shell이다. +- canonical route prefix는 `/console/*`이다. +- dashboard, users, workspaces, service pages는 console 아래에 둔다. + +### Settings + +- 보호된 설정 화면 shell이다. +- canonical route prefix는 `/settings/*`이다. +- 공통 설정 축은 `account`, `platform` 두 가지다. +- `Workspace`는 공통 settings 그룹이 아니다. + +### 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는 같은 query 우선순위 계약을 따르되, 각 레이어에서 자체적으로 `workspace_id`를 읽는다. +- 한 사용자는 여러 workspace에 동시에 속할 수 있다. + +### External Workspace + +- `externalReference != null`인 workspace다. +- 현재는 AIP 외부 조직과 1:1로 매핑한다. +- `externalReference != null`이면 `PLATFORM_MANAGED`여야 한다. +- Deck UI와 service 경계 모두에서 identity, membership, invite mutation을 직접 허용하지 않는다. + +### External Reference + +- 외부 시스템과의 매핑 식별자다. +- 현재 최소 shape는 `externalId` 하나다. +- `externalId`는 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 workspace sync enablement를 제어한다. +- `usePlatformManaged = false`이면 `useExternalSync = false`로 normalize하는 것이 현재 v1 규칙이다. + +### AIP Sync + +- OAuth 로그인 이후 JWT의 외부 조직 정보를 기준으로 workspace와 membership을 동기화하는 흐름이다. +- 같은 `externalId`는 같은 external workspace에 매핑한다. +- 한 사용자가 여러 외부 조직에 속하면 여러 workspace membership을 동시에 가진다. +- 현재 v1은 AIP를 유일한 external source로 가정한다. +- OAuth extractor와 auth 경계는 `ExternalOrganizationSync.NoSync`, `Unavailable`, `AuthoritativeSnapshot`을 구분한다. +- `AuthoritativeSnapshot`일 때만 `ExternalWorkspaceSyncService`가 reconciliation을 수행한다. +- 따라서 이번 로그인 claim이 authoritative snapshot으로 전달된 경우에만, claim에 없는 external organization membership이 제거 대상이다. diff --git a/docs/reference/workspace.md b/docs/reference/workspace.md index 2ab6adc65..6ce096e3b 100644 --- a/docs/reference/workspace.md +++ b/docs/reference/workspace.md @@ -3,6 +3,7 @@ ## 목적 workspace 모듈(app control-plane) 개발 시 FE/BE 공통 기준을 정의한다. FE 공통 규칙은 [`frontend.md`](./frontend.md), BE 공통 규칙은 [`backend.md`](./backend.md)를 추가로 따른다. +공통 용어는 [`glossary.md`](./glossary.md)를 우선 참고한다. ## Scope @@ -20,8 +21,8 @@ workspace 모듈(app control-plane) 개발 시 FE/BE 공통 기준을 정의한 - workspace는 항상 owner를 최소 1명 유지해야 한다. - workspace 관리 타입은 `USER_MANAGED`, `PLATFORM_MANAGED` 두 가지다. - `Workspace.externalReference?.externalId`는 AIP의 실제 조직 ID다. -- `externalReference != null`인 workspace는 external workspace이며 사실상 `PLATFORM_MANAGED`로 취급한다. -- external workspace의 identity와 membership은 Deck UI에서 직접 수정하지 않는다. +- `externalReference != null`인 workspace는 external workspace이며 반드시 `PLATFORM_MANAGED`여야 한다. +- external workspace의 identity와 membership은 Deck UI와 service 경계에서 직접 수정하지 않는다. - `PlatformSettingEntity.workspacePolicy == null`이면 workspace 기능 전체를 끈 것으로 해석한다. ## 아키텍처 개요 @@ -34,10 +35,16 @@ PlatformSettingController WorkspaceController MyWorkspace ↓ ↓ ↓ WorkspaceService ← WorkspaceMemberService ← WorkspaceInviteService ↓ - WorkspaceEntity ← WorkspaceMemberEntity + WorkspaceEntity ← WorkspaceMemberEntity [OAuth Login] -OAuth2AuthenticationSuccessHandler → AuthService → UserService → Workspace sync by externalId +OAuth2AuthenticationSuccessHandler → AuthService → 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 ``` ## Workspace Policy Contract @@ -46,9 +53,14 @@ OAuth2AuthenticationSuccessHandler → AuthService → UserService → Workspace - backend는 `PlatformSettingEntity.workspacePolicy`를 SSOT로 사용한다. - `workspacePolicy == null`이면 workspace 기능 전체를 비활성화한다. -- program metadata는 `ProgramDefinition.WorkspacePolicy(required, managementType)`로 선언한다. +- program metadata는 `ProgramDefinition.WorkspacePolicy(required, requiredManagedType)`로 선언하고, frontend 계약도 같은 `requiredManagedType` 필드명을 사용한다. - workspace가 필요한 page/program은 정책상 비활성화되면 메뉴에서 숨기고 직접 URL 접근도 `404`로 처리한다. - service는 필요할 때만 `workspace_id` query param으로 active workspace를 요구한다. +- 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` 공용 계약을 재사용한다. +- service page/API는 같은 query 우선순위 계약을 따르되, 각 레이어에서 `workspace_id`를 읽는다. +- migration 호환을 위해 runtime helper는 legacy `workspaceId` query도 fallback으로 읽지만, 신규 링크와 문서는 `workspace_id`를 canonical로 사용한다. ### MUST NOT @@ -91,6 +103,7 @@ OAuth2AuthenticationSuccessHandler → AuthService → UserService → 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가 하나라도 있으면 작업을 차단한다. @@ -123,6 +136,7 @@ OAuth2AuthenticationSuccessHandler → AuthService → UserService → Workspace - invite 수락 시 기존 사용자는 membership만 추가하고, 비회원은 계정 생성 후 membership을 추가한다. - 비회원 invite로 생성되는 사용자는 명시 role이 없으면 현재 default role을 부여받는다. - role은 정확히 1개의 default를 유지해야 한다. +- invite validation은 missing workspace, external workspace, 이미 멤버인 상태를 accept 전에 invalid로 확정해야 한다. ### MUST NOT @@ -135,9 +149,15 @@ OAuth2AuthenticationSuccessHandler → AuthService → UserService → Workspace ### MUST - OAuth 로그인 성공 후 JWT의 외부 조직 목록을 읽어 workspace sync를 수행한다. +- sync 진입점은 `AuthService`다. +- `UserService`는 platform policy와 approval event를 조합하고, 실제 reconciliation은 `ExternalWorkspaceSyncService`가 담당한다. +- auth 경계는 `ExternalOrganizationSync.NoSync`, `Unavailable`, `AuthoritativeSnapshot`을 구분한다. - `externalId`가 같은 조직은 기존 workspace를 재사용한다. - 매칭되는 workspace가 없으면 생성한다. - 같은 사용자가 여러 외부 조직에 속할 수 있으므로 여러 workspace membership을 동시에 가질 수 있어야 한다. +- `AuthoritativeSnapshot`일 때만 AIP claim 목록을 authoritative full snapshot으로 해석한다. +- 따라서 이번 로그인 claim이 authoritative snapshot으로 전달된 경우에만 없는 external workspace membership이 제거 대상이다. +- external workspace identity와 membership은 sync service만 authoritative하게 바꾼다. ### MUST NOT @@ -147,12 +167,16 @@ OAuth2AuthenticationSuccessHandler → AuthService → UserService → Workspace ### MUST -- `store.ts`의 visible workspace 목록은 `workspacePolicy`와 `managementType`으로 필터링한다. +- `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_id` query param을 읽는다. +- 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 @@ -164,14 +188,14 @@ OAuth2AuthenticationSuccessHandler → AuthService → UserService → Workspace - deskpie처럼 workspace가 전제인 program은 `workspace.required = true`를 선언한다. - meetpie program은 기본적으로 `workspace.required = false`로 유지한다. -- `managementType`이 필요한 page만 `USER_MANAGED` 또는 `PLATFORM_MANAGED`를 명시한다. -- 일반 business page는 `required = true`, `managementType = null`로 두고 현재 활성 workspace만 요구한다. +- `requiredManagedType`이 필요한 page만 `USER_MANAGED` 또는 `PLATFORM_MANAGED`를 명시한다. +- 일반 business page는 `required = true`, `requiredManagedType = null`로 두고 현재 활성 workspace만 요구한다. ### 예시 -- `/console/workspaces`: `required = true`, `managementType = PLATFORM_MANAGED` -- `/console/my-workspaces`: `required = true`, `managementType = null` -- deskpie business pages: `required = true`, `managementType = null` +- `/console/workspaces`: `required = true`, `requiredManagedType = PLATFORM_MANAGED` +- `/console/my-workspaces`: `required = true`, `requiredManagedType = null` +- deskpie business pages: `required = true`, `requiredManagedType = null` - meetpie business pages: 기본적으로 `required = false` ## API 클라이언트 Contract diff --git a/frontend/app/src/app/App.tsx b/frontend/app/src/app/App.tsx index 396a65c42..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')); @@ -113,16 +114,21 @@ function AppShellRoute() { const isStandaloneMode = searchParams.get('standalone') === 'true'; if (isStandaloneMode) { - return ; + return ; } return ; } -function LegacyDashboardRedirect() { +function LegacyPathRedirect() { const location = useLocation(); - return ; + return ( + + ); } function useAppShellBootstrap(enabled: boolean) { @@ -133,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(); @@ -144,7 +150,7 @@ function StandalonePageRoute({ pathname }: { pathname: string }) { return ; } - const Page = getAccessiblePage(pathname); + const Page = getAccessiblePage(url); return ( }> @@ -165,15 +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: '/dashboard', element: }, - { path: '/dashboard/*', element: }, - { path: '/my-workspaces', element: }, - { path: '/my-workspaces/*', element: }, - { path: '/console/menus', element: }, - { path: '/console/menus/*', element: }, - { path: '/account/profile/*', element: }, - { path: '/account/setting/*', element: }, + { 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 e40be93b7..85fe6e76a 100644 --- a/frontend/app/src/app/app.test.tsx +++ b/frontend/app/src/app/app.test.tsx @@ -338,6 +338,65 @@ describe('App overlay dismiss 통합', () => { 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'); @@ -354,6 +413,49 @@ describe('App overlay dismiss 통합', () => { ); }); + 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(); @@ -509,7 +611,7 @@ describe('App overlay dismiss 통합', () => { permissions: ['PROTECTED_READ'], workspace: { required: true, - managedType: null, + requiredManagedType: null, }, }, ]); @@ -520,6 +622,7 @@ describe('App overlay dismiss 통합', () => { workspacePolicy: { useUserManaged: true, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, }); diff --git a/frontend/app/src/app/auth.test.ts b/frontend/app/src/app/auth.test.ts index 0a79100b9..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: { diff --git a/frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx b/frontend/app/src/app/command-palette/CommandPaletteProvider.test.tsx index 67c234f00..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: () =>
, @@ -146,7 +151,7 @@ describe('CommandPaletteProvider', () => { }); }); - it('owner가 아니면 ownerOnly settings command는 등록되지 않아야 함', async () => { + it('platform admin이 아니면 platformAdminOnly settings command는 등록되지 않아야 함', async () => { user.set(memberUser); render( diff --git a/frontend/app/src/app/command-palette/CommandPaletteProvider.tsx b/frontend/app/src/app/command-palette/CommandPaletteProvider.tsx index 58affbb11..22ab418a9 100644 --- a/frontend/app/src/app/command-palette/CommandPaletteProvider.tsx +++ b/frontend/app/src/app/command-palette/CommandPaletteProvider.tsx @@ -99,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/navigation/menu-runtime.ts b/frontend/app/src/app/navigation/menu-runtime.ts new file mode 100644 index 000000000..ed5e77a16 --- /dev/null +++ b/frontend/app/src/app/navigation/menu-runtime.ts @@ -0,0 +1,248 @@ +import { createStore } from '#app/shared/store/create-store'; +import { user } from '#app/app/state'; +import { currentWorkspaceId, getCurrentWorkspaceContextId } from '#app/entities/workspace'; +import { platformSettings, isProgramAccessible } from '#app/entities/platform-settings'; +import { resolveMenuLookupPath } from '#app/shared/router'; +import { getCurrentUrl } from '#app/shared/runtime'; +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; + +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 isMenuProgramAccessible(programCode?: string): boolean { + const program = findProgram(programCode); + if (!program) return !programCode; + + return isProgramAccessible( + program, + platformSettings.get()?.workspacePolicy ?? null, + getCurrentWorkspaceContextId() + ); +} + +function isPlatformAdminUser(): boolean { + return user.get()?.isOwner === true; +} + +function isMenuVisible(apiMenu: ApiMenu): boolean { + if (apiMenu.managementType === 'PLATFORM_MANAGED' && !isPlatformAdminUser()) { + return false; + } + + if (apiMenu.children.length > 0) { + return apiMenu.children.some(isMenuVisible); + } + + return isMenuProgramAccessible(apiMenu.program); +} + +function convertApiMenuToMenuItem(apiMenu: ApiMenu): MenuItem | null { + if (!isMenuVisible(apiMenu)) { + return 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, + }; + } + + return { + id: apiMenu.id, + label: apiMenu.name, + icon: apiMenu.icon, + url, + }; +} + +function syncMenuData() { + const apiMenus = normalizeItems(rawApiMenus.get()); + menuItems.set( + apiMenus.map(convertApiMenuToMenuItem).filter((item): item is MenuItem => item != null) + ); +} + +export function refreshMenuData() { + syncMenuData(); +} + +function findVisibleMenuByPath(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 = findVisibleMenuByPath(item.children, path); + if (match) { + return match; + } + } + } + + return null; +} + +export function resolveActiveMenuIdByPath(path: string): string | null { + return findVisibleMenuByPath(menuItems.get(), 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 findMenuDefinitionByPath(apiMenus: ApiMenu[], path: string): ApiMenu | null { + const normalizedPath = resolveMenuPath(path); + + for (const apiMenu of apiMenus) { + const program = findProgram(apiMenu.program); + if (hasProgramPath(program) && resolveMenuPath(program.path) === normalizedPath) { + return apiMenu; + } + + const child = findMenuDefinitionByPath(apiMenu.children, normalizedPath); + if (child) { + return child; + } + } + + return null; +} + +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 = findMenuDefinitionByPath(rawApiMenus.get(), path); + if (!apiMenu || !isMenuVisible(apiMenu)) { + return null; + } + + return toMenuLookupItem(apiMenu, path); +} + +export function hasMenuDefinitionByPath(path: string): boolean { + return findMenuDefinitionByPath(rawApiMenus.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/page-access.ts b/frontend/app/src/app/page-access.ts index e18f6cfa1..f75f3b557 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 { 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 { getProgramByPath, programs, type Program } from '#app/widgets/sidebar'; +import { + findRawMenuByPath, + 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 = platformSettings.get()?.workspacePolicy ?? null; - const activeWorkspaceId = currentWorkspaceId.get(); + const activeWorkspaceId = resolveWorkspaceContextIdFromUrl(url, currentWorkspaceId.get()); + const menuItem = findRawMenuByPath(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(); + useMenuRuntimeDependencies(); platformSettings.useStore(); currentWorkspaceId.useStore(); } diff --git a/frontend/app/src/app/page-registry.test.ts b/frontend/app/src/app/page-registry.test.ts index 26d2d9513..cc89a309e 100644 --- a/frontend/app/src/app/page-registry.test.ts +++ b/frontend/app/src/app/page-registry.test.ts @@ -3,7 +3,7 @@ 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/platform-settings'; -import { setPrograms } from '#app/widgets/sidebar'; +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,6 +31,7 @@ const session: UserSession = { describe('page-registry workspace access', () => { afterEach(() => { setPrograms([]); + setMenuData([]); setPlatformSettings(null); currentWorkspaceId.set(''); clearSession(); @@ -44,7 +45,7 @@ describe('page-registry workspace access', () => { permissions: ['WORKSPACE_MANAGEMENT_READ'], workspace: { required: true, - managedType: 'PLATFORM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }), ]); @@ -59,6 +60,7 @@ describe('page-registry workspace access', () => { workspacePolicy: { useUserManaged: true, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, } as never); @@ -77,7 +79,7 @@ describe('page-registry workspace access', () => { permissions: ['WORKSPACE_MANAGEMENT_READ'], workspace: { required: true, - managedType: 'PLATFORM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }), ]); @@ -93,6 +95,7 @@ describe('page-registry workspace access', () => { workspacePolicy: { useUserManaged: true, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, } as never); @@ -101,28 +104,53 @@ describe('page-registry workspace access', () => { 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', + requiredManagedType: 'USER_MANAGED', }, }); expect(isPageAccessible('/console/my-workspaces/', program, null, '')).toBe(false); }); - it('managedType이 꺼져 있으면 해당 페이지를 막아야 한다', () => { + it('requiredManagedType이 꺼져 있으면 해당 페이지를 막아야 한다', () => { const program = createProgram({ path: '/console/my-workspaces', workspace: { required: true, - managedType: 'USER_MANAGED', + requiredManagedType: 'USER_MANAGED', }, }); const policy: WorkspacePolicy = { useUserManaged: false, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; @@ -134,12 +162,13 @@ describe('page-registry workspace access', () => { path: '/booking', workspace: { required: true, - managedType: null, + requiredManagedType: null, }, }); const policy: WorkspacePolicy = { useUserManaged: false, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; @@ -151,12 +180,13 @@ describe('page-registry workspace access', () => { path: '/booking', workspace: { required: true, - managedType: null, + requiredManagedType: null, }, }); const policy: WorkspacePolicy = { useUserManaged: false, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; @@ -168,12 +198,13 @@ describe('page-registry workspace access', () => { path: '/console/my-workspaces', workspace: { required: true, - managedType: null, + requiredManagedType: null, }, }); const policy: WorkspacePolicy = { useUserManaged: false, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; @@ -187,12 +218,13 @@ describe('page-registry workspace access', () => { permissions: ['WORKSPACE_MANAGEMENT_READ', 'WORKSPACE_MANAGEMENT_WRITE'], workspace: { required: true, - managedType: 'PLATFORM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }); const policy: WorkspacePolicy = { useUserManaged: false, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; @@ -203,6 +235,7 @@ describe('page-registry workspace access', () => { const policy: WorkspacePolicy = { useUserManaged: true, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; @@ -213,6 +246,7 @@ describe('page-registry workspace access', () => { const policy: WorkspacePolicy = { useUserManaged: true, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }; diff --git a/frontend/app/src/app/sidebar/SidebarWrapper.test.tsx b/frontend/app/src/app/sidebar/SidebarWrapper.test.tsx index a5d282f1a..16f6c98f8 100644 --- a/frontend/app/src/app/sidebar/SidebarWrapper.test.tsx +++ b/frontend/app/src/app/sidebar/SidebarWrapper.test.tsx @@ -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: () =>
, @@ -131,6 +136,7 @@ describe('SidebarWrapper', () => { workspacePolicy: { useUserManaged: true, usePlatformManaged: true, + useExternalSync: true, useSelector: 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 9dac128b6..98c495dff 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(), + 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.findRawMenuByPath).mockReturnValue(null); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(false); }); - afterEach(() => { - clearSession(); + afterEach(async () => { + await clearRuntimeState(); }); async function getSidebarModule() { @@ -111,6 +161,45 @@ describe('tabs', () => { 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/deskpie/contacts/?workspaceId=ws-1', + }, + ]; + + const { openTab } = await import('./tabs'); + openTab( + 'deskpie-contacts', + 'Contacts', + 'ContactRound', + '/console/deskpie/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', () => { @@ -146,10 +235,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('/'); @@ -176,6 +270,7 @@ describe('tabs', () => { describe('URL 동기화', () => { it('탭 활성화 시 URL이 탭의 url로 변경되어야 함', async () => { 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'); @@ -183,6 +278,7 @@ describe('tabs', () => { await vi.waitFor(() => { expect(replaceStateMock).toHaveBeenCalledWith({}, '', '/console/users/'); }); + expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(PopStateEvent)); }); it('탭을 모두 닫으면 URL이 /로 복원되어야 함', async () => { @@ -222,6 +318,11 @@ describe('tabs', () => { 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(); @@ -259,13 +360,14 @@ describe('tabs', () => { writable: true, }); - const sidebar = await getSidebarModule(); - vi.mocked(sidebar.getProgramByPath).mockReturnValue({ + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ code: 'MY_WORKSPACE', path: '/console/my-workspaces', permissions: [], }); - vi.mocked(sidebar.findRawMenuByPath).mockReturnValue({ + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findRawMenuByPath).mockReturnValue({ id: 'my-workspace', label: 'My Workspace', icon: 'Building2', @@ -285,18 +387,23 @@ describe('tabs', () => { }); it('현재 URL query가 있으면 새 탭 복원 시 query를 보존해야 함', async () => { + await seedSession({ + permissions: ['CRM_PIPELINE_MANAGEMENT_READ'], + }); + Object.defineProperty(window, 'location', { value: { ...window.location, pathname: '/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', permissions: ['CRM_PIPELINE_MANAGEMENT_READ'], }); - vi.mocked(sidebar.findRawMenuByPath).mockReturnValue({ + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findRawMenuByPath).mockReturnValue({ id: 'deskpie-pipelines', label: 'Pipelines', icon: 'Workflow', @@ -315,23 +422,61 @@ describe('tabs', () => { 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.findRawMenuByPath).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: '/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: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ'], workspace: { required: true, - managedType: 'PLATFORM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }); - vi.mocked(sidebar.findRawMenuByPath).mockReturnValue({ + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findRawMenuByPath).mockReturnValue({ id: 'workspace-management', label: 'Workspaces', icon: 'Building2', @@ -350,6 +495,129 @@ describe('tabs', () => { 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/deskpie/contacts', + search: '?workspace_id=ws-query', + hash: '', + }, + writable: true, + }); + await setWorkspaceContext(''); + + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ + code: 'DESKPIE_CONTACTS', + path: '/console/deskpie/contacts', + permissions: [], + workspace: { + required: true, + requiredManagedType: null, + }, + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findRawMenuByPath).mockReturnValue({ + id: 'deskpie-contacts', + label: 'Contacts', + icon: 'ContactRound', + url: '/console/deskpie/contacts/', + }); + + const { restoreTabs } = await import('./tabs'); + restoreTabs('/console/deskpie/contacts?workspace_id=ws-query'); + + expect(mockOpenTab).toHaveBeenCalledWith({ + id: 'deskpie-contacts', + title: 'Contacts', + icon: 'ContactRound', + url: '/console/deskpie/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/deskpie/contacts', + search: '', + hash: '', + }, + writable: true, + }); + await setWorkspaceContext(''); + + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ + code: 'DESKPIE_CONTACTS', + path: '/console/deskpie/contacts', + permissions: [], + workspace: { + required: true, + requiredManagedType: null, + }, + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findRawMenuByPath).mockReturnValue({ + id: 'deskpie-contacts', + label: 'Contacts', + icon: 'ContactRound', + url: '/console/deskpie/contacts/', + }); + + const { restoreTabs } = await import('./tabs'); + restoreTabs('/console/deskpie/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/deskpie/contacts/?workspaceId=ws-query', + }, + ], + activeTabId: 'deskpie-contacts', + }) + ); + + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ + code: 'DESKPIE_CONTACTS', + path: '/console/deskpie/contacts', + permissions: [], + workspace: { + required: true, + requiredManagedType: null, + }, + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findRawMenuByPath).mockReturnValue({ + id: 'deskpie-contacts', + label: 'Contacts', + icon: 'ContactRound', + url: '/console/deskpie/contacts/', + }); + await seedPlatformWorkspacePolicy(); + + const { restoreTabs } = await import('./tabs'); + restoreTabs('/console/deskpie/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: '/console/workspaces/' }, @@ -369,17 +637,18 @@ describe('tabs', () => { }) ); - const sidebar = await getSidebarModule(); - vi.mocked(sidebar.getProgramByPath).mockReturnValue({ + await getSidebarModule(); + vi.mocked(menuRuntime.getProgramByPath).mockReturnValue({ code: 'WORKSPACE_MANAGEMENT', path: '/console/workspaces', permissions: ['WORKSPACE_MANAGEMENT_READ', 'WORKSPACE_MANAGEMENT_WRITE'], workspace: { required: true, - managedType: 'PLATFORM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }); - vi.mocked(sidebar.findRawMenuByPath).mockReturnValue(null); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockReturnValue(true); + vi.mocked(menuRuntime.findRawMenuByPath).mockReturnValue(null); const { restoreTabs } = await import('./tabs'); restoreTabs(); @@ -387,6 +656,121 @@ 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.findRawMenuByPath).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/deskpie/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/deskpie/contacts/') { + return { + code: 'DESKPIE_CONTACTS', + path: '/console/deskpie/contacts', + permissions: [], + workspace: { + required: true, + requiredManagedType: null, + }, + }; + } + + return undefined; + }); + vi.mocked(menuRuntime.hasMenuDefinitionByPath).mockImplementation( + (path) => path === '/settings/platform/roles/' || path === '/console/deskpie/contacts/' + ); + vi.mocked(menuRuntime.findRawMenuByPath).mockImplementation((path) => { + if (path === '/console/deskpie/contacts/') { + return { + id: 'deskpie-contacts', + label: 'Contacts', + icon: 'ContactRound', + url: '/console/deskpie/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/deskpie/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/deskpie/contacts/?workspace_id=ws-1', + }, + ]); + expect(stored.activeTabId).toBe('deskpie-contacts'); + }); }); describe('refreshTab', () => { diff --git a/frontend/app/src/app/tabs.ts b/frontend/app/src/app/tabs.ts index b5cf559fc..89764675e 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 { platformSettings } from '#app/entities/platform-settings'; -import { isProgramAccessible } from '#app/entities/platform-settings/workspace-access'; +import { normalizeWorkspaceScopeSearch } from '#app/entities/workspace'; import { tabs, activeTabId, @@ -8,27 +6,26 @@ 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) ); } @@ -41,12 +38,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 +114,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 +131,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 +144,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 +159,16 @@ 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); + const removedSavedTabs = accessibleSavedTabs.length !== savedTabs.length; + + accessibleSavedTabs.forEach((tab) => openTabWidget(tab)); // 현재 URL 경로와 일치하는 탭이 있으면 그 탭을 활성화 const currentUrl = initialUrl; @@ -182,38 +177,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, - platformSettings.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 +211,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/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/menu/types.ts b/frontend/app/src/entities/menu/types.ts index 0ec7b579b..161cf9dee 100644 --- a/frontend/app/src/entities/menu/types.ts +++ b/frontend/app/src/entities/menu/types.ts @@ -4,11 +4,11 @@ * 메뉴 관련 타입 정의 */ -import type { ManagementType, WorkspaceManagedType } from '#app/entities/platform-settings'; +import type { ManagementType } from '#app/entities/platform-settings'; export interface ProgramWorkspacePolicy { required: boolean; - managedType: WorkspaceManagedType | null; + requiredManagedType: ManagementType | null; } export interface Menu { diff --git a/frontend/app/src/entities/platform-settings/api.test.ts b/frontend/app/src/entities/platform-settings/api.test.ts index 8992559bd..24a3eb2f8 100644 --- a/frontend/app/src/entities/platform-settings/api.test.ts +++ b/frontend/app/src/entities/platform-settings/api.test.ts @@ -46,11 +46,21 @@ describe('platformSettingsApi', () => { vi.mocked(http.put).mockResolvedValue(undefined); await platformSettingsApi.updateWorkspacePolicy({ - workspacePolicy: { useUserManaged: true, usePlatformManaged: false, useSelector: true }, + workspacePolicy: { + useUserManaged: true, + usePlatformManaged: false, + useExternalSync: false, + useSelector: true, + }, }); expect(http.put).toHaveBeenCalledWith('/platform-settings/workspace-policy', { - workspacePolicy: { useUserManaged: true, usePlatformManaged: false, useSelector: true }, + workspacePolicy: { + useUserManaged: true, + usePlatformManaged: false, + useExternalSync: false, + useSelector: true, + }, }); }); diff --git a/frontend/app/src/entities/platform-settings/index.ts b/frontend/app/src/entities/platform-settings/index.ts index a2d18a287..0090f06de 100644 --- a/frontend/app/src/entities/platform-settings/index.ts +++ b/frontend/app/src/entities/platform-settings/index.ts @@ -9,7 +9,6 @@ export type { PlatformSettings, AuthSettings, AuthResponse, - WorkspaceManagedType, WorkspacePolicy, CountryPolicy, CurrencyPolicy, diff --git a/frontend/app/src/entities/platform-settings/types.ts b/frontend/app/src/entities/platform-settings/types.ts index 22f43b51b..b91598f76 100644 --- a/frontend/app/src/entities/platform-settings/types.ts +++ b/frontend/app/src/entities/platform-settings/types.ts @@ -8,11 +8,11 @@ export type LogoType = export type OAuthProvider = 'GOOGLE' | 'NAVER' | 'KAKAO' | 'OKTA' | 'MICROSOFT' | 'AIP'; export type ManagementType = 'USER_MANAGED' | 'PLATFORM_MANAGED'; -export type WorkspaceManagedType = ManagementType; export interface WorkspacePolicy { useUserManaged: boolean; usePlatformManaged: boolean; + useExternalSync: boolean; useSelector: boolean; } diff --git a/frontend/app/src/entities/platform-settings/workspace-access.test.ts b/frontend/app/src/entities/platform-settings/workspace-access.test.ts index 37707c60f..21898ee01 100644 --- a/frontend/app/src/entities/platform-settings/workspace-access.test.ts +++ b/frontend/app/src/entities/platform-settings/workspace-access.test.ts @@ -9,10 +9,11 @@ describe('isWorkspaceAccessible', () => { it('platform-managed workspace는 usePlatformManaged 정책을 따라야 한다', () => { expect( isWorkspaceAccessible( - { required: true, managedType: 'PLATFORM_MANAGED' }, + { required: true, requiredManagedType: 'PLATFORM_MANAGED' }, { useUserManaged: true, usePlatformManaged: false, + useExternalSync: false, useSelector: true, }, 'workspace-1' diff --git a/frontend/app/src/entities/platform-settings/workspace-access.ts b/frontend/app/src/entities/platform-settings/workspace-access.ts index ee4e8768e..aa8814524 100644 --- a/frontend/app/src/entities/platform-settings/workspace-access.ts +++ b/frontend/app/src/entities/platform-settings/workspace-access.ts @@ -1,9 +1,9 @@ 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; + requiredManagedType: ManagementType | null; } export interface ProgramAccess { @@ -19,15 +19,15 @@ export function isWorkspaceAccessible( if (!workspace?.required) return true; if (!workspacePolicy) return false; - if (workspace.managedType === 'USER_MANAGED' && !workspacePolicy.useUserManaged) { + if (workspace.requiredManagedType === 'USER_MANAGED' && !workspacePolicy.useUserManaged) { return false; } - if (workspace.managedType === 'PLATFORM_MANAGED' && !workspacePolicy.usePlatformManaged) { + if (workspace.requiredManagedType === 'PLATFORM_MANAGED' && !workspacePolicy.usePlatformManaged) { return false; } - if (workspace.managedType == null && !activeWorkspaceId) { + if (workspace.requiredManagedType == null && !activeWorkspaceId) { return false; } 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..5ef553226 --- /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/deskpie/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/deskpie/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 2770f2ca7..37be7c67d 100644 --- a/frontend/app/src/entities/workspace/store.test.ts +++ b/frontend/app/src/entities/workspace/store.test.ts @@ -52,6 +52,7 @@ describe('workspace store helpers', () => { const policy: WorkspacePolicy = { useUserManaged: true, usePlatformManaged: false, + useExternalSync: false, useSelector: true, }; diff --git a/frontend/app/src/entities/workspace/types.ts b/frontend/app/src/entities/workspace/types.ts index 76fdf8bfa..7f68d44d0 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/platform-settings'; +import type { ManagementType } from '#app/entities/platform-settings'; export interface WorkspaceOwner { id: string; @@ -17,7 +17,7 @@ export interface Workspace { autoJoinDomains?: string[]; owners: WorkspaceOwner[]; memberCount: number; - managedType: WorkspaceManagedType; + managedType: ManagementType; externalReference?: ExternalReference | null; createdAt: string | null; updatedAt: string | null; @@ -30,7 +30,7 @@ export interface MyWorkspace { autoJoinDomains?: string[]; owners: WorkspaceOwner[]; memberCount: number; - managedType: WorkspaceManagedType; + managedType: ManagementType; externalReference?: ExternalReference | null; role: 'OWNER' | 'MEMBER'; createdAt: string | null; @@ -41,14 +41,12 @@ export interface CreateWorkspaceRequest { name: string; description?: string; autoJoinDomains: string[]; - managedType?: WorkspaceManagedType; } export interface UpdateWorkspaceRequest { name: string; description?: string; autoJoinDomains: string[]; - managedType?: WorkspaceManagedType; } export interface WorkspaceMember { diff --git a/frontend/app/src/pages/settings/settings-nav.ts b/frontend/app/src/pages/settings/settings-nav.ts index a6ccf1ab6..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 { @@ -75,7 +75,7 @@ export const settingsNav: SettingsGroup[] = [ titleKey: 'settingsShell.leaves.general.title', subtitleKey: 'settingsShell.leaves.general.subtitle', icon: 'Settings2', - ownerOnly: true, + platformAdminOnly: true, }, { key: 'branding', @@ -84,7 +84,7 @@ export const settingsNav: SettingsGroup[] = [ titleKey: 'settingsShell.leaves.branding.title', subtitleKey: 'settingsShell.leaves.branding.subtitle', icon: 'Palette', - ownerOnly: true, + platformAdminOnly: true, }, { key: 'authentication', @@ -93,7 +93,7 @@ export const settingsNav: SettingsGroup[] = [ titleKey: 'settingsShell.leaves.authentication.title', subtitleKey: 'settingsShell.leaves.authentication.subtitle', icon: 'KeyRound', - ownerOnly: true, + platformAdminOnly: true, }, { key: 'workspace-policy', @@ -102,7 +102,7 @@ export const settingsNav: SettingsGroup[] = [ titleKey: 'settingsShell.leaves.workspacePolicy.title', subtitleKey: 'settingsShell.leaves.workspacePolicy.subtitle', icon: 'Building2', - ownerOnly: true, + platformAdminOnly: true, }, { key: 'roles', @@ -111,7 +111,7 @@ export const settingsNav: SettingsGroup[] = [ titleKey: 'settingsShell.leaves.roles.title', subtitleKey: 'settingsShell.leaves.roles.subtitle', icon: 'UsersRound', - ownerOnly: true, + platformAdminOnly: true, }, { key: 'menus', @@ -120,7 +120,7 @@ export const settingsNav: SettingsGroup[] = [ titleKey: 'settingsShell.leaves.menus.title', subtitleKey: 'settingsShell.leaves.menus.subtitle', icon: 'PanelsTopLeft', - ownerOnly: true, + platformAdminOnly: true, }, ], }, diff --git a/frontend/app/src/pages/settings/tabs/general-tab.test.tsx b/frontend/app/src/pages/settings/tabs/general-tab.test.tsx index 08920f853..0d58c933e 100644 --- a/frontend/app/src/pages/settings/tabs/general-tab.test.tsx +++ b/frontend/app/src/pages/settings/tabs/general-tab.test.tsx @@ -21,6 +21,7 @@ const mockSettings = { workspacePolicy: { useUserManaged: true, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, }; diff --git a/frontend/app/src/pages/settings/tabs/workspace-tab.test.tsx b/frontend/app/src/pages/settings/tabs/workspace-tab.test.tsx index 5be71c228..44f5476a9 100644 --- a/frontend/app/src/pages/settings/tabs/workspace-tab.test.tsx +++ b/frontend/app/src/pages/settings/tabs/workspace-tab.test.tsx @@ -27,6 +27,7 @@ const settings = { workspacePolicy: { useUserManaged: true, usePlatformManaged: false, + useExternalSync: false, useSelector: true, }, }; @@ -45,8 +46,9 @@ describe('WorkspaceTab', () => { expect(screen.getByLabelText('Use user-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,15 +63,24 @@ describe('WorkspaceTab', () => { expect(description.previousElementSibling?.textContent).toBe('Use user-managed'); }); - it('platform-managed 설명은 external AIP sync workspace 의미를 안내해야 한다', () => { + 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( - 'Allow external workspaces that are synced from AIP organization claims.' + '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 platform-managed'); + expect(description.previousElementSibling?.textContent).toBe('Use external sync'); }); it('workspace 제목을 클릭하면 해당 토글이 전환되어야 한다', () => { diff --git a/frontend/app/src/pages/settings/tabs/workspace-tab.tsx b/frontend/app/src/pages/settings/tabs/workspace-tab.tsx index e568ed467..2ece9d1f2 100644 --- a/frontend/app/src/pages/settings/tabs/workspace-tab.tsx +++ b/frontend/app/src/pages/settings/tabs/workspace-tab.tsx @@ -23,10 +23,17 @@ interface WorkspaceTabProps { } function normalizeWorkspacePolicy(policy: WorkspacePolicy): WorkspacePolicy | null { - if (!policy.useUserManaged && !policy.usePlatformManaged) { + 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) { @@ -34,6 +41,7 @@ export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) const [policy, setPolicy] = useState({ useUserManaged: true, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }); const [saving, setSaving] = useState(false); @@ -45,6 +53,7 @@ export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) setPolicy({ useUserManaged: false, usePlatformManaged: false, + useExternalSync: false, useSelector: false, }); } @@ -80,6 +89,11 @@ export function WorkspaceTab({ settings, onSettingsChange }: WorkspaceTabProps) 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, title: t('setting.workspace.useSelector'), @@ -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..afe7a3c97 100644 --- a/frontend/app/src/pages/settings/use-settings-page.ts +++ b/frontend/app/src/pages/settings/use-settings-page.ts @@ -20,6 +20,7 @@ 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); @@ -27,7 +28,7 @@ export function useSettingsPage(pathname: string) { .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, @@ -60,5 +61,5 @@ export function useSettingsPage(pathname: string) { currentLeaf, fallbackPath: fallbackLeaf?.path ?? defaultSettingsPath, }; - }, [currentUser?.isOwner, pathname, translate]); + }, [isPlatformAdmin, pathname, translate]); } 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 5ea46920b..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: { diff --git a/frontend/app/src/shared/auth-redirect.test.ts b/frontend/app/src/shared/auth-redirect.test.ts index 42c2bf612..530d826af 100644 --- a/frontend/app/src/shared/auth-redirect.test.ts +++ b/frontend/app/src/shared/auth-redirect.test.ts @@ -35,4 +35,13 @@ describe('auth redirect helpers', () => { 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 0ac669007..1589d5718 100644 --- a/frontend/app/src/shared/auth-redirect.ts +++ b/frontend/app/src/shared/auth-redirect.ts @@ -1,46 +1,11 @@ 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 = '/console/dashboard'; -function toPathnameSearchHash(url: URL): string { - return `${url.pathname}${url.search}${url.hash}`; -} - -function normalizeLegacyPath(path: string): string { - const url = new URL(path, window.location.origin); - const pathname = url.pathname.replace(/\/$/, '') || '/'; - - if (pathname === '/system' || pathname.startsWith('/system/')) { - url.pathname = pathname.replace(/^\/system/, '/console'); - 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 === '/account/setting' || pathname.startsWith('/account/setting/')) { - url.pathname = '/settings/platform/general'; - return toPathnameSearchHash(url); - } - - return path; -} - function safeDecode(value: string): string { try { return decodeURIComponent(value); @@ -74,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 = normalizeLegacyPath(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/i18n/locales/en/account.json b/frontend/app/src/shared/i18n/locales/en/account.json index 49ed8110e..28e72eebe 100644 --- a/frontend/app/src/shared/i18n/locales/en/account.json +++ b/frontend/app/src/shared/i18n/locales/en/account.json @@ -37,7 +37,9 @@ "useUserManaged": "Use user-managed", "useUserManagedDescription": "Allow workspaces that users create and manage themselves.", "usePlatformManaged": "Use platform-managed", - "usePlatformManagedDescription": "Allow external workspaces that are synced from AIP organization claims.", + "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", diff --git a/frontend/app/src/shared/i18n/locales/ja/account.json b/frontend/app/src/shared/i18n/locales/ja/account.json index 6fe393f68..18c59d05e 100644 --- a/frontend/app/src/shared/i18n/locales/ja/account.json +++ b/frontend/app/src/shared/i18n/locales/ja/account.json @@ -37,7 +37,9 @@ "useUserManaged": "ユーザー管理型を使用", "useUserManagedDescription": "ユーザーが自分で作成・管理する workspace を使用できます。", "usePlatformManaged": "プラットフォーム管理型を使用", - "usePlatformManagedDescription": "AIP の organization claim と同期される external workspace を使用できます。", + "usePlatformManagedDescription": "プラットフォームが所有・管理する workspace を使用できます。", + "useExternalSync": "外部同期を使用", + "useExternalSyncDescription": "AIP の organization claim から external workspace と membership を自動同期します。", "useSelector": "選択 UI を使用", "useSelectorDescription": "表示可能な workspace がある場合にサイドバーへ selector を表示します。", "enabledCountryCodes": "許可する国コード", diff --git a/frontend/app/src/shared/i18n/locales/ko/account.json b/frontend/app/src/shared/i18n/locales/ko/account.json index 7b8c2a6d5..9100abf20 100644 --- a/frontend/app/src/shared/i18n/locales/ko/account.json +++ b/frontend/app/src/shared/i18n/locales/ko/account.json @@ -37,7 +37,9 @@ "useUserManaged": "사용자 관리형 사용", "useUserManagedDescription": "사용자가 직접 생성하고 관리하는 workspace를 허용합니다.", "usePlatformManaged": "플랫폼 관리형 사용", - "usePlatformManagedDescription": "AIP 조직 claim과 동기화되는 external workspace를 허용합니다.", + "usePlatformManagedDescription": "플랫폼이 소유하고 관리하는 workspace를 허용합니다.", + "useExternalSync": "외부 동기화 사용", + "useExternalSyncDescription": "AIP 조직 claim을 받아 external workspace와 membership을 자동 동기화합니다.", "useSelector": "선택 UI 사용", "useSelectorDescription": "보이는 workspace가 있을 때 사이드바에 workspace 선택 UI를 표시합니다.", "enabledCountryCodes": "허용 국가 코드", diff --git a/frontend/app/src/shared/router/index.ts b/frontend/app/src/shared/router/index.ts index 299ae6549..582b3d21c 100644 --- a/frontend/app/src/shared/router/index.ts +++ b/frontend/app/src/shared/router/index.ts @@ -2,7 +2,11 @@ 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 { + 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..22780bec7 --- /dev/null +++ b/frontend/app/src/shared/router/legacy-path.test.ts @@ -0,0 +1,34 @@ +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 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..a9f3c9e49 --- /dev/null +++ b/frontend/app/src/shared/router/legacy-path.ts @@ -0,0 +1,99 @@ +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 === '/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 2cc1721b2..6748872a7 100644 --- a/frontend/app/src/shared/router/route-descriptor.test.ts +++ b/frontend/app/src/shared/router/route-descriptor.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { resolveRouteDescriptor } from './route-descriptor'; +import { + resolveMenuLookupPath, + resolveRouteDescriptor, + resolveTabMatchPath, +} from './route-descriptor'; describe('route-descriptor', () => { it('console workspace detail path를 canonical path로 해석해야 함', () => { @@ -22,7 +26,7 @@ describe('route-descriptor', () => { it('settings leaf path는 자기 자신의 path를 유지해야 함', () => { expect(resolveRouteDescriptor('/settings/platform/general?foo=bar#section')).toEqual({ - actualPath: '/settings/platform/general?foo=bar#section', + actualPath: '/settings/platform/general/?foo=bar#section', canonicalPath: '/settings/platform/general', kind: 'regular', }); @@ -36,6 +40,14 @@ describe('route-descriptor', () => { }); }); + it('legacy system path는 console canonical path로 정규화해야 함', () => { + expect(resolveRouteDescriptor('/system/users?page=2#roles')).toEqual({ + actualPath: '/system/users/?page=2#roles', + canonicalPath: '/console/users/', + kind: 'regular', + }); + }); + it('workspace detail path에서도 hash를 보존해야 함', () => { expect(resolveRouteDescriptor('/console/workspaces/ws-1?tab=security#members')).toEqual({ actualPath: '/console/workspaces/ws-1?tab=security#members', @@ -44,4 +56,28 @@ describe('route-descriptor', () => { workspaceId: 'ws-1', }); }); + + it('workspace_id query param은 global router가 해석하지 않고 그대로 보존해야 함', () => { + expect(resolveRouteDescriptor('/console/deskpie/companies?workspace_id=ws-1')).toEqual({ + actualPath: '/console/deskpie/companies/?workspace_id=ws-1', + canonicalPath: '/console/deskpie/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 ed9e9171e..426790397 100644 --- a/frontend/app/src/shared/router/route-descriptor.ts +++ b/frontend/app/src/shared/router/route-descriptor.ts @@ -1,9 +1,22 @@ +import { normalizeLegacyPath } from './legacy-path'; + const URL_PARSE_BASE = 'http://localhost'; -const CONSOLE_WORKSPACE_DETAIL_PATTERN = /^\/console\/workspaces\/([^/]+)\/?$/; -const MY_WORKSPACE_DETAIL_PATTERN = /^\/console\/my-workspaces\/([^/]+)\/?$/; -const LEGACY_CANONICAL_PATHS: Record = { - '/dashboard': '/console/dashboard', -}; + +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; @@ -19,53 +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 pathnameWithoutTrailingSlash = - parsedUrl.pathname === '/' ? parsedUrl.pathname : parsedUrl.pathname.replace(/\/$/, ''); - const legacyCanonicalPath = LEGACY_CANONICAL_PATHS[pathnameWithoutTrailingSlash]; - const normalizedPath = normalizePathname(parsedUrl.pathname); - const actualPath = `${normalizedPath}${parsedUrl.search}${parsedUrl.hash}`; - - const consoleWorkspaceDetail = pathnameWithoutTrailingSlash.match( - CONSOLE_WORKSPACE_DETAIL_PATTERN + normalizedLegacyUrl.pathname === '/' + ? normalizedLegacyUrl.pathname + : normalizedLegacyUrl.pathname.replace(/\/$/, ''); + const actualPath = `${normalizePathname(parsedUrl.pathname)}${parsedUrl.search}${parsedUrl.hash}`; + const detailRoute = DETAIL_ROUTE_RULES.find(({ pattern }) => + pattern.test(pathnameWithoutTrailingSlash) ); - if (consoleWorkspaceDetail) { - return { - actualPath: `${pathnameWithoutTrailingSlash}${parsedUrl.search}${parsedUrl.hash}`, - canonicalPath: '/console/workspaces/', - kind: 'workspace-detail', - workspaceId: consoleWorkspaceDetail[1], - }; - } - const myWorkspaceDetail = pathnameWithoutTrailingSlash.match(MY_WORKSPACE_DETAIL_PATTERN); - if (myWorkspaceDetail) { + if (detailRoute) { + const workspaceId = pathnameWithoutTrailingSlash.match(detailRoute.pattern)?.[1]; return { actualPath: `${pathnameWithoutTrailingSlash}${parsedUrl.search}${parsedUrl.hash}`, - canonicalPath: '/console/my-workspaces/', + canonicalPath: detailRoute.canonicalPath, kind: 'workspace-detail', - workspaceId: myWorkspaceDetail[1], + workspaceId, }; } if (pathnameWithoutTrailingSlash.startsWith('/settings/')) { - return { - actualPath: `${pathnameWithoutTrailingSlash}${parsedUrl.search}${parsedUrl.hash}`, - canonicalPath: pathnameWithoutTrailingSlash, - kind: 'regular', - }; - } - - if (legacyCanonicalPath) { return { actualPath, - canonicalPath: normalizePathname(legacyCanonicalPath), + canonicalPath: pathnameWithoutTrailingSlash, kind: 'regular', }; } return { actualPath, - canonicalPath: normalizedPath, + 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/widgets/sidebar/Sidebar.test.tsx b/frontend/app/src/widgets/sidebar/Sidebar.test.tsx index 4fb0462f4..3cc431c00 100644 --- a/frontend/app/src/widgets/sidebar/Sidebar.test.tsx +++ b/frontend/app/src/widgets/sidebar/Sidebar.test.tsx @@ -13,6 +13,8 @@ import { setPrograms, setCurrentRole, setMenuData, + resolveActiveMenuIdByPath, + syncSidebarState, toggleGroup, setActiveMenu, clearActiveState, @@ -302,7 +304,7 @@ describe('Sidebar', () => { permissions: [], workspace: { required: true, - managedType: 'USER_MANAGED', + requiredManagedType: 'USER_MANAGED', }, }, ]); @@ -312,6 +314,7 @@ describe('Sidebar', () => { workspacePolicy: { useUserManaged: false, usePlatformManaged: true, + useExternalSync: true, useSelector: true, }, }); @@ -337,7 +340,7 @@ describe('Sidebar', () => { permissions: [], workspace: { required: true, - managedType: 'USER_MANAGED', + requiredManagedType: 'USER_MANAGED', }, }, { @@ -346,7 +349,7 @@ describe('Sidebar', () => { permissions: [], workspace: { required: true, - managedType: 'PLATFORM_MANAGED', + requiredManagedType: 'PLATFORM_MANAGED', }, }, ]); @@ -383,6 +386,46 @@ describe('Sidebar', () => { expect(menuItems.get()).toHaveLength(0); }); + it('workspace_id query가 있으면 현재 선택이 비어 있어도 workspace-required 메뉴를 보여야 함', () => { + window.history.replaceState({}, '', '/console/deskpie/contacts?workspace_id=ws-query'); + currentWorkspaceId.set(''); + + setPrograms([ + { + code: 'DESKPIE_CONTACTS', + path: '/console/deskpie/contacts', + permissions: [], + workspace: { + required: 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: [] }]); @@ -432,6 +475,52 @@ describe('Sidebar', () => { 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', () => { @@ -456,6 +545,75 @@ 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/deskpie/contacts', + permissions: [], + workspace: { + required: 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/deskpie/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 5e5d5fc22..258974860 100644 --- a/frontend/app/src/widgets/sidebar/Sidebar.tsx +++ b/frontend/app/src/widgets/sidebar/Sidebar.tsx @@ -7,11 +7,7 @@ * - 활성 메뉴 하이라이팅 */ -import { createStore } from '#app/shared/store/create-store'; import { Icon } from '#app/shared/icon'; -import { user } from '#app/app/state'; -import { currentWorkspaceId } from '#app/entities/workspace'; -import { platformSettings, isProgramAccessible } from '#app/entities/platform-settings'; import { SidebarMenu, SidebarMenuItem, @@ -22,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 { ManagementType, WorkspaceManagedType } from '#app/entities/platform-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'; @@ -41,35 +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; - managementType?: ManagementType; - 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; @@ -82,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(''); // ============================================ @@ -115,123 +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, - platformSettings.get()?.workspacePolicy ?? null, - currentWorkspaceId.get() - ); -} - -function convertApiMenuToMenuItem(apiMenu: ApiMenu): MenuItem | null { - if (apiMenu.managementType === 'PLATFORM_MANAGED' && !user.get()?.isOwner) { - return 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)) { @@ -243,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); @@ -263,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(); } -platformSettings.subscribe(syncMenuData); -currentWorkspaceId.subscribe(syncMenuData); - // ============================================ // Storage Functions // ============================================ @@ -489,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..6235ef070 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, 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.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.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/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.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'); From 012c3424f14f574b393206833ae6bc4ed286458b Mon Sep 17 00:00:00 2001 From: keIIy-kim Date: Sat, 4 Apr 2026 09:52:42 +0900 Subject: [PATCH 3/3] refactor: reset platform shell and workspace scope --- .../app/controller/MyWorkspaceController.kt | 6 +- .../io/deck/app/controller/WorkspaceDtos.kt | 4 +- .../kotlin/io/deck/app/init/DevDataSeeder.kt | 11 +- .../resources/db/migration/app/V1__init.sql | 33 ++- .../controller/MyWorkspaceControllerTest.kt | 18 ++ .../io/deck/app/init/DevDataSeederTest.kt | 7 + .../AppMigrationMenuPermissionsTest.kt | 18 ++ .../deck/audit/service/ApiAuditLogService.kt | 2 +- .../deck/deskpie/registry/ProgramRegistrar.kt | 41 ++- .../deskpie/init/DeskPieDevDataSeederTest.kt | 4 +- .../deskpie/registry/DeskPieRegistryTest.kt | 8 + .../io/deck/iam/api/ProgramDefinition.kt | 1 + .../kotlin/io/deck/iam/api/ProgramPaths.kt | 40 +++ .../io/deck/iam/api/WorkspaceDirectory.kt | 2 + .../kotlin/io/deck/iam/api/WorkspaceRecord.kt | 2 + .../io/deck/iam/controller/MenuController.kt | 1 + .../kotlin/io/deck/iam/controller/MenuDtos.kt | 1 + .../iam/domain/ExternalOrganizationSync.kt | 1 + .../io/deck/iam/domain/ExternalReference.kt | 8 + .../io/deck/iam/domain/ExternalSource.kt | 5 + .../deck/iam/domain/WorkspaceInviteEntity.kt | 18 +- .../deck/iam/registry/IamProgramRegistrar.kt | 35 ++- .../WorkspaceMutationJdbcRepository.kt | 119 ++++++++ .../iam/repository/WorkspaceRepository.kt | 6 +- .../iam/security/OAuth2UserInfoExtractor.kt | 2 + .../iam/service/ExternalOrganizationFlow.kt | 66 +++++ .../service/ExternalWorkspaceSyncService.kt | 92 +++--- .../service/OAuthLoginProvisioningService.kt | 15 +- .../kotlin/io/deck/iam/service/UserService.kt | 150 ++++++---- .../iam/service/WorkspaceDirectoryImpl.kt | 12 +- .../service/WorkspaceInvitationManagerImpl.kt | 12 +- .../iam/service/WorkspaceInviteService.kt | 144 +++++++-- .../iam/service/WorkspaceMemberService.kt | 4 + .../WorkspaceProvisioningCommandImpl.kt | 2 +- .../io/deck/iam/service/WorkspaceService.kt | 71 +++++ .../io/deck/iam/api/ProgramPathsTest.kt | 28 ++ .../deck/iam/controller/MenuControllerTest.kt | 12 +- .../io/deck/iam/domain/WorkspaceEntityTest.kt | 7 +- .../iam/domain/WorkspaceInviteEntityTest.kt | 14 +- .../io/deck/iam/security/OAuth2LinkingTest.kt | 3 +- .../security/OAuth2UserInfoExtractorTest.kt | 2 + .../io/deck/iam/service/AuthServiceTest.kt | 7 +- .../ExternalWorkspaceSyncServiceTest.kt | 177 +++++------ .../OAuthLoginProvisioningServiceTest.kt | 37 ++- .../deck/iam/service/ProgramRegistryTest.kt | 25 +- .../io/deck/iam/service/UserServiceTest.kt | 147 ++++++++-- .../iam/service/WorkspaceInviteServiceTest.kt | 277 +++++++++++++++++- .../WorkspaceProvisioningCommandImplTest.kt | 4 +- .../deck/iam/service/WorkspaceServiceTest.kt | 192 +++++++++++- .../capability/CalendarCapabilitySupport.kt | 4 +- .../deck/meetpie/registry/ProgramRegistrar.kt | 30 +- .../mcp/calendar-widget-preview.html | 2 +- .../ManageCalendarCapabilityTest.kt | 2 +- .../mcp/ManageCalendarMcpScenarioTest.kt | 2 +- .../meetpie/registry/ProgramRegistrarTest.kt | 7 + .../assets/screenshots/app/account-info.png | Bin 37125 -> 48692 bytes .../screenshots/app/account-password.png | Bin 48847 -> 57168 bytes .../screenshots/app/account-preferences.png | Bin 33641 -> 52244 bytes .../screenshots/app/account-security.png | Bin 29721 -> 50180 bytes .../screenshots/app/account-sessions.png | Bin 30509 -> 37588 bytes .../screenshots/app/console-activity-logs.png | Bin 0 -> 114469 bytes .../screenshots/app/console-audit-logs.png | Bin 0 -> 120519 bytes .../app/console-email-templates.png | Bin 0 -> 108872 bytes .../screenshots/app/console-error-logs.png | Bin 0 -> 118957 bytes .../screenshots/app/console-login-history.png | Bin 0 -> 118857 bytes ...console-my-workspace-external-readonly.png | Bin 0 -> 65354 bytes .../screenshots/app/console-my-workspaces.png | Bin 0 -> 41477 bytes .../app/console-notification-channels.png | Bin 0 -> 115110 bytes .../app/console-notification-rules.png | Bin 0 -> 106134 bytes .../app/console-slack-templates.png | Bin 0 -> 111773 bytes .../assets/screenshots/app/console-users.png | Bin 0 -> 88359 bytes .../screenshots/app/console-workspaces.png | Bin 0 -> 59582 bytes .../assets/screenshots/app/dashboard.png | Bin 0 -> 68968 bytes .../assets/screenshots/app/login-error.png | Bin 34450 -> 35464 bytes .../assets/screenshots/app/login-form.png | Bin 25349 -> 26581 bytes .../assets/screenshots/app/platform-menus.png | Bin 0 -> 46443 bytes .../app/settings-admin-visibility.png | Bin 0 -> 59609 bytes .../app/settings-manager-platform-denied.png | Bin 0 -> 23337 bytes .../app/settings-manager-visibility.png | Bin 0 -> 49104 bytes .../app/settings-user-platform-denied.png | Bin 0 -> 23393 bytes .../app/workspace-invite-external-invalid.png | Bin 0 -> 21611 bytes .../screenshots/deskpie/companies-list.png | Bin 79669 -> 66157 bytes .../screenshots/deskpie/contacts-list.png | Bin 94147 -> 80696 bytes .../deskpie/contracting-parties-list.png | Bin 73733 -> 59457 bytes .../screenshots/deskpie/contracts-list.png | Bin 61959 -> 47814 bytes .../assets/screenshots/deskpie/deals-list.png | Bin 85502 -> 72456 bytes .../assets/screenshots/deskpie/leads-list.png | Bin 61004 -> 47245 bytes .../deskpie/license-requests-list.png | Bin 60413 -> 46432 bytes .../screenshots/deskpie/licenses-edit.png | Bin 59684 -> 45838 bytes .../screenshots/deskpie/licenses-list.png | Bin 59684 -> 45838 bytes .../assets/screenshots/deskpie/login-form.png | Bin 27730 -> 26581 bytes .../screenshots/deskpie/login-success.png | Bin 45551 -> 73961 bytes .../screenshots/deskpie/pipelines-list.png | Bin 79172 -> 66222 bytes .../screenshots/deskpie/products-list.png | Bin 56421 -> 42495 bytes .../screenshots/deskpie/quotes-list.png | Bin 58182 -> 44011 bytes .../screenshots/meetpie/booking-bookings.png | Bin 48729 -> 52774 bytes .../screenshots/meetpie/booking-dashboard.png | Bin 62463 -> 62360 bytes .../meetpie/booking-event-types.png | Bin 58269 -> 57877 bytes .../screenshots/meetpie/booking-profile.png | Bin 52550 -> 51352 bytes .../meetpie/booking-public-confirmed.png | Bin 20636 -> 24651 bytes .../meetpie/booking-public-form.png | Bin 36233 -> 51711 bytes .../meetpie/booking-public-listing.png | Bin 25052 -> 23972 bytes .../meetpie/booking-public-slots.png | Bin 46108 -> 48326 bytes .../screenshots/meetpie/booking-schedules.png | Bin 54954 -> 54247 bytes .../meetpie/calendar-caldav-form.png | Bin 49428 -> 61150 bytes .../screenshots/meetpie/calendar-holidays.png | Bin 50019 -> 73879 bytes .../screenshots/meetpie/calendar-manage.png | Bin 49987 -> 73893 bytes .../screenshots/meetpie/calendar-settings.png | Bin 49428 -> 53558 bytes .../screenshots/meetpie/contacts-edit.png | Bin 63592 -> 51012 bytes .../screenshots/meetpie/contacts-list.png | Bin 63592 -> 51014 bytes .../assets/screenshots/meetpie/login-form.png | Bin 27576 -> 26581 bytes .../screenshots/meetpie/login-success.png | Bin 47199 -> 84708 bytes .../screenshots/meetpie/namecard-editor.png | Bin 92654 -> 80145 bytes .../screenshots/meetpie/namecard-public.png | Bin 35105 -> 33735 bytes .../screenshots/meetpie/namecard-share.png | Bin 73069 -> 65201 bytes .../screenshots/meetpie/widget-preview.png | Bin 10294 -> 9548 bytes .../2026-03-23-workspace-detail-route.md | 4 +- ...platform-reset-and-workspace-scope-plan.md | 49 ++-- ...4-03-platform-reset-and-workspace-scope.md | 19 +- docs/reference/backend/flyway.md | 21 +- docs/reference/backend/oauth-setup.md | 8 +- docs/reference/frontend/router.md | 18 +- docs/reference/glossary.md | 25 +- docs/reference/meetpie.md | 1 + docs/reference/workspace.md | 49 ++-- frontend/app/package.json | 2 + frontend/app/src/app/bootstrap.test.ts | 8 - .../src/app/navigation/menu-access.test.ts | 56 ++++ .../app/src/app/navigation/menu-access.ts | 98 +++++++ .../app/src/app/navigation/menu-runtime.ts | 113 +++---- frontend/app/src/app/navigation/menu-types.ts | 23 ++ frontend/app/src/app/page-access.ts | 4 +- frontend/app/src/app/page-registry.test.ts | 23 +- frontend/app/src/app/page-registry.ts | 10 +- frontend/app/src/app/tabs.test.ts | 102 ++++--- frontend/app/src/app/tabs.ts | 8 +- frontend/app/src/entities/menu/types.ts | 1 + .../workspace-access.test.ts | 30 ++ .../platform-settings/workspace-access.ts | 24 +- .../app/src/entities/workspace/scope.test.ts | 4 +- frontend/app/src/entities/workspace/types.ts | 1 + .../model/recents-store.test.ts | 2 +- .../model/shortcuts-store.test.ts | 2 +- .../ui/workspace-info-tab.test.tsx | 2 +- .../ui/my-workspace-detail-page.test.tsx | 2 +- .../ui/my-workspace-info-tab.test.tsx | 2 +- .../ui/my-workspace-members-tab.test.tsx | 2 +- .../ui/my-workspaces-page-actions.test.tsx | 2 +- .../app/src/pages/settings/settings-path.ts | 61 ++++ .../src/pages/settings/settings.page.test.tsx | 116 ++++++++ .../app/src/pages/settings/settings.page.tsx | 50 +++- .../src/pages/settings/use-settings-page.ts | 30 +- .../workspaces/workspaces.page.test.tsx | 4 +- .../src/shared/router/console-path.test.ts | 29 ++ .../app/src/shared/router/console-path.ts | 46 +++ frontend/app/src/shared/router/index.ts | 1 + .../app/src/shared/router/legacy-path.test.ts | 6 + frontend/app/src/shared/router/legacy-path.ts | 5 + .../shared/router/route-descriptor.test.ts | 10 +- .../app/src/shared/router/route-descriptor.ts | 8 +- frontend/app/src/test/auth.test.ts | 6 + .../app/src/widgets/sidebar/Sidebar.test.tsx | 21 +- frontend/app/src/widgets/sidebar/index.ts | 2 +- .../app/tests/manual/meetpie/namecard.spec.ts | 13 +- frontend/deskpie/src/page-plugins.ts | 4 +- .../contracting-parties.page.test.tsx | 4 +- .../pages/contracts/contracts.page.test.tsx | 4 +- .../pages/pipelines/pipelines.page.test.tsx | 8 +- .../pages/shared/crm-pipeline-state.test.ts | 2 +- .../src/pages/shared/crm-pipeline-state.ts | 3 +- .../src/pages/shared/crm-view-mode.test.ts | 18 +- .../calendar/ui/calendar-widget.test.tsx | 2 +- frontend/meetpie/src/page-plugins.ts | 9 +- .../booking-dashboard.page.tsx | 9 +- .../calendar-settings.page.test.tsx | 2 +- .../calendar-settings.page.tsx | 16 +- .../pages/mcp-apps-dev/mcp-apps-dev.page.tsx | 3 +- .../meetpie/src/test/booking-constants.ts | 8 +- frontend/tests/.auth/.gitkeep | 0 .../tests/booking/booking-bookings.spec.ts | 2 +- .../tests/booking/booking-event-types.spec.ts | 2 +- .../tests/booking/booking-profile.spec.ts | 2 +- .../tests/booking/booking-schedules.spec.ts | 2 +- .../tests/deskpie/accessibility-smoke.spec.ts | 4 +- .../tests/deskpie/crm-country-aware.spec.ts | 6 +- .../tests/deskpie/crm-filters-smoke.spec.ts | 18 +- frontend/tests/helpers/auth-state.ts | 43 +-- frontend/tests/helpers/auth.ts | 32 +- frontend/tests/helpers/manual-global-setup.ts | 187 ++++-------- frontend/tests/manual/app/account.spec.ts | 2 +- .../tests/manual/app/chunk-recovery.spec.ts | 12 +- .../manual/app/helpers/ensure-signed-in.ts | 25 +- .../tests/manual/app/logs-pagination.spec.ts | 12 +- .../manual/app/settings-visibility.spec.ts | 56 ++++ .../app/shared-registry-contracts.spec.ts | 4 +- frontend/tests/manual/app/system.spec.ts | 145 +++++++-- frontend/tests/manual/deskpie/crm.spec.ts | 161 +++++++--- .../tests/manual/deskpie/licenses.spec.ts | 179 +++++++---- .../manual/helpers/external-workspace.ts | 151 ++++++++++ frontend/tests/manual/helpers/navigation.ts | 57 ++++ frontend/tests/manual/helpers/screenshot.ts | 2 +- frontend/tests/manual/helpers/workspace.ts | 33 +++ frontend/tests/manual/meetpie/booking.spec.ts | 63 ++-- .../tests/manual/meetpie/calendar.spec.ts | 28 +- .../tests/manual/meetpie/namecard.spec.ts | 151 ++++++---- .../manual/meetpie/widget-preview.spec.ts | 3 +- .../tests/manual/setup/app-admin.setup.ts | 6 + frontend/tests/playwright.manual.config.ts | 18 ++ .../tests/system/accessibility-smoke.spec.ts | 2 +- frontend/tests/system/logs.spec.ts | 6 +- .../system/sidebar-collapsed-dropdown.spec.ts | 4 +- .../system/standalone-menu-smoke.spec.ts | 6 +- .../system/workspace-detail-route.spec.ts | 2 +- 213 files changed, 3385 insertions(+), 1196 deletions(-) create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/api/ProgramPaths.kt create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalSource.kt create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/repository/WorkspaceMutationJdbcRepository.kt create mode 100644 backend/iam/src/main/kotlin/io/deck/iam/service/ExternalOrganizationFlow.kt create mode 100644 backend/iam/src/test/kotlin/io/deck/iam/api/ProgramPathsTest.kt create mode 100644 docs/manual/assets/screenshots/app/console-activity-logs.png create mode 100644 docs/manual/assets/screenshots/app/console-audit-logs.png create mode 100644 docs/manual/assets/screenshots/app/console-email-templates.png create mode 100644 docs/manual/assets/screenshots/app/console-error-logs.png create mode 100644 docs/manual/assets/screenshots/app/console-login-history.png create mode 100644 docs/manual/assets/screenshots/app/console-my-workspace-external-readonly.png create mode 100644 docs/manual/assets/screenshots/app/console-my-workspaces.png create mode 100644 docs/manual/assets/screenshots/app/console-notification-channels.png create mode 100644 docs/manual/assets/screenshots/app/console-notification-rules.png create mode 100644 docs/manual/assets/screenshots/app/console-slack-templates.png create mode 100644 docs/manual/assets/screenshots/app/console-users.png create mode 100644 docs/manual/assets/screenshots/app/console-workspaces.png create mode 100644 docs/manual/assets/screenshots/app/dashboard.png create mode 100644 docs/manual/assets/screenshots/app/platform-menus.png create mode 100644 docs/manual/assets/screenshots/app/settings-admin-visibility.png create mode 100644 docs/manual/assets/screenshots/app/settings-manager-platform-denied.png create mode 100644 docs/manual/assets/screenshots/app/settings-manager-visibility.png create mode 100644 docs/manual/assets/screenshots/app/settings-user-platform-denied.png create mode 100644 docs/manual/assets/screenshots/app/workspace-invite-external-invalid.png create mode 100644 frontend/app/src/app/navigation/menu-access.test.ts create mode 100644 frontend/app/src/app/navigation/menu-access.ts create mode 100644 frontend/app/src/app/navigation/menu-types.ts create mode 100644 frontend/app/src/pages/settings/settings-path.ts create mode 100644 frontend/app/src/shared/router/console-path.test.ts create mode 100644 frontend/app/src/shared/router/console-path.ts delete mode 100644 frontend/tests/.auth/.gitkeep create mode 100644 frontend/tests/manual/app/settings-visibility.spec.ts create mode 100644 frontend/tests/manual/helpers/external-workspace.ts create mode 100644 frontend/tests/manual/helpers/navigation.ts create mode 100644 frontend/tests/manual/helpers/workspace.ts create mode 100644 frontend/tests/manual/setup/app-admin.setup.ts 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 210295909..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 @@ -40,8 +40,8 @@ class MyWorkspaceController( workspaceDirectory.ensureManagedTypeEnabled(ManagementType.USER_MANAGED) } - private fun ensureUserManagedAccessible(workspaceId: UUID) { - workspaceDirectory.ensureAccessible(workspaceId, ManagementType.USER_MANAGED) + private fun ensureReadableWorkspace(workspaceId: UUID) { + workspaceDirectory.ensureAccessible(workspaceId) } private fun verifyUserManagedOwner( @@ -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/WorkspaceDtos.kt b/backend/app/src/main/kotlin/io/deck/app/controller/WorkspaceDtos.kt index a3c2e2945..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 @@ -7,6 +7,7 @@ import io.deck.iam.api.WorkspaceInviteRecord 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 @@ -32,6 +33,7 @@ data class WorkspaceDto( ) data class ExternalReferenceDto( + val source: ExternalSource, val externalId: String, ) @@ -177,4 +179,4 @@ internal fun WorkspaceInviteRecord.toWorkspaceInviteDto(): WorkspaceInviteDto = private fun LocalDateTime.toUtcInstant(): Instant = toInstant(ZoneOffset.UTC) -private fun ExternalReferenceRecord.toDto(): ExternalReferenceDto = ExternalReferenceDto(externalId = externalId) +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 9f3a29830..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, @@ -166,6 +168,11 @@ class DevDataSeeder( requirePasswordChange = false, ) + workspaceProvisioningCommand.createPersonalWorkspace( + userId = createdUser, + userName = seed.name, + ) + if (seed.status != "ACTIVE") { devSeedUserManager.changeStatus(createdUser, seed.status) } @@ -468,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, @@ -553,7 +560,7 @@ class DevDataSeeder( logger = "window.onerror", message = "ChunkLoadError: Loading chunk vendors-node_modules_tabulator failed", method = "GET", - path = "/console/audit-logs/", + path = "/console/api-audit-logs/", userId = adminUser.id, username = "admin", email = DEFAULT_ADMIN_EMAIL, 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 f81603fc2..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 @@ -544,7 +544,8 @@ VALUES ('019bca88-0000-7000-8000-000000000301', -- └─ Logs (group) -- ├─ API Audits -- ├─ Activity --- └─ Errors +-- ├─ Errors +-- └─ Login History -- ============================================= -- Dashboard @@ -660,6 +661,14 @@ 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: @@ -1242,17 +1251,28 @@ CREATE TABLE workspaces description VARCHAR(500), 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_id ON workspaces (external_id) - WHERE external_id IS NOT NULL AND deleted_at IS NULL; +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) @@ -1287,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(), @@ -1304,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/MyWorkspaceControllerTest.kt b/backend/app/src/test/kotlin/io/deck/app/controller/MyWorkspaceControllerTest.kt index a467da143..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 @@ -218,6 +218,24 @@ class MyWorkspaceControllerTest : } } + 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) } + } + } + describe("removeMembersBatch") { it("user-managed owner가 멤버 batch 제거를 요청할 수 있다") { val workspaceId = UUID.randomUUID() 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 311efad0b..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, @@ -113,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 = "승인대기 사용자", @@ -244,6 +249,7 @@ class DevDataSeederTest : menuSeedCommand, notificationSeeder, workspaceSeeder, + workspaceProvisioningCommand, errorLogSeeder, loginHistorySeeder, oauthProviderSeeder, @@ -268,6 +274,7 @@ class DevDataSeederTest : menuSeedCommand, notificationSeeder, workspaceSeeder, + workspaceProvisioningCommand, errorLogSeeder, loginHistorySeeder, oauthProviderSeeder, 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 041231a1e..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 @@ -151,8 +151,10 @@ 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/platform/menu managed 컬럼은 V1 초기 스키마에 반영된다") { @@ -166,12 +168,28 @@ class AppMigrationMenuPermissionsTest : Regex("""auto_join_domains\s+JSONB\s+NOT\s+NULL\s+DEFAULT\s+'\[\]'::jsonb""") .containsMatchIn(workspacesBlock) shouldBe true + 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/audit/src/main/kotlin/io/deck/audit/service/ApiAuditLogService.kt b/backend/audit/src/main/kotlin/io/deck/audit/service/ApiAuditLogService.kt index 62caa95fd..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 @@ -192,7 +192,7 @@ class ApiAuditLogService( path = it.path, statusCode = it.statusCode, performedBy = it.userName ?: it.email ?: "Platform", - createdAt = it.createdAt.toInstant(java.time.ZoneOffset.UTC), + createdAt = it.createdAt, ) } } 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/init/DeskPieDevDataSeederTest.kt b/backend/deskpie/src/test/kotlin/io/deck/deskpie/init/DeskPieDevDataSeederTest.kt index e12164f65..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,11 +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 @@ -244,7 +244,7 @@ private fun workspaceRecord(name: String): WorkspaceRecord = override val name: String = name override val description: String? = null override val autoJoinDomains: List = emptyList() - override val managedType: WorkspaceManagedType = WorkspaceManagedType.USER_MANAGED + 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/api/ProgramDefinition.kt b/backend/iam/src/main/kotlin/io/deck/iam/api/ProgramDefinition.kt index c14816f42..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 @@ -11,5 +11,6 @@ data class ProgramDefinition( data class WorkspacePolicy( val required: Boolean = false, 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 fe2bb38f6..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 @@ -4,6 +4,8 @@ import io.deck.iam.ManagementType import java.util.UUID interface WorkspaceDirectory { + fun listAll(): List + fun ensureManagedTypeEnabled(managedType: ManagementType) fun ensureAccessible( 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 86075bb69..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,6 +1,7 @@ package io.deck.iam.api import io.deck.iam.ManagementType +import io.deck.iam.domain.ExternalSource import java.time.LocalDateTime import java.util.UUID @@ -16,5 +17,6 @@ interface WorkspaceRecord { } data class ExternalReferenceRecord( + val source: ExternalSource, val externalId: String, ) 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 8bd103088..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 @@ -45,6 +45,7 @@ class MenuController( ProgramWorkspacePolicyDto( required = workspace.required, requiredManagedType = workspace.requiredManagedType, + selectionRequired = workspace.selectionRequired, ) }, ) 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 f5073a244..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 @@ -13,6 +13,7 @@ data class ProgramDto( data class ProgramWorkspacePolicyDto( val required: Boolean, val requiredManagedType: ManagementType? = null, + val selectionRequired: Boolean = false, ) data class PermissionDto( 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 index 78221a88d..bff37bd63 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationSync.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalOrganizationSync.kt @@ -6,6 +6,7 @@ sealed interface 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 index a611e5f22..0fc244587 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalReference.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/domain/ExternalReference.kt @@ -2,9 +2,17 @@ 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/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/registry/IamProgramRegistrar.kt b/backend/iam/src/main/kotlin/io/deck/iam/registry/IamProgramRegistrar.kt index 3984f0939..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 @@ -3,6 +3,7 @@ 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 org.springframework.stereotype.Component @@ -11,24 +12,32 @@ class IamProgramRegistrar : ProgramRegistrar { override fun programs() = listOf( ProgramDefinition(MenuEntity.NONE_PROGRAM_CODE, ""), - ProgramDefinition("DASHBOARD", "/console/dashboard"), + ProgramDefinition("DASHBOARD", consoleProgramPath("/dashboard")), ProgramDefinition( "MENU_MANAGEMENT", "/settings/platform/menus", setOf("MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE"), ), - ProgramDefinition("USER_MANAGEMENT", "/console/users", setOf("USER_MANAGEMENT_READ", "USER_MANAGEMENT_WRITE")), - ProgramDefinition("ERROR_LOG", "/console/error-logs", setOf("ERROR_LOG_READ", "ERROR_LOG_WRITE")), - ProgramDefinition("API_AUDIT_LOG", "/console/api-audit-logs", setOf("API_AUDIT_LOG_READ", "API_AUDIT_LOG_WRITE")), - ProgramDefinition("ACTIVITY_LOG", "/console/activity-logs", setOf("ACTIVITY_LOG_READ")), ProgramDefinition( - "CODEBOOK_MANAGEMENT", - "/console/codebook", - setOf("CODEBOOK_MANAGEMENT_READ", "CODEBOOK_MANAGEMENT_WRITE"), + "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", - "/console/workspaces", + consoleProgramPath("/workspaces"), setOf("WORKSPACE_MANAGEMENT_READ", "WORKSPACE_MANAGEMENT_WRITE"), workspace = ProgramDefinition.WorkspacePolicy( @@ -38,13 +47,9 @@ class IamProgramRegistrar : ProgramRegistrar { ), ProgramDefinition( "MY_WORKSPACE", - "/console/my-workspaces", + consoleProgramPath("/my-workspaces"), setOf("MY_WORKSPACE_READ", "MY_WORKSPACE_WRITE"), - workspace = - ProgramDefinition.WorkspacePolicy( - required = true, - requiredManagedType = null, - ), + workspace = ProgramDefinition.WorkspacePolicy(required = true), ), ) } 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 8d65e5fbf..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,12 +1,16 @@ 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 findByExternalReferenceExternalId(externalId: String): WorkspaceEntity? + fun findByExternalReferenceSourceAndExternalReferenceExternalId( + source: ExternalSource, + externalId: String, + ): WorkspaceEntity? @Query( """ 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 28ddacfb5..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 @@ -3,6 +3,7 @@ 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 @@ -156,6 +157,7 @@ private data object AipExtractor : OAuth2UserInfoExtractor { } return ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, organizations = flattenedOrganizations, ) } 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 index 0bcfed7e8..6ed2af24c 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/ExternalWorkspaceSyncService.kt @@ -1,10 +1,10 @@ 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.UserId import io.deck.iam.domain.WorkspaceEntity @@ -12,9 +12,8 @@ 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.WorkspaceRepository +import io.deck.iam.repository.WorkspaceMutationJdbcRepository import org.springframework.context.ApplicationEventPublisher -import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -29,12 +28,18 @@ data class ExternalWorkspaceSyncResult( 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 workspaceRepository: WorkspaceRepository, + private val workspaceService: WorkspaceService, private val workspaceMemberRepository: WorkspaceMemberRepository, private val eventPublisher: ApplicationEventPublisher, + private val workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository, ) { @Transactional fun syncForUser( @@ -48,11 +53,16 @@ class ExternalWorkspaceSyncService( return ExternalWorkspaceSyncResult(workspaces = emptyList()) } - val externalOrganizations = externalOrganizationSnapshot.organizations - val desiredWorkspaces = externalOrganizations.map(::upsertWorkspace) + 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, desiredWorkspaces, reason) + reconcileMemberships(user.id, externalSource, desiredWorkspaces, reason) } else { ExternalWorkspaceMembershipDelta() } @@ -65,52 +75,27 @@ class ExternalWorkspaceSyncService( ) } - private fun upsertWorkspace(organization: ExternalOrganizationClaim): WorkspaceEntity = - findExternalWorkspace(organization.externalId)?.let { workspace -> - syncWorkspaceIdentity(workspace, organization) - } ?: createExternalWorkspace(organization) - - private fun createExternalWorkspace(organization: ExternalOrganizationClaim): WorkspaceEntity { - val newWorkspace = - WorkspaceEntity( - name = organization.name ?: organization.externalId, - description = organization.description, - managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference(organization.externalId), - ) - - return try { - workspaceRepository.save(newWorkspace) - } catch (_: DataIntegrityViolationException) { - findExternalWorkspace(organization.externalId)?.let { workspace -> - syncWorkspaceIdentity(workspace, organization) - } ?: throw DataIntegrityViolationException("External workspace upsert failed for ${organization.externalId}") - } - } - - private fun findExternalWorkspace(externalId: String): WorkspaceEntity? = workspaceRepository.findByExternalReferenceExternalId(externalId) - - private fun syncWorkspaceIdentity( - workspace: WorkspaceEntity, + private fun upsertWorkspace( + source: ExternalSource, organization: ExternalOrganizationClaim, - ): WorkspaceEntity { - workspace.syncExternalIdentity( - name = organization.name ?: workspace.name, - description = organization.description ?: workspace.description, + ): WorkspaceEntity = + workspaceService.upsertExternalWorkspace( + reference = ExternalReference(source = source, externalId = organization.externalId), + name = organization.name, + description = organization.description, ) - return workspace - } private fun reconcileMemberships( userId: java.util.UUID, + source: ExternalSource, desiredWorkspaces: List, reason: String, ): ExternalWorkspaceMembershipDelta { val desiredWorkspaceIds = desiredWorkspaces.map { it.id }.toSet() val currentExternalWorkspaces = - workspaceRepository - .findAllByMemberUserId(userId) - .filter { it.isExternal } + workspaceService + .findByUser(userId) + .filter { workspace -> isManagedByCurrentExternalSource(workspace, source) } val removedWorkspaceIds = mutableSetOf() val addedWorkspaceIds = mutableSetOf() @@ -158,21 +143,7 @@ class ExternalWorkspaceSyncService( return false } - return try { - workspaceMemberRepository.save( - WorkspaceMemberEntity( - workspaceId = workspaceId, - userId = userId, - isOwner = false, - ), - ) - true - } catch (_: DataIntegrityViolationException) { - if (workspaceMemberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId)) { - return false - } - throw DataIntegrityViolationException("External workspace membership upsert failed for workspace=$workspaceId user=$userId") - } + return workspaceMutationJdbcRepository.insertWorkspaceMemberIfAbsent(workspaceId, userId) != null } } @@ -180,3 +151,8 @@ 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/OAuthLoginProvisioningService.kt b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthLoginProvisioningService.kt index 359672877..cb5164a20 100644 --- a/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthLoginProvisioningService.kt +++ b/backend/iam/src/main/kotlin/io/deck/iam/service/OAuthLoginProvisioningService.kt @@ -30,14 +30,8 @@ class OAuthLoginProvisioningService( name: String, externalOrganizationSync: ExternalOrganizationSync = ExternalOrganizationSync.NoSync, ): OAuthLoginProvisioningResult { - val externalOrganizations = - when (externalOrganizationSync) { - is ExternalOrganizationSync.AuthoritativeSnapshot -> externalOrganizationSync.organizations - - ExternalOrganizationSync.NoSync, - ExternalOrganizationSync.Unavailable, - -> emptyList() - } + val authoritativeExternalSync = ExternalOrganizationFlow.authoritativeSnapshotFor(provider, externalOrganizationSync) + val externalOrganizations = authoritativeExternalSync?.organizations.orEmpty() val user = userService.findOrCreateByOAuth( provider = provider, @@ -45,11 +39,12 @@ class OAuthLoginProvisioningService( email = email, name = name, externalOrganizations = externalOrganizations, + externalOrganizationSource = authoritativeExternalSync?.source, ) val statusError = user.toOAuthLoginStatusError() - if (statusError == null && externalOrganizationSync is ExternalOrganizationSync.AuthoritativeSnapshot) { - userService.syncExternalOrganizationsForOAuthUser(user, externalOrganizationSync) + if (statusError == null && authoritativeExternalSync != null) { + userService.syncExternalOrganizationsForOAuthUser(user, authoritativeExternalSync) } return OAuthLoginProvisioningResult( 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 f4464c1e0..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 @@ -15,6 +15,7 @@ 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 @@ -22,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 @@ -68,6 +70,7 @@ class UserService( private val eventPublisher: ApplicationEventPublisher, private val channelAvailability: ChannelAvailability, private val workspaceMemberRepository: WorkspaceMemberRepository, + private val workspaceMemberService: WorkspaceMemberService, private val workspaceRepository: WorkspaceRepository, private val platformSettingService: PlatformSettingService, private val contactPhoneNumberNormalizer: ContactPhoneNumberNormalizer, @@ -219,25 +222,34 @@ class UserService( email: String, name: String, externalOrganizations: List = emptyList(), + externalOrganizationSource: ExternalSource? = null, ): UserEntity { - val normalizedExternalOrganizations = externalOrganizations.normalizeExternalOrganizations() - val externalWorkspaceSyncEnabled = isExternalWorkspaceSyncEnabled() + 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) { return normalizeExistingOAuthUser( user = existingIdentity.user, - provider = provider, + externalOrganizationSource = externalOrganizationSource, externalOrganizations = normalizedExternalOrganizations, - externalWorkspaceSyncEnabled = externalWorkspaceSyncEnabled, + externalWorkspaceFlowEnabled = externalOrganizationFlowEnabled, ) } - val allowedDomain = extractEmailDomain(email) - val matchedWorkspaces = allowedDomain?.let(workspaceRepository::findAllByAllowedDomain).orEmpty() - val autoApprovedByAip = shouldAutoApproveByAip(provider, normalizedExternalOrganizations, externalWorkspaceSyncEnabled) - val autoApproved = matchedWorkspaces.isNotEmpty() || autoApprovedByAip + val autoApprovedByExternalSync = + shouldAutoApproveByExternalSync( + externalOrganizationSource = externalOrganizationSource, + externalOrganizations = normalizedExternalOrganizations, + externalWorkspaceFlowEnabled = externalOrganizationFlowEnabled, + ) + val autoApproved = autoJoinMatches.workspaces.isNotEmpty() || autoApprovedByExternalSync // 2. 새 사용자 생성 val user = @@ -260,9 +272,9 @@ class UserService( // 3. OAuth Identity 생성 identityService.createOAuthIdentity(savedUser, provider, providerUserId, email, isPrimary = true) - if (matchedWorkspaces.isNotEmpty()) { - publishAutoApprovedEvent(savedUser, allowedDomain.orEmpty(), matchedWorkspaces) - addUserToMatchedWorkspaces(savedUser.id, allowedDomain.orEmpty(), matchedWorkspaces) + if (autoJoinMatches.workspaces.isNotEmpty()) { + publishAutoApprovedEvent(savedUser, autoJoinMatches.domain, autoJoinMatches.workspaces) + addUserToMatchedWorkspaces(savedUser.id, autoJoinMatches.domain, autoJoinMatches.workspaces) } if (!autoApproved) { @@ -282,50 +294,74 @@ class UserService( private fun normalizeExistingOAuthUser( user: UserEntity, - provider: AuthProvider, + externalOrganizationSource: ExternalSource?, externalOrganizations: List, - externalWorkspaceSyncEnabled: Boolean, + externalWorkspaceFlowEnabled: Boolean, ): UserEntity { - if (!shouldAutoApprovePendingAipUser(user, provider, externalOrganizations, externalWorkspaceSyncEnabled)) { + if (!shouldAutoApprovePendingExternalUser(user, externalOrganizationSource, externalOrganizations, externalWorkspaceFlowEnabled)) { return user } - user.changeStatus(UserStatus.ACTIVE, AIP_EXTERNAL_ORGANIZATION_REASON) + 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 shouldAutoApproveByAip( - provider: AuthProvider, + private fun shouldAutoApproveByExternalSync( + externalOrganizationSource: ExternalSource?, externalOrganizations: List, - externalWorkspaceSyncEnabled: Boolean, - ): Boolean = provider == AuthProvider.AIP && externalOrganizations.isNotEmpty() && externalWorkspaceSyncEnabled + externalWorkspaceFlowEnabled: Boolean, + ): Boolean = externalOrganizationSource != null && externalOrganizations.isNotEmpty() && externalWorkspaceFlowEnabled - private fun shouldAutoApprovePendingAipUser( + private fun shouldAutoApprovePendingExternalUser( user: UserEntity, - provider: AuthProvider, + externalOrganizationSource: ExternalSource?, externalOrganizations: List, - externalWorkspaceSyncEnabled: Boolean, - ): Boolean = user.status == UserStatus.PENDING && shouldAutoApproveByAip(provider, externalOrganizations, externalWorkspaceSyncEnabled) + 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 normalizedExternalOrganizations = externalOrganizationSync.organizations.normalizeExternalOrganizations() + val approvalReason = ExternalOrganizationFlow.approvalReason(externalOrganizationSync.source) val syncResult = externalWorkspaceSyncService.syncForUser( user = user, - externalOrganizationSnapshot = - ExternalOrganizationSync.AuthoritativeSnapshot( - normalizedExternalOrganizations, - ), - enabled = isExternalWorkspaceSyncEnabled(), - reason = AIP_EXTERNAL_ORGANIZATION_REASON, + externalOrganizationSnapshot = externalOrganizationSync, + enabled = isExternalOrganizationFlowEnabled(externalOrganizationSync.source), + reason = approvalReason, ) if (syncResult.addedWorkspaces.isNotEmpty()) { - publishExternalOrganizationApprovedEvent(user, syncResult.addedWorkspaces) + publishExternalOrganizationApprovedEvent(user, syncResult.addedWorkspaces, approvalReason) } return syncResult.workspaces @@ -876,6 +912,7 @@ class UserService( private fun publishExternalOrganizationApprovedEvent( user: UserEntity, matchedWorkspaces: List, + reason: String, ) { eventPublisher.publishEvent( InternalUserApprovedEvent( @@ -883,7 +920,7 @@ class UserService( userName = user.name, targetUserId = user.id, approvedByUserId = SYSTEM_ACTOR_ID, - reason = AIP_EXTERNAL_ORGANIZATION_REASON, + reason = reason, matchedWorkspaceIds = matchedWorkspaces.map { it.id.toString() }.sorted(), ), ) @@ -899,26 +936,17 @@ 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 isExternalWorkspaceSyncEnabled(): Boolean = platformSettingService.getSettings().workspacePolicy?.useExternalSync == true + private fun isExternalOrganizationFlowEnabled(source: ExternalSource?): Boolean = source != null && ExternalOrganizationFlow.isEnabled(platformSettingService.getSettings().workspacePolicy, source) private fun findOwnedWorkspaceMemberships(userId: UUID): List = workspaceMemberRepository.findActiveOwnerMembershipsByUserId(userId) @@ -1285,26 +1313,9 @@ class UserService( companion object { private const val WORKSPACE_ALLOWED_DOMAIN_REASON = "WORKSPACE_ALLOWED_DOMAIN" - private const val AIP_EXTERNAL_ORGANIZATION_REASON = "AIP_EXTERNAL_ORGANIZATION" } } -private fun List.normalizeExternalOrganizations(): 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 { it.externalId } - .toList() - /** * TOTP 설정 시작 결과 */ @@ -1327,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 ab8cc123f..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 @@ -5,13 +5,17 @@ import io.deck.iam.api.ExternalReferenceRecord import io.deck.iam.api.WorkspaceDirectory 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 listAll(): List = workspaceService.findAll().map { it.toRecord() } + override fun ensureManagedTypeEnabled(managedType: ManagementType) { workspaceService.ensureManagedTypeEnabled(managedType.toDomain()) } @@ -126,11 +130,13 @@ private fun io.deck.iam.domain.WorkspaceEntity.toRecord(): WorkspaceRecord = description = description, autoJoinDomains = autoJoinDomains, managedType = managedType.toApi(), - externalReference = externalReference?.let { ExternalReferenceRecord(it.externalId) }, - createdAt = createdAt, - updatedAt = updatedAt, + externalReference = externalReference?.let { ExternalReferenceRecord(source = it.source, externalId = it.externalId) }, + createdAt = createdAt?.toUtcLocalDateTime(), + updatedAt = updatedAt?.toUtcLocalDateTime(), ) private fun ManagementType.toApi(): ManagementType = this private fun ManagementType.toDomain(): ManagementType = this + +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 8141e0341..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,7 +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.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 @@ -34,6 +37,11 @@ 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?, @@ -63,10 +71,26 @@ class WorkspaceInviteService( private val userService: UserService, private val memberService: WorkspaceMemberService, private val eventPublisher: ApplicationEventPublisher, + private val workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository, ) { private fun requireMutableWorkspace(workspaceId: UUID) = workspaceService.findMutableById(workspaceId) - fun findAllByWorkspace(workspaceId: UUID): List = inviteRepository.findAllByWorkspaceId(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) @@ -84,25 +108,16 @@ class WorkspaceInviteService( message: String?, invitedBy: UUID, ): WorkspaceInviteEntity { + val normalizedEmail = email.normalizeInviteEmail() val workspace = requireMutableWorkspace(workspaceId) - val existingUser = userRepository.findByEmail(email) + 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) @@ -110,21 +125,22 @@ class WorkspaceInviteService( val invite = WorkspaceInviteEntity( workspaceId = workspaceId, - email = email, + email = normalizedEmail, tokenHash = tokenHash, expiresAt = Instant.now().plus(INVITE_EXPIRY_DAYS, ChronoUnit.DAYS), message = message, + inviterId = invitedBy, ).apply { createdBy = invitedBy } - val saved = inviteRepository.save(invite) + 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.PLATFORM_NAME, @@ -135,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, @@ -160,15 +192,14 @@ class WorkspaceInviteService( name = resolvedName, email = invite.email, roleIds = emptySet(), - createdBy = resolveInviteActorId(invite), + 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( @@ -189,7 +220,7 @@ class WorkspaceInviteService( 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, @@ -223,8 +254,12 @@ class WorkspaceInviteService( 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( @@ -264,6 +299,51 @@ 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 { @@ -304,8 +384,6 @@ class WorkspaceInviteService( hasAccount = false, ) - private fun resolveInviteActorId(invite: WorkspaceInviteEntity): UserId = UserId(invite.updatedBy ?: invite.createdBy ?: SYSTEM_ACTOR_ID) - private fun generateToken(): String { val bytes = ByteArray(TOKEN_BYTES) SecureRandom().nextBytes(bytes) @@ -317,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 a126a4dcc..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 @@ -79,6 +79,8 @@ class WorkspaceMemberService( workspaceId: UUID, userId: UUID, addedBy: UUID, + reason: String? = null, + domain: String? = null, ): WorkspaceMemberEntity { requireMutableWorkspace(workspaceId) if (memberRepository.existsByWorkspaceIdAndUserId(workspaceId, userId)) { @@ -97,6 +99,8 @@ class WorkspaceMemberService( workspaceId = workspaceId, memberId = UserId(userId), addedBy = UserId(addedBy), + reason = reason, + domain = domain, ), ) return saved 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 956cc3904..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 @@ -14,7 +14,7 @@ class WorkspaceProvisioningCommandImpl( userId: UUID, userName: String, ) { - workspaceService.createForUserIfEnabled( + workspaceService.ensurePersonalWorkspace( name = "${userName}의 워크스페이스", description = null, initialOwnerId = userId, 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 9e101dd6f..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 @@ -3,6 +3,7 @@ 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 @@ -12,6 +13,7 @@ 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 @@ -26,7 +28,10 @@ class WorkspaceService( private val memberRepository: WorkspaceMemberRepository, private val eventPublisher: ApplicationEventPublisher, private val platformSettingService: PlatformSettingService, + private val workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository, ) { + fun findAll(): List = workspaceRepository.findAll() + fun findById(id: UUID): WorkspaceEntity = workspaceRepository .findById(id) @@ -97,6 +102,21 @@ class WorkspaceService( 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, @@ -108,6 +128,16 @@ class WorkspaceService( return saveWorkspace(name, description, initialOwnerId, ManagementType.PLATFORM_MANAGED, autoJoinDomains) } + @Transactional + 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?, @@ -143,6 +173,47 @@ 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, 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/MenuControllerTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/controller/MenuControllerTest.kt index a09482434..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 @@ -113,11 +113,7 @@ class MenuControllerTest : code = "MY_WORKSPACE", path = "/console/my-workspaces", permissions = setOf("MY_WORKSPACE_READ"), - workspace = - ProgramDefinition.WorkspacePolicy( - required = true, - requiredManagedType = ManagementType.USER_MANAGED, - ), + workspace = ProgramDefinition.WorkspacePolicy(required = true), ), ) @@ -131,11 +127,7 @@ class MenuControllerTest : code = "MY_WORKSPACE", path = "/console/my-workspaces", permissions = setOf("MY_WORKSPACE_READ"), - workspace = - ProgramWorkspacePolicyDto( - required = true, - requiredManagedType = ManagementType.USER_MANAGED, - ), + workspace = ProgramWorkspacePolicyDto(required = true), ), ) } 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 30bb19f0c..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,6 +1,7 @@ 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 @@ -21,7 +22,7 @@ class WorkspaceEntityTest : WorkspaceEntity( name = "AIP Workspace", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference(externalId = "aip-org-1"), + externalReference = ExternalReference(source = AIP, externalId = "aip-org-1"), ) workspace.isExternal shouldBe true @@ -33,7 +34,7 @@ class WorkspaceEntityTest : WorkspaceEntity( name = "AIP Workspace", managedType = ManagementType.USER_MANAGED, - externalReference = ExternalReference(externalId = "aip-org-1"), + externalReference = ExternalReference(source = AIP, externalId = "aip-org-1"), ) } } @@ -63,7 +64,7 @@ class WorkspaceEntityTest : WorkspaceEntity( name = "AIP Workspace", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference(externalId = "aip-org-1"), + externalReference = ExternalReference(source = AIP, externalId = "aip-org-1"), ) shouldThrow { 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 317cc76e2..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 @@ -5,6 +5,7 @@ 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 @@ -195,7 +196,7 @@ class OAuth2LinkingTest : ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta", description = "Beta team"), ) - val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(externalOrganizations) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations) every { userService.validateOAuthLogin(AuthProvider.AIP, "aip-sub-123", "aip@example.com") } returns null every { 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 index 65abebc3a..19738884f 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2UserInfoExtractorTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/security/OAuth2UserInfoExtractorTest.kt @@ -2,6 +2,7 @@ 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 @@ -59,6 +60,7 @@ class OAuth2UserInfoExtractorTest : result.externalOrganizationSync shouldBe ExternalOrganizationSync.AuthoritativeSnapshot( + source = ExternalSource.AIP, organizations = emptyList(), ) } 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 31e442427..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 @@ -6,6 +6,7 @@ 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 @@ -317,7 +318,7 @@ class AuthServiceTest : ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta", description = "Beta team"), ) - val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(externalOrganizations) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations) every { oAuthLoginProvisioningService.resolveUser( @@ -378,7 +379,7 @@ class AuthServiceTest : expiresAt = session.idleExpiresAt, ) - val authoritativeEmptySnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(emptyList()) + val authoritativeEmptySnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, emptyList()) every { oAuthLoginProvisioningService.resolveUser( @@ -517,7 +518,7 @@ class AuthServiceTest : listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ) - val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(externalOrganizations) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations) every { oAuthLoginProvisioningService.resolveUser( 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 index c1464815b..70c111869 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/ExternalWorkspaceSyncServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/ExternalWorkspaceSyncServiceTest.kt @@ -5,29 +5,36 @@ 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.WorkspaceRepository +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.slot import io.mockk.verify -import org.springframework.dao.DataIntegrityViolationException +import java.util.UUID class ExternalWorkspaceSyncServiceTest : DescribeSpec({ - val workspaceRepository = mockk() + val workspaceService = mockk() val workspaceMemberRepository = mockk() val eventPublisher = mockk(relaxed = true) - val service = ExternalWorkspaceSyncService(workspaceRepository, workspaceMemberRepository, eventPublisher) + val workspaceMutationJdbcRepository = mockk() + val service = + ExternalWorkspaceSyncService( + workspaceService, + workspaceMemberRepository, + eventPublisher, + workspaceMutationJdbcRepository, + ) fun user(email: String = "aip@example.com") = UserEntity( @@ -36,7 +43,7 @@ class ExternalWorkspaceSyncServiceTest : ) beforeEach { - io.mockk.clearMocks(workspaceRepository, workspaceMemberRepository, eventPublisher) + io.mockk.clearMocks(workspaceService, workspaceMemberRepository, eventPublisher, workspaceMutationJdbcRepository) } describe("syncForUser") { @@ -46,13 +53,13 @@ class ExternalWorkspaceSyncServiceTest : WorkspaceEntity( name = "Acme", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) val staleWorkspace = WorkspaceEntity( name = "Legacy Org", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-stale"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-stale"), ) val staleMembership = WorkspaceMemberEntity( @@ -61,8 +68,10 @@ class ExternalWorkspaceSyncServiceTest : isOwner = false, ) - every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns activeWorkspace - every { workspaceRepository.findAllByMemberUserId(user.id) } returns listOf(activeWorkspace, staleWorkspace) + 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 @@ -72,7 +81,8 @@ class ExternalWorkspaceSyncServiceTest : user = user, externalOrganizationSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot( - listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme")), + source = ExternalSource.AIP, + organizations = listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme")), ), enabled = true, reason = "aip_external_organization", @@ -94,27 +104,38 @@ class ExternalWorkspaceSyncServiceTest : } } - it("새 external workspace를 PLATFORM_MANAGED로 생성하고 membership을 연결한다") { + it("normalized claim을 workspace service upsert로 위임하고 membership을 연결한다") { val user = user() - val savedWorkspace = slot() - val membership = slot() + val workspace = + WorkspaceEntity( + name = "Acme", + description = "Imported workspace", + managedType = ManagementType.PLATFORM_MANAGED, + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), + ) - every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns null - every { workspaceRepository.save(capture(savedWorkspace)) } answers { savedWorkspace.captured } - every { workspaceRepository.findAllByMemberUserId(user.id) } returns emptyList() + 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 { workspaceMemberRepository.save(capture(membership)) } answers { membership.captured } + every { workspaceMutationJdbcRepository.insertWorkspaceMemberIfAbsent(any(), user.id, false) } returns UUID.randomUUID() val result = service.syncForUser( user = user, externalOrganizationSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim( - externalId = "aip-org-1", - name = "Acme", - description = "Imported workspace", + externalId = " aip-org-1 ", + name = " Acme ", + description = " Imported workspace ", ), ), ), @@ -122,22 +143,22 @@ class ExternalWorkspaceSyncServiceTest : reason = "aip_external_organization", ) - result.workspaces.single().managedType shouldBe ManagementType.PLATFORM_MANAGED - result.workspaces - .single() - .externalReference - ?.externalId shouldBe "aip-org-1" - result.addedWorkspaces shouldBe result.workspaces + result.workspaces shouldBe listOf(workspace) + result.addedWorkspaces shouldBe listOf(workspace) result.removedWorkspaceIds shouldBe emptySet() - savedWorkspace.captured.managedType shouldBe ManagementType.PLATFORM_MANAGED - membership.captured.workspaceId shouldBe result.workspaces.single().id - membership.captured.userId shouldBe user.id - membership.captured.isOwner shouldBe false + 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 == result.workspaces.single().id && + it.workspaceId == workspace.id && it.memberId.value == user.id && it.addedBy.value == SYSTEM_ACTOR_ID && it.reason == "aip_external_organization" @@ -146,74 +167,13 @@ class ExternalWorkspaceSyncServiceTest : } } - it("existing external workspace sync에서 description claim이 비어 있으면 기존 description을 유지한다") { - val user = user() - val existingWorkspace = - WorkspaceEntity( - name = "Acme", - description = "Existing description", - managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), - ) - - every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns existingWorkspace - every { workspaceRepository.findAllByMemberUserId(user.id) } returns listOf(existingWorkspace) - every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(existingWorkspace.id, user.id) } returns true - - val result = - service.syncForUser( - user = user, - externalOrganizationSnapshot = - ExternalOrganizationSync.AuthoritativeSnapshot( - listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Renamed Acme")), - ), - enabled = true, - reason = "aip_external_organization", - ) - - result.workspaces.single().name shouldBe "Renamed Acme" - result.workspaces.single().description shouldBe "Existing description" - result.addedWorkspaces shouldBe emptyList() - } - - it("동시 생성 경합으로 insert가 실패하면 기존 external workspace를 다시 조회해 재사용한다") { - val user = user() - val existingWorkspace = - WorkspaceEntity( - name = "Acme", - managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), - ) - - every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returnsMany listOf(null, existingWorkspace) - every { workspaceRepository.save(any()) } throws DataIntegrityViolationException("duplicate key") - every { workspaceRepository.findAllByMemberUserId(user.id) } returns listOf(existingWorkspace) - every { workspaceMemberRepository.existsByWorkspaceIdAndUserId(existingWorkspace.id, user.id) } returns true - - val result = - service.syncForUser( - user = user, - externalOrganizationSnapshot = - ExternalOrganizationSync.AuthoritativeSnapshot( - 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 = 2) { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } - } - it("empty snapshot이면 기존 external membership을 모두 제거한다") { val user = user() val staleWorkspace = WorkspaceEntity( name = "Legacy Org", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-stale"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-stale"), ) val staleMembership = WorkspaceMemberEntity( @@ -222,7 +182,7 @@ class ExternalWorkspaceSyncServiceTest : isOwner = false, ) - every { workspaceRepository.findAllByMemberUserId(user.id) } returns listOf(staleWorkspace) + every { workspaceService.findByUser(user.id) } returns listOf(staleWorkspace) every { workspaceMemberRepository.findByWorkspaceIdAndUserId(staleWorkspace.id, user.id) } returns staleMembership every { workspaceMemberRepository.delete(staleMembership) } just Runs @@ -230,7 +190,7 @@ class ExternalWorkspaceSyncServiceTest : service.syncForUser( user = user, externalOrganizationSnapshot = - ExternalOrganizationSync.AuthoritativeSnapshot(emptyList()), + ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, emptyList()), enabled = true, reason = "aip_external_organization", ) @@ -241,30 +201,29 @@ class ExternalWorkspaceSyncServiceTest : verify(exactly = 1) { workspaceMemberRepository.delete(staleMembership) } } - it("membership 추가 경합으로 insert가 실패해도 기존 membership이 생겼으면 재사용한다") { + it("membership 추가 경합으로 insert-if-absent가 null을 반환하면 기존 membership으로 간주한다") { val user = user() val existingWorkspace = WorkspaceEntity( name = "Acme", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) - every { workspaceRepository.findByExternalReferenceExternalId("aip-org-1") } returns existingWorkspace - every { workspaceRepository.findAllByMemberUserId(user.id) } returns emptyList() - every { - workspaceMemberRepository.existsByWorkspaceIdAndUserId(existingWorkspace.id, user.id) - } returnsMany listOf(false, true) every { - workspaceMemberRepository.save(any()) - } throws DataIntegrityViolationException("duplicate key") + 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( - listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme")), + source = ExternalSource.AIP, + organizations = listOf(ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme")), ), enabled = true, reason = "aip_external_organization", @@ -273,9 +232,7 @@ class ExternalWorkspaceSyncServiceTest : result.workspaces shouldBe listOf(existingWorkspace) result.addedWorkspaces shouldBe emptyList() result.removedWorkspaceIds shouldBe emptySet() - verify(exactly = 2) { - workspaceMemberRepository.existsByWorkspaceIdAndUserId(existingWorkspace.id, user.id) - } + 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/OAuthLoginProvisioningServiceTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthLoginProvisioningServiceTest.kt index f6d541a7f..5fe690133 100644 --- a/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthLoginProvisioningServiceTest.kt +++ b/backend/iam/src/test/kotlin/io/deck/iam/service/OAuthLoginProvisioningServiceTest.kt @@ -3,6 +3,7 @@ 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 @@ -41,9 +42,10 @@ class OAuthLoginProvisioningServiceTest : "oauth@example.com", "OAuth User", externalOrganizations, + ExternalSource.AIP, ) } returns user - val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(externalOrganizations) + val authoritativeSnapshot = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations) every { userService.syncExternalOrganizationsForOAuthUser(user, authoritativeSnapshot) } returns emptyList() @@ -72,6 +74,7 @@ class OAuthLoginProvisioningServiceTest : "oauth@example.com", "OAuth User", externalOrganizations, + ExternalSource.AIP, ) } returns user @@ -81,7 +84,7 @@ class OAuthLoginProvisioningServiceTest : providerUserId = "aip-sub", email = "oauth@example.com", name = "OAuth User", - externalOrganizationSync = ExternalOrganizationSync.AuthoritativeSnapshot(externalOrganizations), + externalOrganizationSync = ExternalOrganizationSync.AuthoritativeSnapshot(ExternalSource.AIP, externalOrganizations), ) result.user shouldBe user @@ -104,6 +107,7 @@ class OAuthLoginProvisioningServiceTest : "oauth@example.com", "OAuth User", emptyList(), + null, ) } returns user @@ -119,5 +123,34 @@ class OAuthLoginProvisioningServiceTest : 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/ProgramRegistryTest.kt b/backend/iam/src/test/kotlin/io/deck/iam/service/ProgramRegistryTest.kt index 68a8f9105..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 @@ -27,15 +28,10 @@ class ProgramRegistryTest : program?.permissions shouldBe setOf("MENU_MANAGEMENT_READ", "MENU_MANAGEMENT_WRITE") } - it("CODEBOOK_MANAGEMENT 프로그램이 등록되어 있어야 한다") { + it("실제 console page가 없는 capability 전용 프로그램은 top-level registry에 노출하지 않아야 한다") { val registry = ProgramRegistry(listOf(IamProgramRegistrar())) - val program = registry.findByCode("CODEBOOK_MANAGEMENT") - - program shouldNotBe null - program?.path shouldBe "/console/codebook" - program?.permissions?.contains("CODEBOOK_MANAGEMENT_READ") shouldBe true - program?.permissions?.contains("CODEBOOK_MANAGEMENT_WRITE") shouldBe true + registry.findByCode("CODEBOOK_MANAGEMENT") shouldBe null } it("메뉴 그룹용 NONE 프로그램이 등록되어 있어야 한다") { @@ -53,6 +49,7 @@ 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 "/console/api-audit-logs" @@ -61,6 +58,20 @@ class ProgramRegistryTest : activityLogProgram shouldNotBe null 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 b3c2de7fd..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 @@ -21,6 +21,7 @@ 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 @@ -91,6 +92,7 @@ class UserServiceTest : lateinit var eventPublisher: ApplicationEventPublisher lateinit var channelAvailability: ChannelAvailability lateinit var workspaceMemberRepository: WorkspaceMemberRepository + lateinit var workspaceMemberService: WorkspaceMemberService lateinit var workspaceRepository: WorkspaceRepository lateinit var platformSettingService: PlatformSettingService lateinit var contactPhoneNumberNormalizer: ContactPhoneNumberNormalizer @@ -276,6 +278,7 @@ class UserServiceTest : eventPublisher = mockk(relaxed = true) channelAvailability = mockk(relaxed = true) workspaceMemberRepository = mockk(relaxed = true) + workspaceMemberService = mockk(relaxed = true) workspaceRepository = mockk(relaxed = true) platformSettingService = mockk(relaxed = true) contactPhoneNumberNormalizer = @@ -340,6 +343,7 @@ class UserServiceTest : eventPublisher = eventPublisher, channelAvailability = channelAvailability, workspaceMemberRepository = workspaceMemberRepository, + workspaceMemberService = workspaceMemberService, workspaceRepository = workspaceRepository, platformSettingService = platformSettingService, contactPhoneNumberNormalizer = contactPhoneNumberNormalizer, @@ -1054,20 +1058,21 @@ class UserServiceTest : WorkspaceEntity( name = "Acme", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) val workspaceB = WorkspaceEntity( name = "Beta", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-2"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-2"), ) every { externalWorkspaceSyncService.syncForUser( user, ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta"), ), @@ -1083,7 +1088,8 @@ class UserServiceTest : user = user, externalOrganizationSync = ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta"), ), @@ -1095,7 +1101,8 @@ class UserServiceTest : externalWorkspaceSyncService.syncForUser( user, ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ExternalOrganizationClaim(externalId = "aip-org-2", name = "Beta"), ), @@ -1113,14 +1120,15 @@ class UserServiceTest : WorkspaceEntity( name = "Acme", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) every { externalWorkspaceSyncService.syncForUser( user, ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ), ), @@ -1135,7 +1143,8 @@ class UserServiceTest : user = user, externalOrganizationSync = ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ), ), @@ -1146,7 +1155,8 @@ class UserServiceTest : externalWorkspaceSyncService.syncForUser( user, ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ), ), @@ -1163,14 +1173,15 @@ class UserServiceTest : WorkspaceEntity( name = "Acme", managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) every { externalWorkspaceSyncService.syncForUser( user, ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ), ), @@ -1186,7 +1197,8 @@ class UserServiceTest : user = user, externalOrganizationSync = ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ), ), @@ -1221,7 +1233,8 @@ class UserServiceTest : user = user, externalOrganizationSync = ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-locked", name = "Locked Org"), ), ), @@ -1232,7 +1245,8 @@ class UserServiceTest : externalWorkspaceSyncService.syncForUser( user, ExternalOrganizationSync.AuthoritativeSnapshot( - listOf( + source = ExternalSource.AIP, + organizations = listOf( ExternalOrganizationClaim(externalId = "aip-org-locked", name = "Locked Org"), ), ), @@ -1422,6 +1436,7 @@ class UserServiceTest : 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 @@ -1455,6 +1470,7 @@ class UserServiceTest : listOf( ExternalOrganizationClaim(externalId = "aip-org-disabled", name = "Disabled Org"), ), + externalOrganizationSource = ExternalSource.AIP, ) result.status shouldBe UserStatus.PENDING @@ -1495,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 @@ -1513,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" @@ -1533,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() @@ -1625,6 +1721,7 @@ class UserServiceTest : listOf( ExternalOrganizationClaim(externalId = "aip-org-1", name = "Acme"), ), + externalOrganizationSource = ExternalSource.AIP, ) result.status shouldBe UserStatus.ACTIVE 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 5e90f95e2..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,9 +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 @@ -16,6 +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.WorkspaceMutationJdbcRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -26,7 +29,10 @@ import io.mockk.just import io.mockk.mockk import io.mockk.verify import org.springframework.context.ApplicationEventPublisher -import java.time.LocalDateTime +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.dao.OptimisticLockingFailureException +import java.time.Instant +import java.time.temporal.ChronoUnit import java.util.Optional import java.util.UUID @@ -39,6 +45,7 @@ class WorkspaceInviteServiceTest : lateinit var userService: UserService lateinit var memberService: WorkspaceMemberService lateinit var eventPublisher: ApplicationEventPublisher + lateinit var workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository lateinit var workspaceInviteService: WorkspaceInviteService fun createUser( @@ -57,8 +64,9 @@ class WorkspaceInviteServiceTest : email: String = "invite@example.com", tokenHash: String = HashUtils.sha3("token"), status: InviteStatus = InviteStatus.PENDING, - expiresAt: LocalDateTime = LocalDateTime.now().plusDays(1), + expiresAt: Instant = Instant.now().plus(1, ChronoUnit.DAYS), createdBy: UUID = UUID.randomUUID(), + inviterId: UUID = createdBy, updatedBy: UUID? = null, ) = WorkspaceInviteEntity( workspaceId = workspaceId, @@ -67,6 +75,7 @@ class WorkspaceInviteServiceTest : status = status, expiresAt = expiresAt, message = "메시지", + inviterId = inviterId, ).apply { this.createdBy = createdBy this.updatedBy = updatedBy @@ -80,6 +89,7 @@ class WorkspaceInviteServiceTest : userService = mockk() memberService = mockk() eventPublisher = mockk() + workspaceMutationJdbcRepository = mockk() workspaceInviteService = WorkspaceInviteService( inviteRepository, @@ -89,6 +99,7 @@ class WorkspaceInviteServiceTest : userService, memberService, eventPublisher, + workspaceMutationJdbcRepository, ) every { workspaceService.findMutableById(any()) } answers { io.deck.iam.domain @@ -98,6 +109,19 @@ class WorkspaceInviteServiceTest : 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") { @@ -110,6 +134,17 @@ 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") { @@ -146,7 +181,7 @@ class WorkspaceInviteServiceTest : name = "AIP Workspace", id = workspaceId, managedType = io.deck.iam.ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) every { userRepository.findByEmail("invite@example.com") } returns null @@ -173,7 +208,7 @@ class WorkspaceInviteServiceTest : 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 @@ -217,6 +252,163 @@ 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 @@ -237,7 +429,7 @@ class WorkspaceInviteServiceTest : 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 @@ -249,6 +441,33 @@ 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() @@ -273,7 +492,7 @@ class WorkspaceInviteServiceTest : it.workspaceId == workspaceId && it.inviteId == existingInvite.id && it.email == existingInvite.email && - it.cancelledBy?.value == invitedBy + it.cancelledBy.value == invitedBy }, ) } @@ -313,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 @@ -382,7 +622,7 @@ class WorkspaceInviteServiceTest : email = "resent@example.com", tokenHash = tokenHash, createdBy = originalInviter, - updatedBy = resendBy, + inviterId = resendBy, ) val createdUser = createUser(name = "재초대 사용자", email = "resent@example.com") @@ -526,6 +766,7 @@ class WorkspaceInviteServiceTest : workspaceInviteService.resend(inviteId, resendBy, workspaceId) invite.tokenHash shouldNotBe originalTokenHash + invite.inviterId shouldBe resendBy verify(exactly = 1) { eventPublisher.publishEvent( match { @@ -555,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()) @@ -577,7 +838,7 @@ class WorkspaceInviteServiceTest : .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 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 832552aa0..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 @@ -11,7 +11,7 @@ class WorkspaceProvisioningCommandImplTest : val command = WorkspaceProvisioningCommandImpl(workspaceService) describe("createPersonalWorkspace") { - it("개인 워크스페이스 생성 규칙으로 WorkspaceService.createForUserIfEnabled를 위임한다") { + it("개인 워크스페이스 생성 규칙으로 WorkspaceService.ensurePersonalWorkspace를 위임한다") { val userId = UUID.randomUUID() command.createPersonalWorkspace( @@ -20,7 +20,7 @@ class WorkspaceProvisioningCommandImplTest : ) verify(exactly = 1) { - workspaceService.createForUserIfEnabled( + workspaceService.ensurePersonalWorkspace( name = "홍길동의 워크스페이스", description = null, initialOwnerId = userId, 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 dea285c7c..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 @@ -4,6 +4,7 @@ import io.deck.common.exception.BadRequestException import io.deck.common.exception.NotFoundException 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.WorkspaceMemberEntity @@ -12,6 +13,7 @@ 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 @@ -22,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 @@ -32,6 +35,7 @@ class WorkspaceServiceTest : lateinit var memberRepository: WorkspaceMemberRepository lateinit var eventPublisher: ApplicationEventPublisher lateinit var platformSettingService: PlatformSettingService + lateinit var workspaceMutationJdbcRepository: WorkspaceMutationJdbcRepository lateinit var workspaceService: WorkspaceService beforeEach { @@ -39,7 +43,15 @@ class WorkspaceServiceTest : memberRepository = mockk() eventPublisher = mockk() platformSettingService = mockk() - workspaceService = WorkspaceService(workspaceRepository, memberRepository, eventPublisher, platformSettingService) + workspaceMutationJdbcRepository = mockk() + workspaceService = + WorkspaceService( + workspaceRepository, + memberRepository, + eventPublisher, + platformSettingService, + workspaceMutationJdbcRepository, + ) every { platformSettingService.getSettings() @@ -157,6 +169,113 @@ 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() @@ -168,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() @@ -212,7 +394,7 @@ class WorkspaceServiceTest : name = "AIP Workspace", id = workspaceId, managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) @@ -251,7 +433,7 @@ class WorkspaceServiceTest : name = "AIP Workspace", id = workspaceId, managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) @@ -321,7 +503,7 @@ class WorkspaceServiceTest : name = "AIP Workspace", id = workspaceId, managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) @@ -338,7 +520,7 @@ class WorkspaceServiceTest : name = "AIP Workspace", id = workspaceId, managedType = ManagementType.PLATFORM_MANAGED, - externalReference = ExternalReference("aip-org-1"), + externalReference = ExternalReference(ExternalSource.AIP, "aip-org-1"), ) every { workspaceRepository.findById(workspaceId) } returns Optional.of(workspace) 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/docs/manual/assets/screenshots/app/account-info.png b/docs/manual/assets/screenshots/app/account-info.png index 2a1bf5d193b25eb862eccd2be77831af18485042..e3fa8558d1adc0fcbb7997ba35841ea277b72ce7 100644 GIT binary patch literal 48692 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

4WWJOOO=hp$<) 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 LrE#Uv`MCcBiHkZH literal 37125 zcmeFZXH-;c*DbgK!6O0&Km-opQ9&pKl$=2nQF6{fL{gD+PAZ}xU;vSvQ;~BlKoJp? zoFt>Y3OvF$YxF#^H><+N*89>@^POH0Q5 zrK%p2mGyH?JG|L5jNFS$hFC>e<-D||EcFGdv>i@9=ZhDmJl737#tJr;-d6`8|7;xi zI!l!7jH$U5|0p|&9&AAOVQAs=5G1%-k_i9mfA+jTd^CdOOClur*GfWy%lOv`(o>J{ zum0@+GrzE#cJI~2reNx`XU?1A5Z+ug&?z%-rlvxgMAWpBn^Xi5Rx~sznend`w}36 z=9?Y)$7}Ew3jAB1KYyN&cut09>P^BJ zX%`Nxc~7rlc<*^jWbA>ixS7xNNN=rie0_>`50ro zoR8fex*J5n%(A~p>o~K&wOHY{G8Pd*<%77cjQM>3z%f>E|Nec~#eNPolLYiY{%F#j zXJTI4A2;$0v7WjgeGay1FQ-;;U%x)}?d>&>ZR?+f#;Ny1kM|b~Mrwpa!r8owM-!Fq z#`4?b*RIRrq{`5J#lzP{MGs@F@&`*T)t0Yanh7P+*AAxPVx*)rLE?xrooMEQKiw%9 z$X?UM%DvDk@Yb1E-B%*=#N5Y548Kfov#K~%FP!paHNS}~hau&JHz^a{D_8Z3Y_&ee zd2V#N(euNZY&HE8OG%}^tSB;nsrx66Mt(J? zDp6wB$`^gqfH-|^jbQ7kax#xySR5!Qh)Br3+wzWixWdkEJsUzsA(o#n(7*pda#P#~ z`Qh5XSoO-S`wiIjJjb_2WtM$v-4Zr97me}gFzO~Gsnz&)5$)H{JcrEF~X}>f9v-T<^XNLPXIDUEb4^Ayz9gW2rgrXnj8EKI@;K^D zH_}pyFIu_U#z9JPg2|aE*6HqgnQ+E~ofW3rkDsG-g^vwuT=Qg=PL6l#HGTZXn7s}&Lw(wTIf z>oo325Zmsom5bu6aa$?>?$k&waCo@Uar^e|3l}a_jQbvh3XHk$O-C__xaLA6mzZ~G zo(}ceXcNf6ntuQA@ZrODkEWyjEqZZp_mwehj%Hr9!{pcA-d;LBw0f4Z@nEs3rwhb7 zrYX;;UVJge(6NQtZQN%EPDg&_@8HSia3*2r2cfAl;r*)&ZX?NkTEt3=kCm>rWs41y z=T9?KvzKC(rNsq%syNI>u!}RrO8R2S0Fgxf)%V!qa^q;(9 ztz3uRd6p?9itFl_le&KTJvq5jyU||fbAXnV{_J-+$uu;Bp+s9fF(`j~O$I%vetlp$ zjaa#Ep)V(j#X_mSuO|Bpr*7$k*xSS8zSEIf<(577uc1AtS{QwIe`RH{+R$Rviyk-0 zF!^i_82RBKhYIsrbV|ylJCt2ms*KUKu*hQQugSp9VRG`<$Bh5RiIrN94d*?4&!YS3 z+39mb;_dCJtJ#5Fj_A67{`rU0R|t0*-Melff)Af)Krdr;kd%NMvtTb$U+b!u= zYF5OLsu;w*f4VE04uowsGx=E~K+0>sJyk>KZ91=d=U6C=`tS4hrqN53E}8cO?gaP>grt75s#m-Za1}@XtKp*SFNGJc7=|unKT7)Y z*nCR8g^DqPd`Y7w_>_Z#L%76owu3 zKA7!Ic=a^rY3RG|yXVBxPK}jWX$|Lha-(}$%UG{sNHN9?Dr_GFY=(LhBJ!s)au-i+ z$q$bx{u;6>h!?O+;Lap`_ghC>`=h}vX@&HD{PEQFF%_~2hFHNm&9=+x=tSXm#{CA0 zIgIM`W7`wnyHK=iAtI7@^ zv(0;|=?bY=7Zby0HKd`8^!t}jrAHa=!>42S7jjxZ$WBD_r)E5gQt)abIwzWBp!h!K zw#kd)ob4QWk??JSKzkSWlcOEJY<*XK(tMduBouf^xU7%%v6ZYXu&vT)c>_|B$QcCVJ@sI>O+*m#kI*&x7Dxb zY8g4(Clm6dW2o~9pM!a<`}b`z?l>g^q*8K91Q}p6T-p@w&5moXbzGRx;$z*pu(5

0_2rM6nk&(D|dW@pm%`;$HvKGytOm16Js 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 z zm1a>4Cig!>Y+P+$-GDd=IT@L^)zvZoL1oWtmxU$m$mPO=(Lk^sA&l{cpf)5g%QRJR z67B%#Qx>2HGCu(}L}cyBVsygI43w<73>{++GCw9f1ckK26Q%MM(hjou=V+1aWJ>zn z7x=J*G93-^Pn#`{*6As~uXB7ZOSMvQq?An-GWkqe3Sgix}zYCw=_!K?;_6v&V;hlDp$P{bgATKVVEcl zi>xE#(_+mz(=GFX5ZEGqfQ?|Z5H&YH+LjG0DQq-w0Kfrv`20`<*x`vU_AROQjtUCC zvHHl7p(LCGnQ_DMwTLU3Pl(kneGUSQujqy7h10sEj#F=U<=+6 zkXSPbk0kQ+qkv4PnE$nGfDhKo+gmu!KD}`emmnn!<%R(-(JdVms<}5Ox{Dh942qW1{)`W-is*8&YsLrE05;_dp zNjeq`hIm+b$&6yQ_%5@w+s^u?8)Zpz+7R%u+Jt+UMS(-kVx&RNOQ8D!t zpIx+NKCKO^>P&x4LW3Sszw~8ImTwKBrk%2AlnMBf@#)#QbD)K*=R$wDB4>~A-6ok! z@CtWUj;}-8QSaK`J4t)TK#nmBm}2W3q3(qG|HW=0kW93aGb1cWoV858EF1d$q0T2$_8x0Z&62JCrpadA6ox6CmvzTU8oraiRhIX(e?^sT?oZlKv65D+jkH&=)rxN%SDfA+J% zxrjROoZ5K4C`p9dGmM?)ECP1%T>f`D?|=GF+Z- zC{T0EM^$g$pjY1s^9$w)8XO3QO`9S?Bb-0~3b8;-^W%hs132*l1|=LfAkqM3jV@jK zM(DQw{f!28_%9*n8bk;HB<<;C_>0pu5*|=r5_KBk4w$rCh9)P^LA@~Cl=2raAD#i< zFCrx6&S*Ums_8oY=daooJRH1R^9l0>eK*jGqWi+n(0&eHkmh;?8n80?M|;t~`GPqm z#;Hl1eBeOP2Z+PN54^Kw7yQIqaSxNshVqBaBF&wneMvt6CiKhwC&`V|8%xforMZdH`V4kyudHSHyywrKCX@<|=H5?7 zHuokArGNVdp3eRtakK+))Ke6clwd(=(htHAY;JCjFjwJ_w9~{fVfxol>di9|b{#gx zwjHPm`1;?bue_QhW;VED5vc;x$^Qyn^zh-OhgB59dCY6dZr+WZp~NY{JbC`zY>l9V zL=Wv9Yd->&iznFF&Ghu8*S~ww(X?w4h3emPU9iCMj>7gzeGG>y{xCuVf2#zsgi#0Y%@RhX zt?Z(g3=3guMxEf0xb?MS3eCPHu*JeW-@(f9eNiP8X#)EMwXoY+x%Ad)&!4vFar(sw zV4)d>8!e(SrlP&#Zh$gVFd^tLc@vH<7!GHE95Dc*Ox+8^fMAYOOK5zwkVPYE0a};N zAn4pP*$SEY0s`4j=hd78)uIC{06LY6|NY#k_Ivrd%R*T_t`8b}!m8pzL?qnocG5v% zBjpgu1sE2d>sjHjWxZG}F>m0kCvIg#Fkiy7b#uI94&SB9gT+!DZ{_zY~IIEg52cB7+!^6Cx5KMI<=wV6<~X`V>9fpvCjb!hg zD}*gTb}g*IF?vMY-dqD`!R5@r{*pAMxP6rYMmeJ2KQtr`4N0Bzv~!)=b{G!f4S%yx zUT}dWYNiNU0^xHXKVMxLv`2(Goi+@hEIn8sARoNeifhOMDl7atuR1YWXj-8eWIJU| za4tkxF`yk6%s=%iLUHrBR?$GESzE=%)8}(0Y1$GT>;AbfR@oJ;z|D8^d((?t`8O7z zM|7E%I&*XFX3xmM1nH>Bk<<6gs}BgG=FU_W^Eq|kfGiu~>1OVLyH~f;(__v6UJNnz z)ArXeT2n_YWskP`_3Ev_te0HjdNe;&>OMVm|A;7B!93pjvCtB>ZI=Ox&TQ~0uLqgT z1RLH`!E&R%IMfGrQeX|?hN1qjc%;39n}l!5G7j;&+Pw@%j~-QFtN<#3?kCE7ucqk) zf{ch^=HO=2Lx{}4&kLI_&nq4o>G&qLQys}=HpdQMKl451uU- z1`gVnj$O>TlsA?7$m^MZBfV$MOT9k;KX}EXPIG5aKM{^&iV7u5*g-f8f^$j>tOb0> zs9Vb|TX+xOZgGe5T0^VaNOl2+XYQ~K>(&9m*8IoOYc5RQq_dCmUUd)=R^bEwWlGDu zXZVWw_@lR^moCA^kZsXo0Y>W}OAYPn$XN%ThzY zpl0zttC9MBgPU-zvvd%VO2bI%Nu$lyt4MZAjYmS1cIhpa&U+zj?5((5^F`8wS-FIXMhKPJt-g_M>Own<+P8v!={I#FCVtAdY zrs?XLPt&HJow<2|iR~Av%`;G3p)-#lH4|vnGh8%rDj zdQ#>%fN+#CjN)ofQpv5_^#+W^5OYLn`&|s;l~2zP%PQlQzm1?qx3Jx6Ai@iHULqCJ zrUk!wWRg`s{i}{|Tr_+GQM2h)^whJR>DjI$ukP_!rBtOzExvX5E@@X`nvCfy_@Bn; zno49oc;K3s8`8zb!ON9iu3E3ky=DFS^&Q(R^h%ml_=Y1d9qozrh!Zjqao-vlUrX&9 z!XYC?v>0|j1|=Cmfx6^BU;*a zw3Q+XM)$j}a1Y_R@^0AA-*)&V^NsQNg7)e8^~J?T$Si`+Vs&-?j_Qd|!8gB4tyZ{0 zw6MSJpSA_Z?Ucuo;{wB3n+_q9I-t){`V*>2Bf8EAUr2M%#N^!N|>Xb#I}8s|&_|pljDp5O2L+X}BTsV*%{JgR3BEm$Hhnq>Cm|7g z!OunjD})&a-n7vo^xjI-g9oZV5m=HUwJXzrI!n02qTz=7-)9k1$l_Pmq=i@Lsl%VJx`nqguCVC`GC4E!EiQjNV)98useR2F}#$B zttYaq{GSgVc;Yn96kC6fr_@cMQ+~~|-;r2#@kJP%hOpmYQ-R)F?`;yI7{<^B5YMJV z8!#~@ON|V15Yhf0J8VbNe%lOc;f@1{O}%4%AzquNHS?ix#7rdRvlD|U>2H`FHQq4m z((0X)9OpcrRtcPCzp@04gCE7p)EMXcm8*LG5-JmYkPG53t{*# zgyFvshW|ns{@;QyT#StW{}r>0Lj6C;j|(0-aNs*GTueGs)6=}T2QjZ#R1g3@%{6xd zr@aS20Xzf0prGKPLx(W-oIP6(vKRsjBwWQGQ9~r63z)3APzHAd4-0T{$tmY%1pv-~ znAXx7XopVD!Y|!6*I@jOIs{M^s|T=xK!qWh%g{Z^bOq^x3()6z&bON()yM{@<&xSt zmtWtYtpk}gi@e14AYLvjA}LX!)fKJV?UlK3_s{8xB}ASq!lDPHiLeD|LeT$ZoKNpW zFXpzRA-j5iQ}`!d{8$%)m))yz3&d*!fK}yC?#m*)$9R`ov8@T+*Rh|EKe2-p`HD?m zNvbVD(7jz_gMAL8du!+#(K-xTrx0Ob;&v7(PH=L-q>N#65eki#VqloCprKVnQs$jXyEmrk!`K2FMS@R4bU)Ao!VEXhGl?T{lol@F$XrM7X6TsFQuL zr6u{HSzgI1n<#~jP|L$sQ?n5O4-u|FZ-bF{^2LvbXs`;2cqyNKp6Y^aQhS6g6Te61 zhXZ3t%E-Ew-Y^fU2k9CJ@2UZI3_`60vO>7=B2xB%0)%!}AWf4A;b{PEZq^DQ`pN>I zo|}TV#AFb{*G4Tr1)XbAPJEgLzrWfx>ESRI!dl#r6D(J~Fx8JG)SEy&49 z;`F_QRuz26Xi!7(Y57S$xQ4-d$*-c}yzW z5!f`9Wc7zyfoFZ@_Ua#^=jA=tMN6}x$>l#i0=o$~)+?RXNRI;?o+q~KIVq=mOTjCx zj=&+y7HSfWHJdQON4dGX6U^c;=@ELVj9Ef!O?VVT>>(Qp8`U#FD?2?i;|_BdQ1WF= z1Q{@DkF)&*SfNqrxq_#ZmYQxcNBY&&2-!tE-9UYGo5x`7Q#MNcZ(tq}Dn0K?JsM+8 z6IF!|DU)UFM;?v_JXH}s;I`rn;O+EN`he&QjURyF3c4_xAR~YzJBe^m?W!V!UZGY8 z%10K}LIc@$Jbwg?NOm*dnPX0}s$+?*ggH)p1usFidfc1eQ$HxdPcpsq3YKtw_6eB> z?_5Eb&byFoaL;QNn*36r^qGkXyQoeOU%Yz#kKhOSh@m?$H8+hs-;WJ@@$@!&9L)1z z)-(O^lz6%UEnX~a#km_jgo5hV<7hZ|zsC@fErPswh zZs~h9<6G}(UO}7}n~?eQvcZqvNuT#=H(Z?Ajo}KvpJOqe8YnZxR(S^rknm8ULI06b zMyRhQgtzhJ^HTA_yiHxIbCZ8T;1sgq^-f37g%&cAwnca;mvcB}p$@>W9E+ z5^t_Qaip5u(z4ROTEEoF+@9W*K5d^zRyQ-b$`UV}Nyl?3oj!Bzx|*GFoF#*H{#9;5 zTwL#fByT0;i-C~O9^v9j?v?jb?$-2BSk(Ql9!Jt`vyTzX{9`(O`2j%wELqfw$%&v^ zcf?#Z+#*!(M>D?w3+%!BJA8obG53XdZNSkcU0F`-D@fHNbQj=r(<-U&F@b4Gh$uEX zu_qsz6aHZykGS76G z%$#nBblr#Pt9X1eWis8CK|$Ad<8h7dlBt#D{%EzTC&}UQVjxUkh!+^IC}Ck2O#Yn` zyc=PNDH+#D@uUq-%5x2vR7aeVF6dl`SKb49k9Ok(RUaO`Qu$GY+F%tU)Y!Tu7j9?_ zs8Tf7($X?>&83c1AZw(6{u4xeKY{d8?vis<%~lCpx?ny}5^p_<)B?Ej5#U`2Q5%=2 zO|l?YYOQR!meh`KB)MLUEC&>0dWmP4`_7IGxzzFGP9jt~t0|K_d%@~VncdFYtPVQE z-75k0r&`91Z%tK0_lJ4f280Oi6^ov7!p8Cx>?qA{Bme=Bqqm-yjeV?()Hmb@j$55M zk|0X$a)R(7x{TfhA!%X$#Y!I9aw@6Xfdyt+L+Lpwx1a#1CS1Z#X*0!gTuC9nQ67T~ zNV-U2Bn09l7#~)nq76G(PG-Ix-Ft_{1OQsLRDoaFPebPDrl_9?vV*Kq6zr43O%PUT z&RX-@zfMI9adN&9vUCcqB0CfXeB`zDdGLU%%!Q1Ls=59cv(>Abmz~z;TKZYi>*M_k zxuTU=M{`FW*@hL6b2@5uhbScyOOR zd$zn^R_{+th8=1on`JezTu@3hrsD~>Y9;doZ=cFqDtmdA66lP#FCgzJ-InVf}aP{@Df~;VcoS{2waT(w%F* z+S4_F{Z1XCTBp$_?0l_TNMq0RZxzG&!BjcDj%tFqdkB3t8lL|nBK`j^arZyjhh7o; zECw9#{Q2`}X87NK5umlU=GXROazP{X$Z#!{V$kWr)E1AVX^xD#@wXbe%KcXAZU0gE>f$9QVK_Z873UEf=NKFU=*g^wYR~y;2pax8Q=RcMp^rTdUuV_qm z99BxaRX`Z%I*W-u^5o~Ua5HoD=zz3^hO`nZ@5Iv)yaPZ@^ScRz4ruv-h`K^QznBXy`S$bjVsaR=~uuT}PIwQuwi$)?FVRglB-> z#fmvmQ4pdC|5h0_^mxVW<6&Z9!Ya5yoc~s6CK7OVrlDB1T4j?4d4}4Pb^xXT2m;?5 zwc@WR*vV^*F4m}%SOuNDf`UM}n^sqr(X%HC;L0`r;Pfs9^!a&Y&Y%S;1Xg+&@* zSZA@?RZ--7qG7RujBvzSi{nnK z;Y?eyLWvO+6Rmp0YEypLM|0HddI0Tr6dtXJy4iKZ=d{)(c)Zyx@vbzo-O|&HiEiIm zn`Ek5xR&rshK(NDSdrm2Ua;1x8|ZHgG&T!5Ur=&1{@^zwlE}7BnWsQ^A7aE@m`*gV zA3r5Wwx#Q+`FKK1H}vv06bK62_;HKkI(1Vxp7elu`}SaaMz>n9e?olYS;bxU0ekzR zX9x}IZBLs0S8J919Wb@2rc2`vfzmkLHtaq8t#~+ACV>zts=1z*)R9ye9zZYDmH=5K zDeTJ*Jn{6@=W?N6Es9L_#v$A#v6muijO86dt}a5A2!#x3iY&ioEaf9|DNfHL z99Q$T!POILZ4THhnRq>QERm7Wt<_e9;X?swV}%8K>mt&~i1GD)*A*wdfsWrq!AYA} ztBf=9RLJ6EXDE|_O!M)}j_dg+v+PGk$>sZG*|*qAWJSYP+2~n2hs=h`muU8&>NBsv z_#%o)RaT;D6)$dchVBfS?KW-AvUVz4^llYBZ#A->!xKz=?N-#7w;v5cF6L@Sld>O% zrIyX}r53in-aKHF8E(XQ?p!67VkAQ22XT&%sfja9y}Y~AKGq5`g3&L!ZFvP9iV}AI z!e?v5Vf97QCp6%TA>A-)>OTQP6wOlSJy@Byhj6%EQ*mko)3CU-l-we@C^@HQ!cccr zsZQw%EMi_fYn5q)~7>y=O8H#f}VSE?T?bClH3=@>xNy6|O z^{6&h$z->7Cz`mF^QoImcF86OzO&`|ir2oHK~YWPuvU}O4V0_g`0&N~=avtW4cAN~ zMujlNT2?DbDB03~Hf@RByE)F(a8*m(opNdK@gh20}+`GLFu91uv3+6s|;_vaNjPPb~Q#*J}k?Y&q|9NHZ&3Dr4)up&{=M;Rpz^ zb?7dO8#J$M7om}~wVoi!i&nTD3agl1bW84hK`@Dakq`y5-uh9fDC3o_}5> zL+6~>Zx_x9b&{PikKR;GV(-T+uXoQ6GV{(;Lv7lg)Jt=<1ZW!IK&VuZ4yM7#zTz0f zZ$fjyjE1L8dG|E9X>BR(`IFnyu2Wy98Xk}IPnvhoGP2~0d41%`YL}*=7M%r$(#1;8 zis_V7h09M13&#u9m%4EW;Mx%eHMS5HV8J2oM)KLDf+aO;I@#~vNO5ZGy}yD}mywbsPyk1A{ zNli0vswGt>Q=((F6oT#eqxfqwR3`I@>a5AbC#V&nd=jrTNIkMxeqf) zM9%&U8;K%}d+jgrpS4-Foq>O1|E&x71%r-ayZrm(ScjQjq0m4YFweYCTQX4lefa70 zr9+(u_}kLf!Jo$CK61a6rXUX{2v~i-6U*AWvvjx6-1$eZtwQ&i*q{8votVnTb8s;r z4b(ONZ1Ssw_6|C6lRbPvC&JoOxIY3LNec-W@yFg^NXvra^C-LFd@xujK}ZxtP@ zRnlnSUt>>Us?Rkp6s!hoNDFm0=K2StZ0rhT_fW_0ElZ(!NEHufnC_@OI)cJjprLXZHJcmggONF2WO zZpH|CUHUq*jDIOWLWHo(u#xOatR~TP%ACF3B4^Q`vByM*&NS&Lxr!vGrD_lJJ&~j} z%nlGC)Z{{GtD}p{i`cBP;j`7<5V!QY{^XF=c540VXeC$!kWBn^Mf+aw*d#J)GdIEr zqA7}y+tdMjDoCz%!5$c)N>_LAu-p_2+d)o!Nagj*U2+GecnL4-Aj$G35R~T8`0<3$ zLI~X%HfRF^Y;l5HJYIlXI?77IRg^{-iRHC(mS1z?j98b2CV{?mKh@53ZZV|j30Hte z1e<7VUK}(-uzFJv>+6GYgiz< zQULPQQEZp}d5XSN>4tL|y%nbL3Z*Cu!xtYe4qxeo8{XG44n>*JS1_lBZYNzI?JUlA zEdbI-_?M~I5C}evYbZku1pdnoh#R5x5zb6`kq+(_-3+Cd)TH&G1Q)34U0WCd2ZeO% z)geXdj_zEbe4A!44$7)z zgX1>Opm&A#4?2)aBgvU3%CJ%Bc+80eb_s>i|q>1?&f-?84x%2Pk^wzkL@nvb94 zsmh!`jP03-ip&(uu^gP}y|x}c!)Za|xixpXe&VBx6rU84<_#g1R<~3_jWv`|ZmA1> zWG#)3gG37F6Zg9fgR7LQ{2IBntI4){YBy3+xNA~hnw>*Gr$OlPkF>p2vf34yBx%V& z@6WSO^G@^crgH;l+Idr7F7+Z$82@*Z+Iy4CPok?~s4N+d4!-Y0(59a*HE^mWGtQa6;pvXa zPaf~T^m<8LNURj#Z+oOX;FKF&F>J@*GHJi|{ITxFwbKZ>dGia!z_-Ky1f?hUUyx0Kn4pdpV>h;D~WTC%ZXdbeR7dE@5I=`b314-i*Oa_r; zm&OfncN5r5WWwjAB*P4L*(p-fC*ex46{TYX@yt(5 z76y-C8tK1+*fe4s{GXF+?stF^oG=Pt3pWI-z;+Nb&};sF@-}+-Cf8Ol?ZgmXb{Od-Jf&F8i~t|)PsPAr z!Tm!AgT9L|y=CFL$;p<}ouybhK!^_*b|LWiHR;UTckdvQmfN77k?~x2_=^fUyYVnz zUtd^n>j_|QLL8ac5BG{kp`mwbm1Hl3nd2vN6Pj1Mkb#FL&!C=I`wT0k2t3bQ>FM+G zJ^^_H)*Fqg-h*BU78*jO4PPlq94(JFPGTK}FT9oM-11mH8XgEb<Q_u5Rdo2xSwCXQsh-9PC;h>TXLtx_2n<6$t6k-rE0?Q+JdQ zi{WB(8Xq32!pqR*Jqvb2EkB}w8!9|DR9D0r9cS!WhM9+NE z?30k1#*Wszf#?bWl=oW_a_rq&ui-;W10bgvb8;YodW+Xn<-g%($Spy@wqF26C9o-C zxmW_Vl0)?Rer$OV$Lt-G_ZcPz0#!`af^`Pcujm;)@+%~;L; zmS>EkPsaw);g<9^{9fYlvQ;~=tS65PIY|LQf0}Fs`og065Sflxl>r4iB)kz%ELtk@ z9wLJbYNVDOAfdD|S3?`D3Zdg8-UaN~$i%z`56(7Zf~K&&@DBpSK$fD9gQiEz(5O=1ewwu_L%}gEu2Y1Kql?A*iwjq0FtgfzBbBp){#Q zQdrJp$9?I6_-or_Wn>T;!Nbixba%sE^#d0q^K7*|QDq1NA7f?Q-{49|l&r+;e z{3da0LD`ytB$=dhbxx`WljKbrZm*0Qvwej~DM2=ZGlhD~@}{vP<`OQ5gO0OEvGk)` zOH>!}{PP=G1x?2J!o3d1G!uOzGk5&P>Yp@u;oDmM&9@5FRX<`;n%{-Xzj4M|9Gu#e zIsvgVcZSLqs&A3r^ExY-Z?z>;9}Y*nLiEqVMhlFbe$C7@?HodP5R2t$Voh11ggbUX za|>6d?YtaTr1%uR1tv*k@wfXY7K%fK$q#7#eo+l}^XV1aW^z*dZ1c#-AmxxeVR0(+ zz-~50U`@m>qVZi&atR#Q64U2O3JoD|P#-&I+Jv5ezwv}_^elCrcVzh-2NMEYMh zy{9KhmMq+&`j=EzVt4JZ9KY-y`4)=>mWCTXq++u?*;K=59ogv}$2trq!bMIxzIgj` zo-B5d)y8=ykNW#BfcaEXdhD=uq)J2U7v=1z+c=J6?=a63yGf$dmz>TeCnT&TO3Q~i zh}nX3|Gw|>xFhQ{Znd`ejxXnaP3ASS{%fsf1XF1JiuGb-R35G`5g{#zlk zQMtN@%l{gA?CMnzpG7Dah(PyEe@J&N7N}xv+gr~0{lE5?a-Vzn;r;s!bTx>Idj3KO zD(R^^+}AYLWunjXlTa{m383fi2yyMNF~2QUnCPnrDYfJU=|4>TsixQ@qlDZ46JlzHgOn(l%}-oZ_2T1T2?ySuvjJ=u0P$6jT01YhF3B&)zZ~1?d_-c?|%-!O4b=E9h`!hF4|9=Q5`{L zF98qe-?^?Di+>mSEh!3IyoI7Sd~D9#fA#7WZcL~kXm_(PNylqXc)NIieZY2b$B*R>KevtqDO9?+&kU;a#|XII z6IWT^Zlrnd`XMuB3t@5Dqnw72|=cs2YD)aEJ@Q&QSA zUmeIWD4B+>p7-{|Mnyr~lHrjF{d~54)fSscCR5!MM-%^h#*cA%?~mj=SETyKBpKFw zzv5|@Y1@}!4gbahrlbDI?i)~*kx4uwu`a+@;Rwhgao6cXwI(C`s^Jiq@F+$0RB52M zy8qGvT56Xjo(l}D_qb&^?iCOO$2qbK#$lWrnPV&hn;PhT?6*Jol9M69)OQ|Z8Z;l; zng-$l^LT?3IGijTgWpRERe&T0SF3SP=CJO=cGw~0MD7WcKlJxU1Y6va_lr;NYMly~5bzzGD+WV88v@%=IAV3hTx{eLHTd(3lzh z-e}Ir$vby(4HswQrrH}=B7CvJP1x&LV`$eyv7fk=HBOOk?|96$@ib25`JfA)XUxCs z@6Q5HaPW_iRjbO1+TqJ>1k`)x=CMXLT9zece}>aIjOa$t{_OouFMFZkA-k}Bze2xA zb5c-H5V{|pBizkFA$B4*9rtVmxCW0NJ7wFRci0R4@n~LNUa-H0nYJr^IQ>{WnhE%r z7G`JJF+7{U?E3;_AY&)&`Th}c;nryRo6(B0OyfXlqfNWElk ze*V~W$knLQnt;H8$IMPH8u{eY!JKVcbR&Pb(@@s??#RIPz*xD2u6GbRq+gS-?ykk!UjN<= zx9UE9`h*vVxst|rK-5U1^avhmHG_Y-kH2p!^Z7NZo?(q;UJBm(qr$_@u3q*2;w8u? z_z~)I%08>vR`ukQCr@fsu8y2hj<&$}~spu62v@ z9`d^m@$=Wec_UTF9(us!G7}}eumMNZKQ!xyVMc_CsVSEwrF4w>zHT#b;UA5zD`nql zG}5$7_FWt}FO-=gsIgjoOh{aG1XD@s&x1`n&m8{4dHK=E?Gw5lCeW&{z+H17P3iX5 z2i3dj2OpWHX32M}GoSf( zY|RkerxI2@SHbG_dXc$ro(*gFza(u<{F>sb z>|faE8Nodc`@XNknvRrv`>C;>hYKl^j`E+c>?=s#{B);=XTZ81$i}Wb+j&7oh6I3| ziIKt}<(uF&%X&A})W&8dOH*ukquD;dP)91xeE5vR*_JD6%=w#=h2BI4#bATWCLugS)zTEDviTLNw`Oea=J;A6^)Bp45`*-i;Bl;IFGH{4BS69o{X=7U5So2lJ7BN#Y{zbXD zJZM)*df!f9qsApmORDlupqG7L>+9>2Dqr9Xy1TmrzL8&Qnx39ULry4GzfH@hXOd&`Fi10V^!$`r!#dYDrg{V^oXcGbLAmJ-` zo2>GM3!gCZiHnQlqfgq_2(G98<3|jWDa%}#R$TkI#mlD^IY}TgFNy15Yl#6UZ3w zInSk8i;wHt8TReowd-2~a#PSkA%Sn-IG(%};sIYcofZ(7LXq%YFt=+=+_aREl#~gn z;BIT^711!ViP*B+FaIli!nnfiJ_quwD$ut*?sGN|+>J^fhmv&2V*kTqxI~4qyP=av zX(+gQvA+?%u;&~<8N0f>56gD%6($Gz3BT__b}e7+AhKfc;f`WJxFJedm4uz@K7M5O z@q4{T$ox(|OGN%dpL~yH=g{ma{;sl{c98W96~Xk#a6cj4xKd(1%RXO3?712KCDr<> zTAId}r-DE){RpMM;)(%aYT3Izp4d&38joA&IXKcCcV)JIxwT>&nsQHpuNZ~EIbF@@BZAva&?c#BgD0`$xN&KFl+UPqBuR#SWb?Mj-ks$vs; zg6%mt5>++@aWDUvl|%%M%RGzRtM;8lvF=%mLLT3DApgK?%SrD$I%5^jL6_*iymgvB zzn1j{_Tgp#z2R)9jh{qx%I3|R2X?N}P_>o3D1~&N1b~IpO^J*$0}5!E!k#?wcJ#UE zt8)Kwvbma)(i?q}fxzW!CrLLTj_|idZ6e*~C99|~Jmk>TDPauRbA&E<0#yA0y^M?u znR%@^dq>cZ)AAcV`{<^5X%ASIy9CW_OucPX=DcN*>HXo_H`DYVhzH?uT2%Cd8e4ku z^B>=RX)?+;-?Pz(K%41|fWGEuZ9!Tr(=UZoH*GNLr$5MhwfaO56u9f?78R!ED9L5n z_^l6fL(97JEv9mUe%y8uSiz!1aQkgcxEsbM@gaF=y!q&6-JVo}we-xzt^lMri03 zFLlIx z#R7QoK(Adz0siezrahtbQq=N>o=j7ydg^ie`?zZi3=A;l@%3`ASo7?>?sU zy8vn43}G=GNRSS>1-1_2>?TJ8?vI=`SH%xkJ9R*l!=oXxj>J9&95+~5K>Fh%E>*F; z4{MM8Y5+P*bjcP6ySR=VN%y(d9F$vzan~yHaWaeCnbs}K)iu7jw0NGKd3C4H%^E$H z+s}W^BO^~SkDTY0`XZb9q*G&5dS3bld4XyO(L3(4{k;$Ti}4amiOEJX@}$GjFT0v{ z$Rw3!+pZHd(bnCiEHy-&WS@oR9j_x^+)59D>~BoPs3w2#fov|ZauTvg`P(F=TzSPC z;ThT4lfd46RMjb%8^WIbAc9KpYR8YAM&pFviV&@Wb=w$hYHA9&o5^DPI01sAL7G}3 z`~uVT*B6I!ZYpZfEC9W8G>DMH6}rL_d?c5-&!R=lnaRh`=WmB5B9t)ub&@u z(AS=CFq!$d_zhk2^bregtQqBz?Gbq)@u~KbqV(wYW*+;_gj}6vqo>P33#kIf z3>ldj@BCc%_m<#7nVfSAX#Igk?!8Zu@mgSmbJ75P5#tO@C~nYoXDA*}7U= zc-s~^g$bt{MTNJEPnmJan{k=j`NXJwE|3tfF;y#;2p`-ZmOxi%oM>F0T2wjN6&%FN zq+F}G$KB@skCzhUh50Dw(UdN)vlQoN2??zCSGBoWEZdy|o-l1YTUma{QF(4%@MH|Kba1lN75rt|H;;^`r57w*FfFb?!^Mqs|8Qhi8v2{he9;bT_>6 z^+ZnM?_|@S_B#XBS5}vXQqNY5YorKN_?MLxoI5jMq!&sIwEKAf>9=D1Di{CE1$?%e z)3%ivTACjGd)AG`yX{HWvfNAm)8W_kJ@iQuj6Wr^y3Uv62HtI04O9MOUD0d3r(pfS zKyP%tShp!zN_(1BvNu5Dvmle%hQDXOq}6lv?pAP=rrl|2CqKr{@**SUhy3zN*w?6w z&f@392j>D>DWlmoe^?~u{+fTk*p^l6aK1~n=iMKXMbDM=$+qQK`PqzI{am;X;aK)_S)lPY)i}hLL#TOsJZ)lU00K@ev!5EKCHfF z-z^%f_Wu4n!*)M@D(>f+0IAzLxgMVmpQ4p-UU~EJfN+QLcurAdA*)fq zuR!a{GfRI2pG|c}ecqz#A;@{%@9g*N$&7uItsbgXiZ0O+F?rLg$9$%XI`uy~oV+>Y zapRQf{rUPw5BXn|Ek>6n2$((n^y-i7p!{Fq@2fG?Q?JAC(+#^gTN^O$mTuR`it8m_nv%jCCa86RC_?(?@kIQ=gMyS4^F(TUxa>-iB;9@rZ@=|R*xRHoLd}e z-8L=n9n-NTwMS0#-uRmEN=YG>Z~@l#m!h*b*L|v)u%l0@x2cfQ+-u`|R_L?UH6|;y zf^WtY<>$w_*7vHoZEv^nDfFZ3v9*&pE_ztAqPsMM4gw-E={hzx))K4GJb@SCN%=1} zyl}hE`?E9C!qRY0_7tPn1cfy&cISoKGP&}nWV2lol*9*@E!2*2b%i*3SBsoFA@y9C zPiwVoiAnB-xh2n2T?o6`x5PJwgm?9xen<J9gTOuO#7OH)MefGM zu8{Pxi(|q!7H418_0E=v{#+cDY2H>jJCxG(UZKh%x~0=$^NhwuVY1uX(6kW0d8->u zGe*%n=M;D;&dotn2e+A!x)Ktzk96NWEqcA~2@|C@>XFk*Vr-IrNO#XfNb}d_@lTZ> z=h*z_tZ&$J`v3CZR2dmbB*ksuc-&d1H^=gQkNyZ_S=2 zo;+H1rOk9Y%dB@2)HnA+GwrzGE_L^>dTo2wE zg?&mjs=Xb+%{_dVJJ_-@M>8`h#7E>4J!gp*9aymzE4xnCS%lCgS98|;`rFtv$& z^1F6jb*Jv@J&BL=d6$z{j~c&Yk6bO!`Bkr4lKO(nBqhjs*z4J^f|SBXfe{++IT4*G zkkd=J3Icux4V&A%T|F%E?7|HDy|#F+jDttdDAiVQ=x@C_@!Po!_-6dyL)v(!9rLoL zn(1zeur3A~C$=YD)^8Jzq*`=R=8j;M{A{V{sDjER1MSAwKi52a`mCW$Px+9@<{$Kf z?~>zq9yN?lrGFh*C{nbuN_6V2V15eeXvgaM$Vs^Aw}=np4GqQ^ zSF@_2^t!(M#~K@A)$@OfILRyeznA>`WvFgb+h4z@x^XI}3lbuZR!|ftJecyHoQaK& zih80^_0d*2zr8Qk$ciaoIM(}2Omu4!HQWAsM_xvINGsn1V_gFiwQbjaI6DV<+D>m9 zn6GtryW|!#7X7EyI(gpkk;CYzvD$e0GBJtY*G6}RXT86Y-7z+7dhJp(j(N4d;Wq32 zyT_~rZj-(>j;JjO7pI+UugLyi+B?s$Cf6>FTMp`h03u3}11L>EiU=qj5dwnJt5OA| z7$GzX(jurRAW;yc6Qv7;PLP&>5=ul$=pE_3gx-?5@yuH9tTQuTX1yOLf54Nql4srf z`d!!F`<9Vz$L}@HoOAW#;&QMx>*;Hs&1x`I>qeNbSHwLZtgG97wTqn+DRK!V7KR5X z5#~a{7mRJ5L%W-&B((<&iVyGOn{-@|)48QXZ?A`_mn=Zwj=g$EqL494MO{4vnDrMk z($o6}2k)+Q#qT7(IKg5+Gt(u7Xc1}(C%(A~w=Yg3C>Uu!?XT7ozSpGn#iEroMjc5A z8<<${4P}^eiGWZRU&#sv2{>e#2$^bTa5AxShhCA*y;XFcnc0*xw>0sikfTAD0Q5FZ zU)L-W&J`G+?BHaxIwO9{gv;<_4EjS{^0?FE+~T)_0e1KVt{WyIMu`|=LU^M^%z2ix zPvQIIo{&@3rC}5@SUW26pYaAz;98h%rswLosQb*ZWc zsBK8Mb)mbWdQx?+KpbB<<~q--OP5s~((v1wq|5k4gYunix+0vQGjPRHvS7#yB z%9nI2SA2&8X5NE4ppuh|h5ci#y2C6jcsR@#ZMjhB+OV`~YW4E*z?Rcuw{B}2 z+w#yFbme4G&DMBh#qDmBEJ{i|n!g5lXJDcL8B4uPGgj^?Olcq&y38+unx8dv@asWi znatJ5zWrOwu(l#o)1yd5?eXtw9~&7fEn`#1vm#-2A-P)_W7_NzI2`#sUxY|(6Y^N+ z|BTn&nw9Uiv&kr)_A$?}$l&JCUBi0OYxzDe|5Cxu49_bb?H#m5%-~|DQ*EwdbDlY^ zHh<-)5o^V6t@KQ{*jMCy`NHX~q}aK)!I!8wyl0W%DKC3Ml+rQ7@fSxpD>#~xIg0h4 znUqIy`*Lw%xBH>xFJ5GhT7`dP?f+tzFV=W3i4kJPf;F~x@XE#E(be54vvk?TVZE>I z@@?GKmF1-6WiJZ~SxBXJDTv$i5(}|@m3F0*NnVW0yUPf^mxT_+PJA?oa?HvPmwC>rDeG)kfT`631zMc;kX}B<@s)N$$_@l3DAZ~K z`KrJ@`Xp^)qL56Ul-3Rbbz1A?5k&hmF>*A63Gic@YAQyK<8US9;rRN4ni!7A4P5oE z@;fWS*e2gLF0#P3m4C=<tp4=JW)SKAPhP8`~1RqQ0$X|0h-jre{T|>CfwQG2yr8 zM>1{H%&v<_x_hs-olG8hLQW21ljXDdytOq^)T>S-wlKHT5s2r<3VgI|(|z~sF!kxw z9*hI|O7~FtflNDMb zkmKUpgS@`HSf8M}D(g7eboKpQ&Yl}3Ti;+CYVx*Q%d_i0{2SmfrJGWl&2MV?qZ|@Y zHT$m$azB>fhA6K=1&K^u$y7=)E9t$Jg*Cg0#J|Gzl*ODbx(059QsChio(H-OSB1AL zz00~8@NigA5};r7A3XDUylttsX|Q$R&g%WP~}yNFUZO!IJ^W_aT^ zKd7Frta`jchj20B6x!0!M>ee(m5KE^gkqGehxb${PKAb5$GZpbgcT}|k#9EXH-tQr zDidco3<%Sp0S0)&J>?L_)55f1W7fy`$XxM)gwt zr5)!dOTTO#-VxxoOKVcRxS|-1NY!CYR37pP#OhK@Zc@(sUwJ`h+%HM$niE(B*Ef-k%qiB0E+9e`122RtD&2XvY(HAxJFVTP)axy6d&5fMPI$g~#}xDHX`h;! z-RjaINcF933LyXJn?Xhf&G%9&p>@L*NdFWlm;0*Y@IvEK*kPaI-?JQ(w@ z1Gq-$#AH5WI2X!EGvCNwBo^ZI^nt#0>w z>(s~vY^msi>F(R8%$py$Kk>*d^}HJwhX0dN}&} zDaS>&)Pq2C_XvrcsnGjE**@za$eLy@>tMQ%N^)GSV z=Ja_ZJ^gv}iD}nL0F0vA2fHe75MO4@g6-sE``3t}6axPv(yOwC_ z;0xAEn0{R}>oyo=d3K8M&|M?H*siFzeya1NII?qQJZYwYSwH=dl!o@;=A}orYSbu} zU*qmJHOx>Jv43nIm69klch3te0uu|IH_nl5guVQz;rNiDT%LNp7skhcAvA|(zs9rk ztxzGs+z^AGrNx;lQk?s`5m}47P93?`y}Sit7^K&e$c(2K1TA{=&C(EMANLPfxKS17 zKJKa2Fy{Ou769%WU0nDT2%N719K=)7(`;N)XZad9$HrPY^uJkN#1f`zYuCdT2ARCL z5u>}Is*=>%b>O(Mn1-GV-s^v94rFQB7t%4pBE3DDJ@8t(4crv z#!=*yH6DcwTu}venV$fRUHvt^+5IN$@;}Jz(wQ<0sj$*HhUX^SxMGC3FTZaLcQMJV zcKTC0U}$PcIato+4pDP(b7Qd#wNM{ZV~$RW9-$pa$7V>Euu}#6XoTwQptIUIt;cwJ+cxK^VsFB z!rK8K_LIETP?<&S*0+-Fz}2?!>yY<i61BwX!r7XFg;HGk84?_qNyX z;(!Y%w^L@vfT1_VI^9-Xay&-!e5rEHmJ2Ye+fiicbBn3%qi1LI1co^StTr?RH)Rwc zb!?h4e49)Z=*{%0Wd_j(D@&&RJ^lx7#M-%ANNvX89ojem2o~o@8~wvIqLt&Srxc*%u5984Z;(pZ$>z9vMCsU~z z^ppWW}4UG2jXC4eMeS>V& zpBvT<&vAMpkh2zu9ocbMI}AJa7710q&?`JagA1N3@6DWc7@MU4yu}4IdL@vjrL(SKi2BhH#?u=f5=rrmRTlhYheuthnV) zc~9kmbIIf>yrAwl-JUUZXWOBKk1+8G>9jF5qcWNZ)YoLa4^#69!{cnpAUq$l3IP@2AhdfUpGu_G=2HFYVdo(&ZDDz2^#3VtwUW{sKlg@=|Jn8aXasjf0e3sImj|x{sj!y2EW!H&q?Vi`Ld}w;Qszz}tN8|<- zBp1USGs4Tu!QQGTzrPxRQxdukD$`aYf2O#`cq4|l7fBAI@u|vW(fRHsDnnResRaKY zPu&koHl`EYN_a7*7I(fHKGS7vQ~mC8PnZI@dT*m0*9%|k=*u;sprsTjLJqkB*TG?9 zWN->xL~)Ku-#I!e$`%e6uQ3Gj5;&~ylhKBqLlRk+7|Xtr)dl1qv~;fd8by=A9#-Ml z5~EPZ?0rRK@~qy7NvAfx5^Q;ukT27!&=Ot}q>l5h^*vnD%K(d{OO0|R9Ht$=DEkRr zRlXu2Pw40}nR=s!G3fNfM?1@Tw`}z<)aOj)8FN`hD1MX{u`}zsec6-84<^?@t{UKm z9lHD=Dy`jb1}}z@5;R~iC$GY%z5IG~u{-59&fBu~+%LqZ;sG93I)Yp0^{IPd`z+^* zqcHbG>R+`JbV6p6!$U)hN=u`GjOP6vh>d6*3mFQo9*b_`(ERnrKO%3qM%1$V1#Q4{ zwPVwm5;cPDF@aQrw~Te5(~SK*AIF1=*x6l!jmvJ*o_gY|02?E%9)i9%o*K9E9|cDg zId}7j-i|*1&RFgLANYUmS^Ss1@LzsIeoxRs2If}}akmW&qCmKSe)QJU;vRy6*WHi h2Ide?9kJlaW8c^BYoCHBC?Aa`YC?6?@^9M&{|Eh#eG~uy diff --git a/docs/manual/assets/screenshots/app/account-preferences.png b/docs/manual/assets/screenshots/app/account-preferences.png index c58af72cae65b06272e85c9e40fa90976d56a219..9b3377eaafb804b718522839df67137a5d130b4a 100644 GIT binary patch literal 52244 zcmb5W1yEIM8wLu}NO#AkJ5)LZHX$P2N~Z`C0@5MfAp#1BfC>soD@aR+gaQ&uBS^PM z-goQyZ_J&!Gk48D^Pf3qv-Vow_r~))@AK^#ZB11|d^&tIG&I5+YDir)G%WbXOGCJq z;Qy{OtD~c#k)Yi`D(L%Uum8aEq4;)jZJVA=lT6X=;lqc8we=K**YBpxQ*hsZC7}Hh zD^|(?sLrl1#XdGORA3ttvY%CR!7l+S~>Dk)a8XFtCxw&a;C$r=y#Yk5oDK4{{ zYiVhno^8)zG-YAI4LHOC20zx-NlHsM`~N2LH6|JEvJ!}n*4W+Mjg5)f`TcwOw+Jce zu<9crlYmkDrF%vs!>VoYIInAKYmIN;uG+qdMDF7S9Brdp)F16qBKO_l1yXnh1_m^m z;hA#@n!Iuf@U18EMgk zWG4Q9yx-r2_`ffh^Y`!HkqsliJ-4#uKL0kk^8VJc!BEfe@Z_qwT9FAxdiAFMkHdPE zLl?d&%6{jiOvUg1v^E1F`6UGgjMQ1__s6c?K^jV1xpJ6q8{LSJ6C@^1@|nSsx;It= z8@p>ncr4gU^+b$XSf?`#(ND-28r{omN}bAWspCi>vVA z)^tn~&#h-y6B83BzjaSeN@+cp@%HvsP*9kZ8-6ZmCe?53=~-Qo{=M#j&G_L2=M?+`QkM61YfM03Zqia%T%E3M)iB3&Dl)Gsw19UV0&93C5cfA8D7 z?&Mvo&Uo6})sC!W{049JuQ`n*$5dPoy`=W5)1D=0M2C_`7nvwCZEd!w7=aJ3{U-S0 z-0AY}_MrSl{0j-kp%>^F*v2L%J>Eh>Wu$B>FC38kHUipjbYG?M=*8ALj+e8V+nv$A zuc&D4XP-~ds1f?^PEf%yY|nywx~xC!*Yp7n6tx@q96J*rzbWqvgHz@ z92uU-R>h#J*1UH$ndq6=xzL~>9+s$l@+OCUEpd}*RJMfiLTjv}Etp>V<7I@pwR(nN z;LrZ2!4xYkN~90DSq=i&M$?L zfkEowW`qCl`z|&g?*AZSl9BLQ9kFVQtgEdZd84Pw9Vg?l*j=ES^!ADQ8C4DAfg34x zM=a&dEV0saf&$HxwX){?OZ;RphY=_1`xei9N~9PiOjCIDUJKlQUw(?pnVt4ObhD9f zWLIAwh#orssD4A=6xh7*!yf}9z4G1dFRd#f92@lMvE%M?lPwRH7&rRpO8dt%+zELu z6ta(z4w_^slG?xhcNb&URaBb-{#Y}zKls^S^xMzRZ@SjqrYDtyn=Xm&8vX`6qZ~Z? zLRXTs_j+{audjt==8cozzEKksGfJ@+yA^8X!XFec5F;!OR!12b8A;RAg)Eq8Xrk#n z{_HN1b`<32hm-k@zIb%MdaeJ)-p|h|E+h@E(+zb_qi@U0g|FK8q+)GIKKL2%d-KOw z>1`pikL+nx=2O*oA43?6yG~nTzcX)qdxf{Z^Y=_}(zJPlPle+!VxKc@*XQJLLnq)I zY7v#o@<5Jb`{HEHJ)>GTE6-e6fA6+P!j3rVZ-To^eHq0D#gWacH{4nR3e6k+{C{tD@CFS*XMVkDJS)sK)(uoX6=JT_{Hn%P z_P6*coSx$lFEQEIueCAdkghLQd795g~5p`2i3(kwMYWmN6(85OJh~`V;aI!bF!0750p0*9et1Y z-*}5XyLwl*+){y^Ib6IQ%@*%!{)bQu9P@f_mzAMBT-Qdw1I5qeCRnUEPpyVtC`3OC zJl=1;^NU{}Qjb)g#HU;$nzpjSqB&@8ZqBAN{;9YtQ>r8tRRR>S>zInq+jVUSSwepC zi>@{r<6}}vl41TpD93hFyfqt)mRjYg?DoP9TB!{@5vzRnUh9mi&Yq-@)N9`Xdk}#> zf4#H+=@p0m%+Pd*fdW$ET>0Qcf*2zOhsX$B&L{GAF+@l`_Px3X%ZPwp(sXW}e4PT7 zzH|Y4xc~{y*f65qfV*LOMivq_#f|O@oyM%=;ft5Qz0u>!TQn~5UKF?OyZzz56$wG2 zS2PAmro&Z)&cc(9k58t;{63;fEfR_@rbn7enU`%$eXyxsF$umgYT)49hcxf#47r7S zd2MT}tXUKSAg&@$i-EtTArB)iQiv3Wu2}yWJpM1GYE!ch2A%$#drn_4WdHLzuwl-Bka!Gk#BLCdRMgpUsZ~8Tl%ae@_ z4MTg7(ht|iLscj|QY7sTMsKkef89+p&G|w#KLIPLQ{A3P>JdxH7pWiF9bOx~YN4iv ztL_wSywVcFou4lksm?}5>Zdn|X*g%1ak)UsV=?*;cN+eKrQQfh)w^^L5;5Zl_|u+A zrap;^!kHwc&}kbnvDH|r-)d6g(d9n4t$!a{cfo^ig8a`8JB?OUHg%QEN=6?095|?O zTQS|(&9R<(@8S_*LnPwLUD`*K^G)q?#7amAS_xqZpGl>P#P{ali?fs8VPRoA>OH)# z{+Ifr(ZLWDfg`R z-cWSq?zcW2r`gZY_dl;{6(AQ@9xuK|&(PE>@krSuOuW3mQ{Tm0P~mbcUH>Jim!(!%tW+D}=Pux**x z872}n5Pb{Ace$_RlSEyO&J2H*%;kLV&CSqf(>>E!_iY_fJZSwQU+A8E} zC4H7%$(iG~_t{oqQ^L9xts z=|GCec!Pei5gMI}ea2fPsnroyT+Da)!;Z5(QMxkMd8X_-X)P3OvwH8dr3{N|rC)c@ z8k{FYH>$?_RHZt`ZJkv-+&nxKf6>tLI1FSvxjL%mayyE zhjED#REV*2W99y^N{y^kILJgWVUlu4N0V@;ylG|+*N#nneQcxLo&5RPHKkLfk-OvN zc_p)yLgoOmHXV^p%B|Xw8U&A+Y|#xH(P*#)T^x1t?tcd$(~c33xu6&?x3_lkXgi-j#a64#_%>Bg9)l{RxKu`UiLs}h|(fgVL~O_xRB0i_g^tY0|OV)h!p zsQSt-S6p&VBr)@Ofi<)W2w3B&^{DPB0$A*u{S9|k!*Gc4_g+NCTzbR5Pn%6!XTOLy z3Qfatrm6AxBTGG9K!QeF!xPv5r6?j}|CzwwImag|*oE(!+&T@VSLb{hp_;#UnF40e zqteeC+oo7|vZutRL z`iI#^Plck}Rra5O&XAbB*33EnW)W;vX7*E-!Y9Rfsr0@`cj@g9y!u7kfM5|UthIus z)oR@JC(((8nmPD{gvzqo=biBc0x5n`djR&LQors0`Wm5|pAD^6O1SRN-tvcg--xtW zr!7r=@Smbj*aP;a5vaUlY5C%5-@%k?Ghob{(g<{GA?rW=QW7e zc&C(EG$+2g9;D)^qxA@Dv2$g`@gU7ef8+S~R#KfJnsm^qgv!02ckbR>z;^h#)qJ7y zwmmaK*Z*`ON#1tQ$8cK32ZCmjwln|fbA(gsV=gTf%#O?9DFyHDIj!F7`2T4E1-$Tq>kCCX1?5XDUHLwO3;T(T z(%6J_;R5)?TDfupTcOVJ-Z#90(0XRm-?WYQj(oC@KL}yr?PBX&*jZ5(twr_NRzg&d z^)1&;`kp0}r4;+;&x55KznqHe7(H!0_XRFO?u&0WCJLJ}52TMdnQu|}ib{`9o?fiw zAPTJS%MxzLajnwW7pw(RZ^(%7*zC2PMwex@!b}dPj zKgEDrMZY8PSfn6)N5?5duycm-m&}`1mvYGPBUc4d<}opP=~eI2>$Jq{uk^>S-;#Bg zGQ5BPzVSzi&y@3giK32S*{x4g9PX@@U{1VCzg|<8fp>J+lP$Riy=<{M8Jo1zR>aU@ zb&^_B^!=nAhu+DTo0^)MP7x;cAyN-X$$Rg9S^3(3z>mrX4cDhq@ueC$Pt$(W+_aSE zJww$Wg8OL5{kuh~XVKil9m|}?NlI;NW*<#vzBX1y-DWkSuD(M|Aayt${7t&q7<)hF zjl5h*(3@!WKQ5Q;&VGLoc_$wu6~yQ9?a#RP6LS@1;@@~YYmR2^tx_k=8TQKZ_;F2i zyaVCw8)E#({ggc8aMS$roTv&{#x1E-v0J~``NI5{kNr192OasmRsf2Y3d<>nk!s3I zt~dx>p=HpPPs00*$O`6sYf2(Uu4^$_eRoMVUt^HKI<>Xsb>ofHr_HqVBqT!+M!iq? z3QC{>%ca%UTzQf6Q8UJX#$qG0y0visYngCm$mFVDiLhiC)Zz(IFPhs=O|czBna+v` zG>2zt=C7K9IwGVTz}tefm7F(4KfkIyq_m^WMbW`~`@QqlOmoIuxn=7kn(R>lJcCw^ zr=q{Ulb?{cgrM#D z7{bF{Z8PZqZ{dk`pcz90NyWj-tA3F_0LspO%IrNOe+LDG`klXbFgDIC70{w&MIerm z+corCxil>K#v&*!C^R$_w)-!i#|zk)?*j>`s}&_7VdCJZs;EdwOJ4?g=eT=2f}WJr zYWrJX47bjBZCxE60NaFaH8nMjje)?`HrCez0s@W< zZtm}FKMaZK<`)zcgfBbt`^nJ?4;jNJDk{p)PY^K!>`DR2O#aXoKBIuIfRfYpJFNeI zC~W^P1?vA>va%@{qW81f!3evP#?53wV?}D|05E6Z8Ei8tzAY!L}?n$ipuo z53xCCgYQCJO;-&sbiTfmAw9mpv7`-LvS@vr`%hY2>3jJcxAgg?v*xwX9SgZ+0}D*c z)_ss7P?v@1(@;A!!^h>N@lCsjMoyb{|1C|vPzFJZ;sdGu3*wf^ug{+b!Of~Out;@u z3mOagNK-^sV}|CNX`JH*iCS0SW-m!;X=9pobac8~j`*Uvc~XqFd(h(VDG2BRNJSrDR|7c-bQr`TbE_@9)VMMgyU`uX7_juj!y8vTE3o|%xt6Ozeu!UxVH z8keJ^ak;p-6p$7a4K6yEKO3E+yBXf2LhtE_rd~5 zF!Wpy-g0Dobpt&757#G}gPTD+Ro8EbOy&+%R8k@h`SIfiX$U08{C=e3APpvl^|p4z z9}LkmvXrOuzS;D3;ucK-Pk0R`0bxO$F5Tq?#td||-t!k(V%s(6@p8=^DaZYt9g_;{ zjvVQS03&}?*mOO@y8Jc)P(FMcRY(g(# zCEo?9&uRYAG_S$iG~H|o_e%yA@yrVfDGA@uzB+^wHU*s>!=@{V_clmKBs?nDl!s?l zaRrP>icAZbU~M)BAH%>h<4#KLhEto?09`uRn=FbxDVKqzOLSgn|j*rzrJuV-09?MsIRYAli9&* z0b#sj2KI4o%S9P5DeOfAK{hbb3F6hpBmbRw<$&p)B!PgBPXx{A7zcCZzlm9s*!q#* zr8=RiY-?*{Ghq^WC3N}yp7B;n7*kViEGB6O-dkM>lCfI%1qMlvq6A7_{g&1;Q1hx? zrpESX?I;!N3OlRseg*oFxFw;m&(TJms~JG4UgzHug09v82><@q*PoMFmCA9VO}nAT zN9Zmd0UlLP;|^U=Xg)t)1L^O}>s!T)3^RoiitN$dyWRV@L3{>nG#W{{j22%O;EVU} zd$W-4OWf5D-`<%i85nXG3icJ0gEFn8t152l=5w2A%Kz;6njE?pojWFDhwW2tHx`mt z3&hNFFk#APUkB1Fky(7JSr;T*_skgB0pQcD?Ssz#*B@H1>~z3k|Bv% zzEk3g!$82w5Nm)x6_79pgv!XsttF_1=1l>#vI{=Gk2!Dc_t_nt|A425)Z1~1oBi=Z z0pn~Y(-$hv`Pm6Tj;;B5U8n=1cD>!+>yNT!d{J7hguWT|`0lRsExmaVkGZ%@ZBEB$T8OlPLM)xW_|gQ0rvBlUbyOZmZP-Qycep@qZFS#+ zB*_<-EQM^hVnhV~Xz+dT<>iec!M9;A2BBEV5)Mjjuvwz zZP}dqJb{(j+}g_J(c|PVJ5?q)+v%%`@``Ia%OvDa$!6fq2W?g%*U|OcyPO8}-VCC$ zw1R>HO*Z@nx!C55^Ak{^`!a;O#>glsUv1#fFGCKL=U58qIJh}iGzf-tUt+aHoTY=J z0wh$TgrCeHy9~Y4h)8bJz4l|F^?11zu%XTbnu1#!joH`ndzAxm>Xmc+rKyf_nU@+*$!3B}u*pe> zmp!(oWofYvSV`YJ?20Z1)-?m)0W#t)tF?eN4JQR1U93h#RFME%B&5D(kkO~jAN9kD zEn^@}2D}2@-qayGnj`r957#H-T)99g&y^4>U=pQYiHTH%Osl&`M{A7A%+;Fd#hhF6 zT=HIiy>@SmZf5af4FCi-C6!}X1iDGqnF-1Lj z@Yw-1nGR%%j!ru+;X9QQhIKDX1!0NQKVM$mbRRe4#-S)zjp&bIq{(^UD~z|^XgNLk z!8Pemmu^up2b4Dma}`llTV*B{=`q&My-_2Jz>BlrH1t#oo7F4v7G{uY2TJ>9wQj8V zXdWIbgsLJWfff9fRL=-#8e*g>Wyb^z`QHm7EvoldhA~8pH_&S=s%bQuAANYpS*Zr) z{>Vw`7=(XbCWQ06_G>j6Anpsd$!9Q+yp3+V@zc&YLAQkp4^s{?xuqSQ2)3Fd`%hg| zP4wFlp*H@9hldi80)x&e32J>uW9=ES4D?MbJ2eSHPpAY;Gn@S0!qnoy@<2xj4PI~= zfD$=bq(rzunARM(OT*x|PaoLTyKobBGbps>(~OAmILL#}4!-Rk?n>CkTeDrFQTz$r zBKT-73P3~wBgX_DIj807T$;z5K+F8)48OXcthuoq1rI|=8Tc983j!600 z`{bti+#MK_@)u|e-v4wTyFvrI@9EF zSl%2O{-%nkLX(I=A|waX_dXpFvqrWAX&ir9U57cvYX9*)CLI|-Fdmx1H$CJrFUP%7 z*`WJjQf03!(ZyhGH{T-5+vt&FgPxKuVq~n5vvcno({b0mLUYD2M(MiPa@_9apLg!; zEq$WWEitYDEJMYki&;#hidpTTE@WJeCi}uVNs0K;ynj{B|FnQ;HQWH^Ik(bh;!9ewrr^D zrTy1IzOyzu8&(iAl^8QxP=Aq&_QxEE{J~2WgHG49a3<}p#xhgL!00#zEq-?a26rR+ zA;v}LwRDvXO_-#fI`T*tJ@aQT6*a$NTiUAG_R)*qm+?O&2>agXPklqzA>Ri-Vbymy znoqIE9%_2B#g2!}$}&hYyLop+8W{tRu(u%=sR?dC{_7i$ zv2&{@o$lq|JVpDpxcD-d_{(Ud^IARL!uPfIH|dy6)|(k4F_XZkiJ=7KM7|k-u`Yq*G0JxG@>=BEpj^xI_V` zv*4nXj*^mNOS|;FC6le&Eq|qX(shBGEh_!NAm^N|ZG$P;$cZD}sht`oZwVCxGPzK9Hw0n4a3+fjQ%>-X;Y;7eidV_cmgNOp= zjd5_C2=hlO663Z3&=}4|^bVk~8V8kEA0RoJ9BL~78Vx1>0U8p9D;o1rX-%r{ZGScSRJOix^RZ+9^7|hV)xp>d)1rQ zh?lOgJ8dTQgPX0)q)KHvfG?&Eo5xk`bwo?jC!y6hX}%SjKR;)2<#wnH8uP$y!Bh&hQ{QQV$ zgs(S}P~_o8z3Y3z7sV2VjIDg~K8T*HyBb9%MnSBB6HIdSzvxdhanIaS)fdM4M3Lg58Sm23bUmgd`KtDTgb&(Ftw@m%zttiv6 z)-X9#+qo#hb*#oNe3=AFbW5}tx?4ywZOz)9O%6V%3*3f@9ErP#SUkSYEyKXIZ)F$o zPyh-EesE*iG=H9T9Ic6py}Fw3GFdGWXh2WKqTarXQ#5ivehvUe=Hqs=J!Xt1X7?hj zsU*$MRKzuF78ed5<6Dc|)E;FtkaD<6$oVf1Fut5=mu+!+L>cUv!L3>THSAKH%=L&f zv4FC*nWv0c=|HHhg>I1yXJB-_-_9vAe~?AOrS%+nW_Z4GQ^v9|7?f z$Q$5{X@2spa`nRS5x*HCoSvR5ooGp&Tvn|JW@0NyqQ&Hvmg@&phA~7B5(jnn9;9do zsZx)+e*OI^Uc3EnwQ)en?X%lYF)=YMYgQO0kdoAUBx`e;C9?Z5*O=FGq*E$NDWraM zFaDfiUApoqHQy(ZCfm5scfp{ua-lm;(m}z7c``dAEIKvq)y<6KqodmT`aWqM1P1*{ zL=i!ZwCczFq92y_Q zoPXTqS*t7@eeDc?Y0>;lr9CMrBM7}txjn`2>s|773WQ*MDIzS+^=DvK#i5LhO#d&v ztmK*>7RY1D-c;-Q4tFXBp_a>O$@d;EiBRW_oca<9>xiZUIR{J11Gz8cq<>|y3-xw< zw+5@`;N$&Y3Aw4>a2`$E2{!o7Z{DZcsP*8>Rg@jsp5;&_rc9&E+i#h*%QVA>Nr zYP(@hA@`W&`%K=QvU_JZaOUR2|SYdM>BW6ou zC(Ckj82g%$ww&f5>>~Fs-qa5R-+bWdnf76EZYctJL)FvM6Fh|a`uc8e!qYE(26q|N zkv|s(S-`&cE4BFt3{+WzGp}-Ts*vb>VR0Q76q?KsPXwvXenAzRlr%-K&R-z7JMC8n z_nQjcS;pSApcw9Ve^pb0WCKo=1lxoA|84RkqYF7ZJ2NUVcAxu%<(UC82H@Uz@7|%5 zIJmfDZmbNl0MXk32xe|>z7n+e^A>1Lhg&nQKg*#U#Ky*w^(Lr*M6|iM$UZ;N)#U)~ zwR;YlgD)f&Q1{9AF7z}sM~gj=f%~_+X2V0lm3GR`jRrznjs53mpz@r|MKN!?!6XZW zE0{p}OgH#`rbXdTSg*%F#KgqFrv!eGp!d!b;S12_9|I%$4m1@A zI@GnP53bCeJkjxcdwb;nzNrE7h~8Oih-av%cHR|0^`ep|3z~fJ`S*S_0${pX67C3( zb#QNSX=Dz8L;(a7gySqp38!uVCqPlGz>&4%22yzRybMlOzr%XMUvV4RlT@ekm4%jx zNydkTdAd2+qFqD>Je#%vOh7UOR{*ped9njQ4izvo|j!;vI9?REbU-J(glYgG60@ zk6)nO0TYjw)*$%!z;6(+GvC3c60ZjM4pb>*L-*Iblw2EY>xRZgodGX5x1;lutzi@v zIRmp0Zp%!--e(XFfw3{u(4dBcCIRnYE~;Chz1bas?xj-iFvuj61@{HH&BVmyn#<%! zSC{HPZ9OI825Kp1pb%7p(gAbbPNkO?G7!mEg9%V*4qZ}IbQcJJ(GGM$aj<)`va{nr)JfY10~~jqi0;AXMQr9g<1(T))?{&ac6y(}b-H-l-M-QgWQ;cfo6GbE>@ZggA~$ zV0CH9>(8zo=B#lSGZmHhEvtJc%^b?vO%Ik6|69h(AD)Vr5fKu0SoFtdWo0@4`ceO(;obl2!s@3O zZm|A~LTKQ$yX}Hheh7F321g=$-$Fpu0G(I)>lp=~Awh6TbsPl`B8~Yx@Nk`WwdE5* zcO%fhVw0+$Am`ZifgcU@Q|`ZS8{Pe>{R%2kD1RB9VOa|Nxz_^DSdojaWR05{f@ToD zA_G@r!S(oJ;1o#qQ%N7_yY~H=I<;ex9JAHJ=^c1}uznBg18q>Vs|RTeUQh zBfTci5ll#Ye-)jW*a_~W?#xV@fBBG`^yJBt($Z4!8N>7_L9spM$y4gDtK%QN?txMdDQ&ZD z0@DTL7}SzDBqbA5V%@>^HpvbI3m-*t#cRxz8@P=ieX2Eh)9v^?{QImr2*ta+iWqYP z!^0w)q(aXrQG&KNs2}51pfaRTZ*sn)z$di;Parw6qSZZpXlMu=9vrqdV1h;s0l12r zh=_c98;t>2w!8tH-{?eg=-e>;Pk4&-7Oh@z5`QiZ>3J~Osur6=SF=#^yu z{xo85f4_aRF@}jIq$gilS$U2m1x8qZgEn7?!F#=cveOUD-f^A(W=m<=mcN+{1Z@UI z(H<+e`L~@~eEvIK%xBl63QdshL(`xlM9-wO{JSEy7zd?bDYF@z#7A6)2Sa^hT6U%! z)-&`>ep9ee*IXM6`}Wzb`a!|cYq(l+M3 z9dyYewA&^o2OCpHF6+SPfC_5F!|*{4Z0VI#ot>QEWF_a+xK2i8?+=0`G+m%cZw*U% z{}qVn_&6=JbCtZWE8q)k1}iS5d+4l`WlIR;<>E4UZud8T_=6LK8y8v{Cp8mOlk-F+ z7_3nfVbrxSPaZw^? zKo(TvcpPQttJv7sF!|Vny1yc5(R4c78fJFq&VLIVXo&X_Zw$aQsHiOUDLeRJawEI` zoJc^3{tyOj<(8q1jb}hZ5~6^mqDESvy!#)lMhu;_5zD`D{0>q$+;?FD<@0KO@!R zn{lgm$yPvJ0$wZzMzt_(!ph1@MPbk*1SVl;gTQKNd$*d!>jD_Ze@IL2!;yXn$|&!( zX6V@vd~ptqeUeI`T`gSWYk?mG33LTEHa1coohQm0)L2%u(s7i0Vs^b0c@+8(O0rHP z-0h|^{Q$V&9x#+iN@07A_zX7U5vbYj6nDJ=mWfhg@P1i+Gg*;%>-BFqZ!yo~`SMnl z_wHiP@2wdjpyW_nVbCP%wW|N}J6K1xGKCfFz53mNUK>5EIZZt`N21fRqgIkc=U`y<1Q&N+K|!dWdEJt2W#KB`Q?4KUqCpj zH7nLFUArVKH56o&_j3wHy74%SC?t_DEn z?(KQ}(Mi}R)7!TT4%ap}-v#z|cB*)XhlkIW6&Dx#aR3Z)85J$#m5?yzcChY?dPM=) z-kmP1bmPJ>I=-F+Iu=U;e$~L@P+~LSmC2p%lBv1}4vWhPu^czkLG8s6_}^|8V;Y2z z?u~&n)&+2|9gw4l34FkGFlGFDw)i=VI+*^dqjrG`Vcv){C``rjPXhkz&cWEjOjbrF zeAlJ}iV_%rcEeYVyIz0I!q=hQIfAz7)CW;WQYykUlFp}JB-p=8RX)fq zFp+A_Lyw2ELUW)Nt&hBU{H*9dYeJRU(5-S$$gnclnBdc$C^h}NUtbHDecTsnKJ_?U zHwoA9yy`lA3Ue)~Mo7Q0zP@{)qkeFmL0Rh|&0ui-Xx`!|+ z7g-Gxl#?LQG(hmOo1eOjl}rG*fXR){uJUGJQHvgcO_UJ(cURWu_0XTghk@sN(fPx7 zKq&+dhcbSRGJ7O^eG*JCAn*QlL^}B(8TAsWvlooR6kbM)<}Zg}1O}?p=-8N{JO({O zRt3JWWjM23fN@iRWarlUx(pN(FgxQfZGLrBW$#38Y`_5s6|0HPPJ3YcG!C%E5gv~P zWGMc21LS_43F{}?07WWELdCg#Q2eA4;1LEhMc87CyBl{)>?aUp&^W>Y9pJxx?cYWQ z?YJjbPyJ|IA9e_!I}|?sGIGO4-#zS&v9XK7?9H{cI60df!sDy)jYCkvX+>@SwUM+t zKHw1gZlz9{i{|_Z3Jn}j%BZc&;GX->=fWQzgO=SbAGQzG=zpi$UUseAD)*V;R2Qoi z`FmZ`)j?3RO|86|0zX5=SXnnMD zIqEYFebqng8|UCMFFEo#lVK$;k}z}0;_ud^YG7IN@$tcdJXKXy1wb&b;|Mc9guG@) zT)lc#M&=HR01Pja_k9i}VFI5sEN#Ua6$%GZA#XfELB2~77SkuO{nZd+P_BxJcj_|O zR1;yNsd@Feb>8P=;1I<#NbZ8_cyyW#Zh#PUjK9SrkjQ>x%8GiZqeGd5ltUF*k|~^F z392tFT!zoaQ40x@%|i4;t?e3C`XdZgkfva9{N9L-U;kuCddO2``#I7+r4-qQTyBZt&ZO=TqR&fZ_oSPlw_86^F#= zG7y7iU4ie>UvonFS)x~}DuHvqEL4u8s@#SHK{rm#;LHlN2p~7KbaY^u?}q+GKr4zo z^aTov@+iEygoR)2JmCR!r!o*~9u4yO9YzuoZG+I~OI6SyQG^$UOa@R7*f$_oiyn)K zlp04JuNId9eA0Hg4{@5O7y+fx8yw0T9(he5_pVMi`Y)Ko7eXcEet6Fx_wwc2HBOY1 z6%wF3(#rU3gUVJT3x*79fc)UC?S^Imr5n`X2tdbhSzH3_8%+R5s07ZN{0`L5WrNQf zN=rAAHGUm4+`k^v~yyV64g}wlI2jG5{Z4a5J4T+a?r8GCU8ux?`3{qG1 zV5z5JXb*jaMEaISUv?fGJ%n{U-uN)n8wA$iYETkn{SUkOoyVFJn%ON?aHJ=_I1YA# zo5u$9qHeU{;0tKs`31l#oosAv7r{2u^yg<5Y!D2$|3K^H{)0HnC-H+bEl^$Z8Dgi; z4Fg~R2?!=*f-;d0y4<5vo(KGXs53rvSy%7EKsiDserUY9p8@~ic3MH9#?wa#;ncU< zh~F@0U#u!(@7te(bcd$CIbKl!HbC4)ZVDUUVL8tg?UxoW9m8EPoju{)A?hpxh#!!A z*NMRZt9pFvsVE2879gAVt}~nS^N+Im!=xaObjZ5!g-M4mpdt@;c4FVyf_@9Hq|%~f z19Q#lGpKQtzjVCWj1dg>-O1>b`rwwTMkNmvR+t4J!76Lx45aaptCw{bGchr({&=aw z&^ZH}ee1c5$@^1ir{Kf(hsFc5703{KH`7YRdjr~_6K_CYSf`b>%+v1ZPK_Ly3wzUnHDG@UZ#GB>oIgC@0GE2egUgLTHDwF1w*}t6y5k-*=}^w z^f!MNK-523Z73TTxH(3=7mf3>N1feF-Ra&!usc13kWY3-9Ru>)1+WO5Vt5>2Sq%=R zQB6nyz|Q1k63-XP(v*0#H0U&KV5|58RTYv-@t__^AQYjRs;d72A4Ou!fMoLlYlNZq zN6%mRduhPoCrhWpoM7ND)ff>OiJ&RCRPyy4q$+TIyueVlKg$D4nJemnc){)F^Z`)9 z`t@+bWfZ9UL{bw4lfN~2K39hX%11q{+ZMVnIN(ea1OPy=`jSvoz2>k(**-58mY2DL zv2Qec+|ve3pmRsLJI&$Rn%WcICqXu_MIs&)QINbsT35zAKnaw{J0g6A@y6V{!SCQL zgxayJrMqnxVD;Vl{q4sOR84`>dZ*r70Dl(hr5^}DFyg9M4LaRX{ks*_f43s3 z=F{o94u_okJQ8fPDK|$|p5~8!DJJswKLLw`8Q1_|K_>IS;Ry<`?n||lXr*6pv?^R< z7*Yjh9Iu`CfB)VBRs!^08oUWiB_$==d}Ahvw91s09g2@S(JELp1~Z@$%vo7lS^}Wf zk%Eno{Hr>01U6j0N~$B%Llbj7dYu|}O^F*(f!1@B3iRURuRv_2e0r znmz#x2!~-IhY)8l6)y(+9x4w(pHvqmCMKr77>z=8#MIh$ycQ4vQ^g%wfiOau%!KTh z0?j7^Guk#2WI;H3H9k2B3g^r?0JH=rIBR=}UC{$|5>llz1RWhcx^j2(bBsh9(a2rY z@e$~WV2FV-2*)&)r<>N`WK^5PaA*{q#tS}JH4sq-MGnF_!kc{=Y&_tpg!vUcaSBZN z2JXPf2JkAZ+D-0@4GzfiC@1@#srClF{_KnA6hT(r& zK$%X5^1(IDZ%kdT+>A%FULGEw4qey7e#205g%x%fJ2wy(6hzyK1~?mz!UfzsJBNqf z4C0faMc`AyfmodeA9v6FwQqwH6DHhmj^LcEyzef1FAK`iK-j*o1}3*rAg@rd z`%qbJ@!pv>wfx9&tjr?mxCi_TK!ClVOu*e?EAc8V|C+f$Ue}n%v4p4txu9VD-nL0v z1OQslJ?X!)-Tm?sPrVXV(is<^W^=PlO-?e&2i*y{%B?LQbZTtB4vWTC0j1)0GM3Fh zFR;432IlObH#Kmml`L`BbY25|J6muiLfkk*`a-2B>g{!a=rske9|Yau<`0-rsxmsk ze*}?U+5Y}a(-}w*Lo{@Brqzza&`*1VP_Ec0s7b01p-w~+(jS5h4BFob6lJLHj+52D zfMP-Q2?lY2B6pYzA^y=WO3HtwwO!U3-WRTwxV?!>pg#(c=5d(fsNM5S@>OajpfAN!HfE>i9 zr@Oleyc(QLdCqZ@O5gM3U~L(Up<7$>s6T@woH^`jXQ2D(>OJ4U|*P z+eW@F@AdthBbT3p;b{W5srDMc(FbS~IMhq9ko`wiQ&T__1+W$fBOsf>=h6=aVI@Dk zgh`4KbWu<>P)e)7u6;vv8ESs73a7iZBPn~YHOk4!MSlmx@Tma9H7w_o_x8EG$Sl&e)-b?3C@tOsE?Y%W^cJ4h) zy}UA90NCIH4rqc|Zsg?+a)tE&elvyp>7QtHf?6^6DWZeA7~E!{Y1|uqJNj@Y zkgMDJzu#WrxWX(8Cu8#dW4@`hm zm^(i|cLXN~Db)h76No-CW^}`!;Fg6hz?aG-FE0<|A*U1No`$x{&cS>)z5J}pPedPR z0@zG|f6&p;aL=!RMH6~CqyYC_)DNfJ{NOm84<1;y6I$nH7!j4zz_Da;>$fpX$!R3v z`@~e4k>_wY9n8|N;KW?Y{`%Ah;$h9g9ROXx0!3t5=2yU~-rn^xCtR|Mf_Qvj=S$|M z18_vx*4E-&gfc#TB(tX0_(`s)D%#7 z<5c)5jETu-$L7~xc|w*0O8<7TV#pbwui1bI z;9P)H=sjpja8qRhLBN9ml(pNCXD;yC0MqK+7gRtSPJTK}Zwjbn5KdiKci^8M=)RC; zXSaot3$+E>$}(go{5%6!7Xu|_&E2n@BVUEZU~&Sw&I|5maBf;ALXp|Yj(aCZK9~s) zUGLj~EfU!tjz>Pk-9^l8Y8?w{qGSdltQQRl@o)}s3lJstg}v9uLAmzz@xfgpuq816 z=y})Q-@ktkI-AZf)NB>zhBZ1=H$z`OgDwF@5#eVw4A$^65FIQzhohzD;Aa&gr?oLV z{c*Z3mz$P{WV-N2!|%@kk_POu__6{QK!j=s@fr!6!UCMQfE9~0NRZp1us;I%E7K}o zb)ftsOQ!Yp>(}ALMM>#}>@6N{ZroRQt1tEj<&B9m$tRk5-<2h52c0mMeS*Fs!phI6 zavOk0PU)qE_V2wNtzse6L^u6lgx+6XP_O`G1=8aTZVr(%AB{%!eU{HhKNJgfyB$Rv zY=QyaJGE};aWU82&W^n_h=j5S*vn(yR)s51*W9B%R{cYFk4EU@$Daod1`c6eD$xn# zIHKipAcr%_1w?PKL*`kg4jI4)YME%%eG&WQ3D>gQuH8^TK<|H)T)}3cfFM>st z;I9PLfd5kZWz}cpydcTwAXHArKs>`*Hh7;s+pUklZ4@X)8~|MTPXXQP|cNB>G9)_vqEi)Q2+7Mry5|KV36mbC&VjFM`I} z&5Nt=6OD|W=&S-h`PR4sAw|Lh=FR9zcB%`=Dz%Z-m!#Wn*YltuLKE(2$Rm9vUvj;l zES)0d7bq27$E~r-!c^+KdeoTEi7I{f>?M~SRmC=e6hXcvKPA27W!_ZVBo+M(czFu~ z#x3;dG*((G3c0HX?~Jpd!CCpY#AD&$2$@u3CKyw4J1%gvOtu)6R2Y#gv{nL)!@eA+ z_^!f4*|nAt*ib;98j5Lbc;wq>lS)v$Q_L63RM;9{yp9@Amhy6vL0Yyj$w+Ol zms_=IuPL!o+(nKoM64ieq2qGw=3=)wv>jFy|YTgR#7Q_iW0JCBH1dLg(xJ6 z@Vvjy^SbWqzF*h#dY;v(dI*I z@|VD4+`YZ8-`=$nm%(n5w|jDz{QlH7x2Tpsoiw87AG%XGwU?h~=b@F*%9ZvMH%nlu zn9b9Q;&f-?HniZ4^T1BrCR^5J!=~hw@d*>6gllh1M^I=^uv@EpMEm2w3w4Vp+A@PZ zlJX35U;Hww4?K9yFubqe;zWV-5O>WZlkR(!Hnkn-(up7?9}jhUyHJfCBAmHGTOu0y z?s~{;KIZG{>pQd|fW0Y1Dl1pcTbxV3XzHcz22g$)ZnKg~yk=v`U109#;)sS~`Au6J zdmKl}uP|wdBTgVvVBW@Jf{n@KDJe(CyL@$}WHGVyz(Lx*g<= z8)J=pCB%_(v3PTY?_!cyrpvux!n6)@7vt`1kQr&0=U%dDL;EAnhJn*38rvaX3GYk4 z^B`*UHbxHgfFiTpma1xMjh_eFY}jtt^7u&I=2AbO5QAsnzz%P;jqZ70-AJTk0ULpF)WvE4b=vUnV1x+g zoKiEI=caQOs_fb(4-=-)?clxLFwUSuyhsRu!p2ZK#hmXG*ZC=ZG1Y;Qsd-lLF_ii= z0PY}l{!1qQwVUKbGm=@hc^d_P#~;&;CR@k(j#z+ZgIY$aH{fKXj{8N)4nEU$IFlbW zAIVW~I+wEkLLL;Zv=0Khb&n`ot7nibr&NdMChT8H`ajNF2QZ*HIsWB|3@0z`B1*=GD8ljrtdzKUQmpW%FFu!bim|DVm7UKrjYg(p*FfHgBT(9a7r6q zN^Vo;$Lqc^6pjIiUtC%epRnfupj5ReuSt`;hgvR|hbcfVO7G@W-}*{JxkJVM0Nevl zfRyIGz%+gyt>f&04)x(3tDWpkE5Y&!#r3C*|JtH;Ha8J~wr!+1bS89eCVM>7chZ z)aVD~yf0M`*%vy^w6%t@i(`L9y`|W9%~tqZ1}CZnsd5dZ7BNF$8Pc(qXOoUU7f|sk zJvmr~xg}kYTf=j3-$pSa;wR3SO)=I8<5*_@%a`a-KX8qGeVq5Y4~(OS!ORnMAz>H7 z>ceOaU1z)YFS7ZuCr?{xkvtYafy;M5-!I=n3>o9^^|U%4>=)#EzGeTNpxNWlaY1O7 zIQ>S3{UX#Bk-kLe)l&weiwlKnyPkQ2ProqE)A zLC=9#J(|K?y%R%v7ZXj=@5C+~P*m1G>5k;w7pHL21za4T?NHB6Q^Vx+3LUKCPwpMc zk0q~U%4xZ@2qo^0lOse=~v?%fSjGNuKjU1P4a@yjotk*HyVU` z>|-f>TFRAZN`8%PV&4}@{O^NTunQ2X`AyacfE@-x!2FUPr{q$n3#S2IM$Fy z)+3L}9Zo;Ta|f;Mb~#+OB>Vd^Jl6ZFe$+|UF-Yu6p?R*cQSHEmKJxZ}O3x2-xdM|0 zH?NXQ`<5&)8%iX>-57I262wD`;WHfOvv2~;Lu!VIM(*Ok#>*jfpIHI-WdVGnx24~LxWN6kHr&v z5BM%}Uv%%Ok04d#?G5D|C#)$J48JI{B6h^(CxqK2Ge-wZ5;MkNnj6<-1+E{4(*2Tz0dv?MY2oC z&oEV>ANU3b9?S_pM;f0&Df5lt>G=r%zg3*AoL8FpAG!%O?ORXc%EkaTFR< zD1`7-+;c;rOaPR_Qg)kf&)KtcfTQ+dZ(;iRpYTh(g4teRAVf$cP^N}Pv@WM}05xT` zBm=AlRlEwge>V5ToQ6{!Q!{HHP{mVlSnfzwJ(Y>66k~l7Ri<@xDA>vIcNx`~*pCep zP-D<#*hqkfWUhb+g-p$m6XzH4D^u1gnd+pWVFN&#Sib$gLpC7w$RU%~-TfOdxhFsj z30;8bAqD3KREhNvexF@MD_r=0+oaDpe%O+SS?B=y_({xWO&;6kyq|%X+RvSBfOYwks=tFLd}uN4vqU ziues2io~vyHzbJUHTcutRviRxltC(cbe{(SeP-*`y_*hJiOjC@Lgp zuLJs1PHp69M^GW>9knx?8HzD0R$eZ`1m(uiO^uD^6r~e1`MD20Jq`9U ziO&i;ul(0X_LO3$ni`N9PoNw*ad*b1!09xn?0lhoZUO?SbJ#PG?*h5!?Tv@$K7-nc z|1K!_z=-YYvATYjFJnFqV(GXB$~qwXvY#IaB&i>yXctqrRAoV}Y6sd%2r|$n#+5+?%6+w)>VjY$$}CKy9A` zQ~4KoO!=R=IM$hz{Fk+4)5z#GOqc`>Z-iBX!P5Ozh~5!L8g|TQKr)V=P?`~I7JTCw zeQrn!7Ve;%CGAhimODV8lBd0ljG|t=c#)l*jr^4&O*uKX6E@i{DU=9$2vptH?Bt=F zn4Dj_rc_OA7O5|WbK$a7Dy4NFP9f;DGW$*Wi6qBaSuwK6BFFy#Q>7^$L%{Fl?hetu z)~-3dfAP~bK54<0DmM>r?%ygPNxv`oAK5SX`Wk{HCVu^Y%fcqMme_yIivG8n{@;Xa z+avDUFznW?TkqbPtm$_fdEp&$9><4Z@Hhj>4ES~^5sQGotY_egHGX?rkr4j4rQuZY zAO^{D)FuEA1AwR@H%C0T(QL-ov_nnGDX!murH zMC_JKWpr!jQ7Q?!8iDg?QWKZF+7Wim_9L4#SC}U$*6dD?8SA1H+{DDzJ z1!{ItLBSfKli1_Su!G&^InKbW`48}>VaOE!hL-m}oVl-Gzjk8kuHd|G_(8 zN<(?&amgOFz53ks{JC@bv=e20 z<|&`cul|^rxVXCFXT&IK<7qIc0^k>vsDYdob{Vv{YXpvjG87dBuF$}_{e~t40lWM> zJeEL7<($Q;5+Gy{;o1SHgn`+RDr?-eOySbT+# z5D?gv3ALxI9f+OH1p%qVsCtQ8U4vV7^Lj3#>TXb*|-%<8l6OBtA z&CRdg5+=i45UM;8E3rF63F5w`1Z1yc5lPI>&)@voJ5C6##_S~5E3P>fB)tD+yh;E6 zlk|xn|NqtLp&*05C?g}YmmQ(moSfoeDMHx~{zDglD)y5MSv8qVE-I?C1FVVGZ5Nlp z8iqz2;N|M-3MtKNyP3BWBO}gFDw3XJ*JrlUer{8hiY|v(5TR*f72VGR1F6ch8ZWQ? zv%nvwiI-u{n3|Zl1IC6}e0yjDo<5l>|Fhm82k)6Z5P3+ty1HzErfkQVHlJBTpIG0^ zvTPY<B%m0GC^Q~oW49@V!NT9)ABz@AQW$YgP5+vLrd<6b$UG5+aAF?_v+#*84@D-7 zC*ct=2FsojYPfm6j)PQ3M8Vx>WM(cTlLxou;ahp_3jK0XQPIL7-hciSSx_+u0}sSa z2E@aVO-@MQeBCuOx;-q4fR-Y8pm5Hr&7mN?0gF|=Qq%wWCm}x!Q~$ysk?7IW+dE@v z?Mz&bVDlVQ7zCf-(0VJnFIre6iYNrEh1fBFj_c~u;4;KNFdA@A>841a#{+h5n-*?l zB<^8|XTJPI@t@x$7G0tdvg~KF{Tqpk7S?|Kuf@d?`40r@098-t(<6u3z)*yThto4K zFyLB&u&@E2yZ7MP@^o=Y!h1t!xGT|@s7a2$p#?-kcYgeRFrJ+x8Dj5+KY#8U-F{zN zyWTYP_sEuQLTuqCQ08J|s;a9qWY+w96-3&hFro+qG_VP=k*x+6!XTVEbnDty(`iFN zcGJJu?BzJN(QLLPY-vSD^6oV75ws47^Q0Rc0%;GO^f1190n((lph4J3@YpC|@i5;p z3&6qn6RpY{!hs*^Vhph#C#NE)LZoHi+k1mS6Qb}V^ZBSLDEBx(PN8wZH?#F<=}Ql2 zQ)~bZVGxX5_X`IzlH%TPZkN>>qK@Owf7sxu&nAJ8LCCJkF8?V&TMOGJj-LJ*llOj2A0)^9`p{XUme0b9|r3P?#Barpt7m3Q0fuP4hSzG7Ixv|$b3MjYz2UhVm(W=(^q@IH2BFMR&B}95HRH0(&I7G zEorJtv=Giu(G9iSRWUw?ktpLPQkN7j(8_S4#QlOaeZU(c6$qp9R{9KJ1;zuvmKYgS zisoX*j+Kfl8`Phe=&1g>PmChzBkOC&c&f&>4RK_K{8|g>&Gi!ni|( zj$rx-y-BdIMAgMOg)ejc(DTSh#z*yQm#dIyZ2T6cK_f|NYnP)BqbslnYB4iA%RxWA zvW%yH2}+9gvf!W~k$S{V9gxh{fz*8rw4qh&!MbOQ+TLTs!%*a$fcI4VhU%mr($lED zU0hr&!il-s1&z+r`Bm&j93Y2mZ0I4ZYsPDBmi>&~H~x2aKRPNX_kI%UJMYgt+ik6v zI)Fl;v)uaQu1K`-&LWIO@Sc>O8V+f>h0J#DZ(oaEV?ofaUq9LqNA(GmB(`%1`I3$M z(mj&%m%4;$+4GC<9n5!eBgGi$r0f!5Xjj$B0EE!2?*}U|3dd^Rga>%g$`# zCxELuV|Qp%D9oMMLPR4D5*?nV8F_anr>yy?RwDEvQuPp)6Cvc7>pcQu9bb$AC&;$_H)U)fi#v+z?q*7O`oW6z+? zVta2Adf343Th1-X{Kl9c>wC!Bmn|^L3wP@N872eHh%p#aqVf3xT5P&LIqWrwXav#> z&4Ug7F&AVGi6YPPD84V8+zPOkU?Aa}90Xhq;i#NuJeCk4Qi7kR9$4eMWx`RQ?HF2} z2tefm#%PSW8k(Be5BPU7Q~BJ|mN+U2<#a$m09)JdsSlwHX8>yxTHB+q0_5>S!F)S% z*ZcLZ!9&(kxpn9UFky~AhhVU$rDZz+m4GqMp=}Gqv^>VhxnYBrV+~#6g%=*Zj`_6I z19(MuHqbwMsl7aB4rBo9W$f$MZcwDXuWCLiPth z#rr}IkYbH^a}h@cT?IBK>C%bda5YkFb%QB`{%@d^z?YBh)L+21a>~r%{?S+X4Kq90 z@?L^;sk1eC_WUGPzC_}UkWj3tguKmd*=vCkXx=PfNEl%)-=l8zcc>TDG=nR5#rf4) zC>`%vVnQiAhG7FGH0e`ci5s_!wFxl}35ibO%!CMV*No+|&v|$kA*7x77q8(ue|#UlOJ{Y&e|yd!C`s&*{p1 z|M-l`@nuGTfQugav5(7?o^#!QSM=?7Rwqs;XmFr>AVLrT;2nN_xj0wj?uMpyCluw{ zW&QL^Y1Vw^uPxwlnEF)m8{i45V2A#h?G(zJF%d~Jrm3FN)ryLV(K`PO; zcZ|WdX@NO(M?jeT@R?WVslq}-d$$hph3sXe9*g6Oiy1QqFG^MY|u$g zD-ILI-sdOG@dC&yTZh~MY~u`y`JD_u)J6?oHtrj6uwwtCWdd$oyZs@*I44oMA|jfS zWYfa(us}e2Y(=N`AW3?VbJ4d0yuSd6tMp^eB(ROQe=|5y6FP^?zm0?|qv8kchCj96`LcXOO72L-YF~ zpzJ43uL$aw7k+LwdwxKCegXFa%@IL+i&=_cC&Bj4EF7$Zs6~{r$t#NT;n5o22Qh~t z{`%|>1km>v&6Yzd~s?IUT(%v%n5EdO7^8IB`-@-65>QnyxWb+vL_Bn7*!gZr_~VfD%s9J&f~9 zAVUJ(o-K^n9yx+CB0WSXXWS|YMo>&`@6$KG_%o^i5>q*~0!Zj;Z*QObkta}N6@iv( zGshR~`QLCL;F=7rSOPc1br6xu^mdsep+t9rYj+S#W6qfCaJZ$dbf)OI_QL2bqg+9CO-f8Dgx*KS#yAogWw!X zOG`0^pmx-6-hT($Q>rEBzMUy6NYOyCB{B1hH$sVmvjr}=U^6#h72oifFb;I0j-zZ4 zttl?^f-TFGR>m}w1e8?n3x4XXn8B}KUjX`qXborHD|A#?o^eZwi&^nMVcZ(RU@xNW zWPxX|-^t=mu`fc&pC2D)kHZ{B+mUA6^TKXSbmiuxot8IwI&cHXj83K_pgs zR@XG`cu99?F)zz@->gKnzbbWmk>}w*kq=IX11OOn$CTsJlt>?q9bL;{;3tEie94$b z#Xp3uW${&q$y+Gs8xz;uR4~MqFI`epFe9;nt@vr(#scg z)lRJbD4)G?w(UQ#fK_~B$~OfH9l4g@WxO`4>t~s`#CCXH-8^6=552yJcaO5F=gB+> zC=89ZE7>J*`ReV3Z5#ImUf)|P-toC%f53Ev@j<%1d*c_5@3v#`LeL$(4}x3m>^{qb zgyL7AE*`=l=Sotp@cIoIYpAAgM**8}kXgqeg3}D#MGv3V-6E6_Y`o0#;L>zPM$)NaF6vbt1 zV9_0pKZG8NYiJ0S{F>+>@4illG!F;0<9}-p(46Z3j~3~l zo9s1jp$fQdONE{q7`00L1{%9pXCInmuHSqL$2B;^jH^KjI6VaqldVtC1)+!h14$SL zxXo8JIiNWs^79c@DH0Cqj`gR#NATm=5qYjuVeKn~M-W47^nNZftpQK09(j^aab4;w zuhG-|ccmK#_Jy3g_Zf2(vqdt{enBS7_4+R%rp5j}kftR}N#CxdY}J#)=1PXGmG69H!SXeMw9ow!{;1o5FoTw`=sF+p}#}j=7#rCCd4YGt?dnOpNuTee>xH zoxPE!W35|Q%-|TN`oO2`v%Ha1ITH!v2Bvuq0`KbT85-`acL>8Morm|lkd%8g!Hy`3}b;8MNs$+HxBN{*X2 zlE}rnl$@q)e;AgoTBr3rPXA?fJU7yQ+K1~7@#bcqmHvG4isYN&-jO2@H+u6=wC#jh zkYUSW%~^8iZPi$ZlUk;ArYb&X#gBepWsR}4=a_hwb6e2%vPXsF-YkvCwxKfrEJ0tF zaU+XW;$00Lv6oA_bCh+_TR+OyC9vbd)j>nbJeJO?A~ba~{=bvROX3{2*VK!*^-?=? zJ$L!W3WrewTf2oU!ueBiZ>QOO8*N*9LQj|BH{Oq20jg-N? zZPv27?n(x1M#g@uKC@Y`s% z8BX$2!^h))ao}=bR7i{=3x+(D13`D zcX#eVqlN(nD+40YqT@SOX$V`4Nzi52$phVdhcVE1%zXQ7(9vVNhsI7}L}l(F0zD7Z zoVM77C{jN@gWYV8M;6;cE8AF`;v0^Hz3c0ITCU)9ggP)Ut7YHj>g*Mc#{2Jz*`-4w zer>RSV0Tt#0F|Zos`UO_m3(1=}Kw`IbF=0ol5_?p@whT%#Bw5jAnzCWq!Lr7UKhlE;ZbV@p=$odkNeqmk~QzW4yXg@nmxxUN! zG%J7ETa-mtgrfn3bcQ@=#=c#0A23gw<`tQFU-k5`Sd8^G)vR9E6nQ94)8wngnEYH6o2D=|LP2c_kDtQ7FV)4jVS@!YzV#_ZmB+Gwp zPCnE+S~8pQ{@I7~vBuZl`Jj-2*fF{#S2@7lhQ6teK}f|*h=+5u9Za7MpUXw|ptx?+ zuM)eJ>&$z!eCk3bBl_h;CZ4;Ahs4WnAIcH%8lKx3d5o(rRq5rz={mWD)6s)DoO^eZ zk8TVbh;Vzh$d(s;U{_=FuXI1f3GdjU^503NFV#=1Xmx!Z1=Rd2x(Y4%2@PL29M-e5L_UC1H~-RdkGMfTDN9Bjl4?ke z#&lZ1?QCYS(fu=O9rbpOL=HtaaAnfWiT5v>(jJ>6^C-?{roGlV*Bv&^ZZ|2=#F5)D z0XRqBwwwNlWHEGZmi;l=S$ zWzLcto+8Q$Eg`fnyVoUYigR4&SERYi*=2-xnJ-gNL?mL698=`8Ttm7UBTu5#I>e9b zvFRT*m_jYdZN`jxq=Yn9$FmcSDRP%aRzU?jIq~e3;1RFP*S)|fa&*Xq{mV~iZ618( z+;{J2o}lz{t$`@rH|AbGJ>O=IFX!;3$uYFjw6u@p;(?qv78N>JA5nYI9k_DW^lBhi zNWUFGl6q-r!@-<)+UmQV*_649-_n5-B<$rfctq7LzX|keVVL!T=@6^-UlDf$bnto) zX?M08+J(KGt+PNIwOh%zCX8KjkHhCORAb%VV?Q8CK`kSf$FK!!?O(OuYNm4TB|5!` zO++$GRo2DL*?;8r-TDsRQvO3vcz5d72r=$+?@D~WJy4|k@&`jvZ58?h-rzhmcCco) z*W|c$7F+`}HBvSX1 z*9!VP4l1#|gZJy1ka1(QfqGY&z~i-mx@~5*_;K&)Beu*Y_PNUan7XL3z!berX7o@sum;EUC7{(P;%(J^Aedm?>A2}X$aEZwcft5IG% z#QGef!+k3mYDVT{f7GZ~{!UtnP%5gD&k+=Jq#{`{b2n7!~{{IeUHa(rK8eCE#|am+W0MEjjw_bCI7M#E z_Wbbq^D27pM;ILNt)M56yn6D3eOg^tV5Le{3r)BjVgtBKPd!h%qqr!*nazARO!69l ziO4zzxfv0%d``T>?`0@hdOpeGnvIKAgU(c<8z~nO$`*6ZWX4`W;B3cBgngKze{!m1 zb>C1d-LG^eq7zNE>0P*xQ?j#Npz8V6>U5nm@!Jf|uH-J|q3FOD#=sBv-jvxIHkL4) zwDWcj1*k7QRM;y9p!g&d|DN9R--!_a=j+G+TULyK0snXJGym^Cu;KlMHFt>E-t9!L zQye=%+KodQCxR~|S&T_K8Vnx>@3mk5%LVEG-3PAyz5kQn;s3)2;^qQOW15#N%l&{t zLyZH;ub7u+@>@Fnk3Q=oKUd z^!@~rh~&12Y2-FcVbx^a$8A1pN;uww3Bw}9nA}8iB|5TqVPSL`&Oh+?;ZtV5I@1A0 zctjQYFTm;X0K`zAqYy48BV$AeN8?0^pNu{re-WevL=vB`B0&!`feXI(umW6@uKjnq z8blJbFNf#yF$!W{hPm7g2Yy>y8yJ`?(BwfbN-IV9c|z&PhWSt+R`Ss>@ev`Ua|kK% z&PdKb`K@akX$vj;}wxXxF(uBIk^({kLJ2?w*D*Iy$-vrnRrNSWXbnT5+g_ zz!o>CohE6KXdnU7t;afqDV%UTL-R{_KPsvmFeK#c0h1M4R0Y86*PK$3-t*7-SOEA%H!(4Qn!+i)-IF`#p029{|M( z5l(IGv5p*5m`DY1%W|svBc0HMgm!#YkbZ!(hcVHzzv96|Gs`ym z87p^o-5Qziw6GI;Yy4-<=86?C?( zt>RbO0U}X%CTxiEF7s8HI~*}-TEuUY(kj88qCAPj$Kwrr|M0??O~USw}LoE9}p%{<~8dVwLyV_!RxpQfpzmiTq7s4_W`kLno$+w z0Op-Ij2mYb7G|8{D6qDHe1#kkKc{8Fc?&AzRBn2IzY?56jBns2FT*h$>8l367FwQY z%}C)uFA$T&`E&CMJyLQwnj%=ztxkIO+3I z=OKMp0nJ&3vJHrFkL1Q1kcK}*ua4uR;P~4++V#IE5Xcki*!wyu2Mzlr@ zBk5H{)y-DyLKh5R4)Q|ju=uUSQY3JC^REqypaoAhAKg@e7Wvojh$+M2ZJ7!IyAN$K+-#hhdxq&e-nq5af`-BP0^Dbk{!W`*UlbIewWt5GpH0G? zSd)7g&6u0mi4*i|ZEYipZRa!UQ(Rb?uf)iNb*HdoM0E3Jno%2m5;#7v19u^QbkfAC zC`MMs(5jcyjMK$KeZ^uNdoxaBRLJq{dTiy5qq#5p-;iVA(ZKpGQ?;XFZ6P-KBa?Xq ztPOtpG~6!+_}0GNjF!~%7#z5XmiO}Czb5+lX`bOw^7W32gl2rj1or>$+ctw`xnwwawG!0+d%kToMC&)2Wjubr>fa zQObXtyDpIoyXY&dGx4sRmeU18sZ7+YtO)VLNotb~KlgIwGzw}sFhs+9%m(`O2Ye@q zCO!Lfj{Hyjh5Y>e{C&6=O?grqsshlFnD@Ux#-&^Q&K=KZp{YLgCJ6hMoP25V$1##A z%zSbp$mc*+-CfCi=>(P~A(({>7M6O#k}5t2$xelqEuPtE{;g14G({_nNYmR;ccZT% z4~JcB06A-FT+J6No&PLM%?9=aB*>t81W8?M7R}*yI@v*qAOO?dqKP?yRd2BSW3356 zJ}QpdB_iEIOKg7iT{Y%ad8wT{Oa7rDHtw!fS9|W%pTCfUn|_`^wRM`8&7DZP%xSjw z!vXdJSsw@`JauT$nruDT@KyD)zpx{;6l#6ga*iqXp67uIAXuU9rj_KDS57ZjopL}B zs;hMzhMdFpo*_HZGLJ0-zkdGH=}RA{KA&y$ElC=+OY<_8zDR09mn7C*2P_PChIuDG z%9pVJ`k|RjKN{ubVOXFV;&(C16H+Bt3<#>0B1K{z7C1_^5D2tr+NxChI6UPGDqGVH z|Ly}N(YOJS?$U>eSGw9TZ%Ep`Ee5Bb8k3-Av3}M|FhcKztUzWh&gl%~ zs=fr2)i{qUGMdp0;FMTMCcV}R=lh}RaYVr+I5c#a>KUN3oH0kLYMubTsHiQWdK4?; z*=g_M)Xp6Ds91PevD@Q_Z}&io#8RcN9F5T8F24nIXdu}uzfu*QG+K99;i5HS!Ex}) zkK@Kvz~m1TZcdZ!7fsmv`xXYON)B(Lkb2=8iP%Kb4`Ieum-*_{Wg;5;9>=sjN&Oj* zd@UK4FB_%zZ=$(yLy5&>Fy~rdSgZUWCDAagZA7pj2g8z?B%QsAD~?3f%vHp{>=4`R z;4(x6Vm;M37mwgC$wWp2a%)hKzuz1%z;U|@H53$_xETT0%H$_2#fL?08b4``+{7{# zIpB1jmYz9|g1#7i+eCiYot^|~^9M{J2%6O6&}r2TrzS90KML{6m;p$=`=As9Ng=*Y zRF_dAGu?6E`*$t0DQJmAQy_jmDw!=T_B8$VwK7c^7BSl6tV?&ob`x3yR%^+Qql8mW zCrp@hq0l8ap<0VgN)dIj=UB~(kzqnlfKCZ9QG|EJgo#%VFk-Z%k$)$&tk5<%!BlY;8LTr1XnTG3RWIo`~D zllQhW(5VJdYoT1u;%J9gJ>&JRr)=rjhtT#+jO4o-l{XTh#`%L8R0d74a;GE>iiVx; z;oM!wsiXo5RC& zX+I0}9@y<@3XCVEaS$p;T?b>vl3By8lHQKS!uJnl*gA!z6rs&+S|@T&Hpr~I_F2w_ z%=f`uqODsyb?Mtyv<*-7JTYL>8K+vBlN`-3EnDGXC0`eN+%(~lQ#}#I6Z(^!aP90h zO|{9>4jY^`_xDTbA3YEv6!0*M%?h%%3ui2-S!K9*m;;?;*|HA0uo`ECYWXeN+cC83 zy?_hq(6XGS8odN>uFzZ;?s6Iq6cT_LE8(`3eW>7)&%f@d!qCD%J zd(R+DBtKPt+vJq!y}@*U@~Ga)C+68P$%jkN9ZwDLTIo|%rg3`AUh;@fv_NRSaf92ucNuu7Y5l?IKRC`8#c3m) zrKb`v*H!%SRQ++#F6krd1U2@)?0JtCKZGi*r!MxysJUe8&@C%SG)^Tl*OSh=-9Ss; za~6FdYP+k4F3#^6BrF+2K=~3Xu!*9H!9jV4svrb5{RNf2@VD{<^KM+9H&oF+usUFp z(j0-luBs^1SXoW=IK_w5l_@5t;NR&QKh#A}k~q9MIW_Hj4Wxojx5*;O%~&fq@Y#ZO zol4y0GlTh?UzwGdG}k!I<%(9UA;F_?+k~uV`euRby;AJ?n>cE*uqS0{5GvXb)xc_w$H?>t~d_xKm3gs66Od zzc+RZgX1cD40HE+7py0ae8?UoH*56rinBs$6 z5l>>+sp<7wt(L>Zf336MCY0~~aN&E&50kc96HI6ebh;&zyEZ)IZ0cp3on>e|a7~}{ zn04UEd-TtO&gaOib4pg_yEnBMxe5 zl<`jG{SPd_p`13f)$|-P`PgR#H$aKC;nE*TfiV)-Zg)ijgQ{7apjfzm=H;e6ajH)kXti{@?Ud8JfTy6ArRoV|)^*;evObm8!ed5cqr zqWP%4Fhp)TwY!|MX!43R?p+Q?Y|%=$-jUphoAE3t)SjnowxH0m`#PU)+9`SHb;&s<4cMak3ATAx*O;IOUbJjjx_6-!JWX+UY2FDU=>MbJlw%L8JnpD5KAWQ;UV9g?K9K3YS$kPHx)5N34@ z%c%NGwEF9u;A%I!?kVchlqp{%e|d6Xw?0dN)Q&II-jp`sdYO2~o9pRX_9L;LDT zyYE07-zNDEO`{(^ zIbZu^ih2adqxR0%);bX;v$l<+oIg91T z@3E$ZE{s)s*k|6Fg9|_&+z`6faUsf@uklQ;`uHmXvV`BDZyy16uyzOQLyYpn(#DsC z)VKo1eiCrn^&nBUvW>9F5&EF3*0q%<;*x6p^h_ z;$#@MrUD93`FiU-33}E*{R>MHPz$g#O8@>FJHz(pY)c|)1(=`6;#ymW7WRxzhU>TA zNGC2IA}LT?ofS_Y0^{bd|MEAJIwvXr0aZBRgxVE%R$0w+pUIqgEHU{mD{eI*p`P|o zbXsrgDZz!!JWaNJ8=|7oWy(46(ObsIL;@{hHDCZba=)L~Kv$~bO6e<=-20#EM))(v z_?H$X&qu0OEs_)r23ej(hkbwnm*2jlSa)6Jt6KMM-D=9*w~gjReTit-ZLb|lbmMn< z#ko+w<7F^#01X;fyE3Z7M*<6^ij1GzFL}Vh4HPw~fb0-hPn)Pw z6l@>0-$F7pZJGuGAC=>R8`lBzzpLbab9_CibwQV3^<=t9jM)?feBoTQr>2htF3R=i ze@Sk=&iWXG7LhEx(7Y?8{t@u02N%oTZ_L}e(*JP0JT&;2!5}Gr;npjo25W1X^wMEo z(*o!FSCx6=Ml0f+ens#W81+af{4_JZQQEcd(r1yaNO0)iy!S)cG9Sryc_&qd%dfr$ zYljz1B}#QyI1M+>FAbmn$w5-0-ZV9JNA84$TI`v#+o=z3xwgKhl)uEfTckt9Bzo64 zt2<3ehWfJz{mCm286!2-YDAYyg6YMjg%m^NZxws}O0wF{e!6hw#B=h~PV4Rsb^c`^ zfXaNcljiI042x!a++bbX+0!#JmDKiBm@Pwi>Qou`lXIRTrs<1-!LG8)-a0%PF%TPC zWa{!XU)7rYz=gwRJ-_wx!GphqF6|94$uY{6wJWVXx#Mlmu_<4xp~$0UJN-?(Qi7xJ zL7JxSH>4SC%CF%VkQg>)zUsMz`ghr6?DOZAISHCW3;9j(L7{RzALsfRbv{Vry7+SxaYsP8K&a^<%T zoekMhr9tDp>G)$g)#L2-pXxgaW{i^^uADvnz(lG??E6Sxh1Vir z2H<}#lWv}88Uo7Y)T|y1*8qt;Wibl)2EN!G$~1eQ&Z%tJW)NJo-sec*SN4~ZOffY* zd2ZvYu_ewc2geq6^c0`TZZWvMqH&krfNJ>8mEhAy!&^)wi{Y5kI`O~`={W?Ho5rP( zQ5o@6tAoN+VgJXpp@k@u_u=&ys`lJQcn}hYAOhvS{=rTn6B0U6sw`^Z>|v7gZ9@GT z&#|1=vNGB6SUW6U8kLYI8kuda@$lx=V645ddB1?=_|~VHSy^C_o|Hug$b(7b%+~pt z6}kpS3j>Y;jRa35pzp>n$s&tU1Xc^@K>MCJvGr7PwY#02@3lm;h7bK4;Hf1H>)fyN zMd8h%Z#N%!5xYr&@IVp3_EuQh;b7n8tn49I_q}vK*Oq^Q?LCbfmvG^sqJw$(Z&nif z_`urbh3swFo2v<@J4Paiv~dsC561n60Ndj`iHnPaI5}roVcE>_AD?<=t4`M(n>X?( zacdWwFLm41lRDI)dO)2j?Q-wc)cy;I?}xN{JwCb09BBc~1t8-H(e~%^Rm`&}gY0`k z@IY~d5x86c7?!WN>>9knt8+24=nmErsxgS;Q`E`tDYl41pKH+ho=d zxV!UBq(y?9W9I*X5G_a~ZzKB7)io!41$Xb@{lMWRGc*R60al_NUw^ttD zgKi7Z9_|9%l(_RubDH)lfV(l=9axZ7xq?glpihtHlNpkflpF(>CT5y(;1b{=fM{t* z-2xl8c`+B4P=zs@dEqveHi$hB{v`}jr9N*Vq=~+@EsbDi_XZH}eu(Kp9wrrN8hxR` zL6&^14;+CZO~^+vM3snBG!;mZb6B@1q;=UBBKY#f3F!`>wpO5HX2`HX^z&^F`2p-3 zd3zllicB9Ar?_p9RLjs3Vb&OAnOGKZD%^Vu-+qk&s$;N}5M#{Kz@MWY1ilW@Uy3B%^@*gNd9+;M6U9VDT7<E;eti#?UB(@^)s1Hbg{HCD za(W$gjJ5;MEQn#aH3lK6TUYKw#Wcx5vEyhh1wO$k0IDZ>@v!(U6>2M{u#8xr!rg>H zO;ku!8#fO?DL*m#QnKgEnY)Q@;#nj6+uPdWVFoTVrqa)V_zh;Y|vP?)*Y*ls#|W?9-f41xo6+N+WBC93+h|Zg_%Y`ZGM?zp=@P&}n&| z&f{EHP*Lpi?JI$FySJv)iKPoS@}C(UAAflK_;c4*WBs6~nH2@;bJ0~l;LTxfQMTm8 z>v*Ftqk3_-jWxm2`$-XJ=FAKRIE>cmE#s4uQMzOJzrtM>l3Vy5f1VrD?@oh-DwL+|L!M;c_mzB z!YX_9nQdH(+5}&b8(`6MyOjGoow!>lLgklI6P6;Fe8bpGmHj};EWd4enSa=TEHo>6$d7WDRy2w>Em-S zFHlL<9_NqA4LzM&nLmI2JnW{Z#{LS_eSEG6qi^`g9|!>~7mUFanx?|YF*7rRyhWxj zxFZM;NygRmO)1A0LP~vY9-Mf=D3$|qI;`FPuqhUU_*B!Q?mkjd9a6dJKd=DIk2e^V zjygSyrgtbZ4c2ePHz{kHNqtdA)j+#pSU{|-IH&LBk0Jq@98{zZ_Pc>zk>V39FAWqNp2EwJR@l4s-f6gr+qHO#;@V$gr^>=Mp+j&w1)DK~HI!Za)^!cDLb)k#K> z-Mz5D<)!IxZ*S*45V-P*IAjOSh6-Z_W^ z6^E8gEKgD_N;A7@;_bC|5+&&(KY>VYqJW@;;35-lVBu5Eu5)=blbcl z{KrHk-wxT1_& zJ+jy)`$0#Gcaeq`7CO-qH+z&UlEd# zh>9PV&RDXUq7`vD?k8x%KQpU*l%ul#1m1O89ZnjwDz9oU< zty#{oIYgMvK=R0o5gr#*6Ms<8Jx6I|1v2a$N* z;~2SdI|Xvi&j7vvIPp2oV8d<7Tn%K)c%C|S3YZDtavswy>x^kWM{)-E0cc3wf_59)a$g!WqaOB`x>|0s2=)Z&*F*K2ax)#~#`}|1<*BSMyx!IZcKiQo@5{rfY~Qt4lX|7WOr|oFj3JQ>m8mo#^H_vZ zkx0fx$(vacrAU#?3Y9TRES0$unO2#Fu#C&N&*$C8-uu}9?fren_s@6y{_7}O>$#u% zzOM5;uk*U@=U~v6T6O1B!dgXas)M41;G(UWjb$=gJ0?$RChp^#+tYPEIh?#SY>mEp zqIJ@*Kh7a(2}P8#@7e8U7qi*ze!)tmY*u`IY`OlxSSdd7(WSgkD9x3is5n`F;040S zaCSi#==F1SrhW6Q5=I%gi@Y!@!~?7Ncz%OwD5d02vA9y{^mp^*OfIkP;9Wog@;YCK zUzC%P<*l%G!#O26=(3afBw5gLH^#!8tFQ0!n;*~7g^2!ea&MHj>eAF#u5AG}wS4Za zJJy(R)0A1APjDNkkBG_c5f33Ty_BE{g09;V4diJ|Sdq5t9WKO`s8Yp)Nw)>ab76Az zAGJ&7xHT2)n*^V{j~(Wg{B=Z8{H#mV-){IHn^H4IaS z1olHqz0s$L9ExSG-#-u!#835|?R1&Kbv*tLnndnfuq?eO>VG)!*xQL=JUP_Wbz(+j zNna@U1K|ebmQbLsVld%==_|Y(AjCyEC=XfU8Ui4DVx0oj$JM|_y71>s$v3S@Xyw1n zd-WssFoyqhfkZCNv`g>Y59^)~!>M>h7kqK)DGrs9S6@%kd#<%uD%gxBT<`jUgFszb zo^ASqqCj$llGt5W)x)=b2W(anMOO<-Gzs}QZ50)DusWx0H5)Y$-_q zjE|7u^G9$0^%l$PeUU=@s)a#@#_+%_`=He@jQ4~V)WLIRr>hRzO=$iuZV7?=2iJUSMP^(XzpYjofFV>}F4cSm z9an2KIW38%SQXkwp+4=1QZoYsx3&wZbz;m8F9vYkJnE2NdiKI&KRNrR)4lzHuxh`M z@PPW{`bSR17mU;g60Tc)3&$2w@tt?$R9^GC5^txc*OEb=t>narGCp_}*OOu_!kddZ zMg3X%^@uy|0b5i(&>lHf*b_m2a^z*A5A7(q>MC(A1bnBOu#J*>ORzWL46V}>A&k|I13kmA`8uWnRZ`oY3NjwdTkm`zS;`CA^&0M2%k0+=t;@Y^GCGUGI;ov;NZPkrFYQOW_vJeI zmMr73m5!+$CzBe$;TBh~S!@I$*H73&J*7$)?jk9)U&{XyKJlEWPix!8AMZF5u*nR= zal8f*ZCeoZJW?;iGFd#FWA3FJKaEMEdMLaT5eU&KZ_3=Jwhu%9sMxWeJ1loo{1E;y zA*=Y)&PVr5-)vEx+rIf}L7QV{Qc{x1W0MS8Gz_ZVsKMnb$7EkRKG$~fMOEBa(M#^I zQbCk%7I>KD_(xg%_lE>S;}J2tE&)aZ|5Zl*ifI{pj>qOqp*Oeud1AH9r>oDYTLTAW zhZMw%?i`|_d7+((@wI+vzYl!sw>+7MHj#aB zZ{l2)qus>XXF~IABhE9YrOITjoXa2QMBf>1CAafK+zBoe?%Vuc=jpSb&_$@Jc3||? zX=Tx`{NXa9&gHbv;yn}cEf%NkY3GAyGINT>GBu8Syj!(?B)6I$JJ}qp9!!pYzkD&} zq52KGok-%k!y{Nmd?%%uR@;sA$?j0;2ovurc4N$87{I^qz+@Dz(Dh(Th}RIeNK&yk z(vMc>^t0%bpVw;+s`Njwbw?RlC_+)N4Py*xgF8Yp>5WLfVZ8fkk$#J|*x2G0VaVAl z(=YraJFg;Q$L@c`y=wYRyHkxbEitsZ>GIH6m4PU2l*reo5|{fQIrDq=M(s-Uxhi2U ze|vR}7J$W*ee+9WegcNn#lJT)Ur&C%Z9;xaBJPX}nt^aKpeBF4;;&u!{hgmHOg|-T z&M9A>*XH*WmX0G*mviZa*4qA{x``0Zequza=hDr8*?~x*NC% zc#q_6c+2cNc?ia-@E~slYN$*@c{Ng@X}q=^J9z;RkOtYBdsgOOtc1*ur`)$PtCEFx zdKltH__~jYTCowS_%(+im!#-5n-!n9os77{E17b!qB*xls^nX?`E~Sr*X((eNKuRSIArBRfqVbB2$r8M)4%oQw)ne)z@z!gS4C4*#z#3nN(n0@v(7jLVJLeZ%zF=o9;bO$wRRFXp%Y1nToF zy&1^FyQTZWUdj_DvcNX71;BdMI0ZhCQtm@NP$ zmCiRlfnBS3aZ^qNre17l0CaI$J5rj07Ij$mJ_~S)eJUD!=C`ukWUkRz9xGaYhxsD9 zlX!jet$KE%YcQP5y%0FoojNNJIa=$0z_IGd1ke6B+?RvSZOmr%&EIB5++Njx zLZS0j#Wv?=bz^pBFI!uCYT_gRn8U>Xl%eq_{I`<^6=qW>jC}B`c`Ln zt+zehyO3L{YC%qH;OUW2U)xjhTzrI*Sy#69V0Jgld4I>?pUgvjH#bUQa;B#;F_#AV z1MmuZdk55B=&41i>^%NJx9;5Q&v+Zos`$2-nmJ$4uwseLji4GCTuRt00y&ED3rHy$ zM}GcF70GMcL0ilL)d145XTMzBY;V2#i4n8^s*Jv(fycvePd+BAgL#qZHP#%QoCBw< zJ^NzPN~>1+3ddtZXXEU{)Xa;i#26AxDm90yxmwp>FU!p|t z@np(DrZh=dvqgE2bwWl4zJ;C+l9TCTKAmP(%9jKL>9`f+;{IZBlS;>bs55n8fEv2l9BtC zR+GnY20Mc+hHOO6s*vjQ+n?HiJq1!df%;M9>KyP259!YQiMbW8Z2v!k`^}}rt zx#ArAEL9KFebX1``+0mav)_ai&py1E;|W+JH7#(A zUOJc*YIFBv9J9uxi{L}8DME7yTw=55Oz(*`clv?Eom1Ey^5ELJep(HE%x}dT;41Xp zn_qwPXjRtfbk`Bd^DHgiwLVzTymIbHKEb1(b$?_rJ<1!N#gZRU4JS%ix!o7%DelP- zMds!)pb?ET_3Z?u+Q4<+()|awLp*b**47YHfotFNawBrxiIk%%6wV7w&rGqY1&P^h z|LWUoQR-zQeWoxOAyLXuz=q6Rtz6-l*VSTFkDl;cuH1J7E5Q6besfjvA`jxqXwaT6 z0Elg@TEA;_a`r@;qMc26pV`n@OrLb!U6wxU>MWL~%UNfXgRby62>iX7Z`*De8*`0n z#j1aD(M>lu{a+i{-MO^AbM@)PmE5-~G;5f*+j>)m!c6X~%yvv77(^Jh15xxa5mVx9 z7qmyRF8TJj44G=Yif}rmH1w=h&NhR9h8Xe@f^SPgPFPQTENgLn-$K>$IjNbR6_@!(=vEPj_4{=u9$N{F=DDmoY}~dN_+I zQ~1mr&siaXT|0xjtzH>4odQvT_0o;ybvJSR`|b-f+Uac#-Tw#BT~8}wkvE2d;hGww zUg7fJgLL^)@;5LwsT+(7&fAL&sjNQ7@89G6bgl7L&oR#OIn7%uqT97A9f7^N$biU@ zn8^=uidJlTT)sEAT_fEm z;oA4$o0muS*%g0YVb+np%xyQ5!q}o9ASdfL(n??H*I&VVvPoq2H)WXES^jO|P=$Wc z+HGfsXf*-JgKXN9eYdqLIxeIpFrG?oLf(4?YuYYHf4iMw85ay9CzZ|`>A#PN63R4q%Mo*p zM=#?glU{N}%IESLvoaMy-Q!&Bouw-Kyve&g*1lQK@3H;!-Kb340Jb-VSg5B;FKjO` znb1FMbAU~XF3vX4qZzi=#H3pERxFiU%$zDKXhJk_Lhf`a zK8N!d5tu$gX^FXJQas?n>uo7p95ch&{Msql3%T=%nZQa?1Em4#s`a1`K!M(|3c6( zvoFJL_x|?QZ}JgU<8hej0H^WFK^q|f`0y6$Mpo4=s+NMJEk;acbzptSWrapG($=K>Sy^(Nz$I2S; z%5z8^4LX8L_Kz-eo!4JE)^i%=Q(`w zcZj+3TY0N2pI$|e5cV*UuokI>_mw`UbrqrFhk^9?_CPxZz+!yro$ zs)MYFlh`{_Gs~gAR@+EET%pVRUE3_^<`p)v$%_&Mxk{JUiEw%Mma^@lhBtY~`M@O& zaw~8;zU@38gkXSsah0^+MEezM6b-O5Xp#4VJ9tf0%KhS2nG(?-o!F+H8^UDGpi502 z0+GXQRTgB0&a^^;()X2Ta>H=I(ImLqTsgvkHFrOMr7U2R>U}G(3%n5;qGBEV5;wK) z{j5hm!39?%rIYn8AFmQZF>i>x5)c$mOqz|P*zi8L(Y9%Ti(muRc44cainF?bqq(1> zyoJ70`y@Y4u&(HYDk~}-xlLZ|&Cx99Uz@wcEvBdt8%1Ag=Y_8uMi?S*a#t<{=}h`U z2APi)*m2kV-pv;P3|}m(D8Zd)z>K511tn1cnFN%m(~*i)8+rL5sbDd*(nZj|CtM+7 zCc(XJh{!;@Q{!2x8XR++M;Hi(L7;+-tlph^+I;NcAVlu*hk_2JRA)6YC_`DWxmK^b zFv3*?p36=d%82Q@-<@?LaM;`|aAkW`T#uhS@anamcr(!z=LA_h9kQNpsg~3Du?rIn z{E7Y(H+WLtcC#Lux6@2#U3Y^?pMCw6vg3CPMfr_9hAyyo&mGwOLg-`6g~&W}-UQNoi}%+~{o?-xqB8 zYO#T{*=>zhLGGm*R@Gx;$4o_r&FvRuOJb=f(wDj=ZO#V&wrD=A)UBq_rLt}BdEFn9 zOo;dbf3obmd(F^eD9-zAHIuH~^s#%+k+xHgLxu5a@W8O}!EAIf`}Il}S1+2Dl+ zox9sK(J#*tyRIdo}aXm*eUY`LwBmYzW_>Z2QL=@ zVH23d)il-8XN)Fu`2}g@TD>f;+Me>grbsXQR*!CSb`x z%Hkk~5idJzd~%S)aQ8b*END}-BfWDx3lCC?-zxskTm5f5>r+!^bqJLsonhw`7(_8W zU8P)1Tk@aJg1Uony11Q}l*Ld6a)=t2z3%>1Tt^7kPWPn=WQIqpNSRr4qLEwNTGUR< z6aU0^15}n{Ak1>ZCQ@eF+}6lQ8t$Mzrz^6G>!)AYUmdWrgNPLg?ImSh?e6i$DAg!( z=So}(i~p)+ZRj3Jn-@VIGS`H;HRB$Ejr)&k95Pt*PV5|pRZmRt?aH%UJbq14d-eEA z)p`fKVOVS|4Gg~FitE`sYyI0K>P>OY;B}PEq1x#$q&yrEC9Q}bcJ>KE?r?^x1Gy$V zH7aB{HVsdQHB!bS2N&}ACI3DELhP}1R~D{g?Uj4@n-$+Da&GHo3!6j1&Si9amtZXN zkM4VJNHX8N@r>vl3Se@CbdzQhz`{);Wp4am)=>YaWmk!0OywqOC9FE&P(lkXubn!* zCRyNuL;tcX!?DvKlKZ)zWfT?`LU_bCkw-nX6a=mp`nM%!8NbPcv^0C~^hs{nb4Osl zmAk6|vk!Vkj1<$HQ0moXB_0sv!5l=UwmW2X35ubyC4Q&C&4b#0BxHxqgbr z=KbS_`8`nKvN#CQK{oBy zf0e9%uFtL8*FFN(nW*RRIG4(xGz8jp?xlVa@!!7e<^$7$^yY)sX|xN2OBY07?lg8C zca{O6Y`wgRDboYU8$6gA-$0(px4t(B^9L%JvL@+7Rs764Oeu9iFEi{I@oK&(11E1@ ztk@`B*#l8h87T$>$>Ge=@>8>RBKfiX5T!S*92N0AL zrMucQbfUoij&UEFr!h&>3cLa3#Yqs@7EB}sfAt0Olo^CrFD@q5W*eyz0Z9}kN2m+s zxRjd$tVJ|q`m{lS&}868ksUmOC<2KJ5j6>G5Ps0|K|#mZXkxvxJRBZ-_M?l7ij#Zf zB`RBbRwLmvNAj6n;5u9qUG0bK&X9jYC@43*g&B@ux5~Kic?C533KSQ9G!h?I z#f5Enqt0NopU&*Z>pRFmTHN|rvQIptXRsj*a8wu(_E?Hx;D+BFi!vsHM-Taz6bjP`u^EV z;;riid>SG2@i-NpC73fR(u8=AYl2ijQR$7Scw9vdC+gbdaH1>o-JT&N=4<#tO5=IY z@%QWDl`Kc`SZgZ0*yUN@rhTp31Nweq?_;MdK$buT<6hT}4N;nwHi*s7g>gJkl-L7u zN@<@rP-F&y4&e5?boMT$om76s^e}Bwy*V68%z;!cS4e`7VS9kd3?g?AQ>;4L-uutp z$>{FxM)BSbO04-e+f22qI4wkI6`_6!$8`S?3M`4n`O*6>w`ts}uc>I>(Ie~x_bo(^ ztSfgIvCYTT>2EaRD4rsDj(87d4C4RI_wiE-NAut&)I*g_C5>u0BV&$1J0>}vd35>B zC&yf~GLI|^;;`thS-mGy>fcW%M5QcjJ=5%OsL&95BtVDQ&<8hR7|t(TN73Mz0!bXh)2gu@rre?6w$P_?3^##Q}G?oQ*qQ?(8@C>>e&1t=S9;7?^gt3DG*y{`T(n zUX5F-?f#wU;Ki((Zwg<7K!i>1YvO()9wG3q<9<&4b4DNc)A#Nr-@oq&5k;KE{l_Ex z|HK=7C5XtG$l!J9($rGNE_|?x@!`XVY;0`w^z=+j?fABgcDIOc5lh!L2T=@JI69V| zSe3(|t@|5F!~)3})r+R$I87o0SP?P)6@-X5CXHf4X=&+a&(56QBtY1{pQqJ0R<@`* z87@O;bHI(pF(PxrDk^yW^biQPH}mtR$0d0_p780jc-KXZiLS9o9uOj?ihD(B4qArr zZOvdzhV-{?-MW4IGfZ}j*vpscCx75tRDx%SMU6c@Jy{S4+p5gW%=J)q+lBwj84!kh z{bZl%@h2=TEo&8nv=)sEcX~}kw!Smsb9*r3a)K+V*_yzdzb{_F@gj1aY073rBzn5- zo$4mOZW8hS7W?#gT9`^(H_>|W$xk(@SI_fId(-*6i5#<}qnb_j9B-J3SwG{Qk6&?6~{;M&-_k#b|{M zMkm)Ci#AR;bMD-vZ4-&d&T^aQ*V6FBry6GBU%7XlAuSy$0_= zF$h=nfQvLTXpBxxxsp2?kBEY8tlG(fIiWpqBk+F z;ciamQMccHZi~UO3Y!!^pRAF&{l-K-hthNI95a}P6MmnQqs{=kNw*URv(r2FMm~k= zpQb}-1O^Lr@)(xdcs|XdJ`f;8#f!bpJ9b|YTg%h#ZRubhua9~|bR#%$IA)@(M+o!h zrqk2Mv}+f{Symj@UBA>9ZJqqI)VNh-^AK}EeCzzSyx=tpnpsWLsqofex*q;ZleKOo z3nA~`^=Oeta!}S=3>BUpZFa9zOcn-4hcr~!OcwU_J9G=>C}n;rVRBupTqz&j^2%~` zUL0VgD^<)8r@MD=%;)qtNx)^Wai=-X-Q#zX%LGId1m}2-i;bse?Ki9b?5>|p0bObP z+e3P%yOTZ%vsJUvTJ@(VhwJO>VPRpH?}}fbU}`r4tJmretqbazTs0{iE}Mz3ELhvJo;JQB z;8wS^%$Hu(D!2Tk{X$&JsVDWZ=1r>?Ue{PPOXqtt0*)7QC-Mq%NYbsX&g@zh4x2qk z+hBC-N{e^MI$w)PNKC>U)p_iy1_Zt(6zot!RM#$ekW7@p1X1#Le@>#Qo8Vn4X><30g15?M3X$ zNLfF^X>p)e$xPeo3}<2Xwd>bStG7{R?27}ah{KBp7AxsS4wKd-R9rnoH$}Y1E*Pu{ z5~XNfXU&8bo8^?3{%Ri*uGJAeGpvu`0Zd9R!C~wSJoEA6$6wz*^lNhwdZ1S8c6DcIyS7TYh5Mh8>0xt4)f>FMSXYAB>vPw$?@iU; zX%}gKwJ_L))KiI6DfOOD3y5ab%6@Ml#-he3pSJaeRM_=kA*ZgkcD3@?MNUug6%<1y z(+>tlp`-OC=_wikXRZ|@R)cTBia~eM(W#4rm!n>~HTjcti7iN5w^utW$d`3>ky+RN z`9|w=q!_1+C-o;^^cBAGXEN?6%}JkAuY&sGM4skMsR(|@`P9fL=V3_tIYy(UW)VC| zRkt*5x7R<=O~`uo{z{ysZC5Gm%fJ_*A}5vMqpt|gl~%<5jOw{a!=E{m{bI|`g1oet zn1XFUNd=|Uoc|iXh`*E$Q-82Hr03%`{q=o&wDc5;CnH-wTGpc(#+9oh7ok~f_`W!A zCl4tQy2I;dOS1l5V9iu1Zuxrr(A4KQ4SUl;EjK-69wTY%2Gyk06_o|vQ#oA|E#V|a zdG)b1B;)um=niUpSa-4m(4_36xi!drq-(l$UlR%=-}wYM9aVw_dIK-+*{edY?^P+hQWy2 zwT%jXxt7JSXebfjB5cz8uz)IREb?=wL0DjA?CYGWm&(e@iOQCTTmAA;ucBzlWV`N| zNs?qprZ#zD%OGQq<|^JTtFRna`oJ`S{)f-&V2jp!@3Ss()N<*C!2I}pd|j+h+?lLz zYI)k0L7^_?{rL>7sX2ep# z!l1v>v2qYPH^0@2)PUg$vtP2^{=pK4vx-G;0Xf>mQbM1#&-7ID)a7@afJ@=a^0}<> zHAuVkJ2^E540)$8H6FXGSZPMNhTRE|5ixF;B-I{iGoq2nE%TOEN5-EPhZYa4p33Sy zkKJMaGLcen;e`KLhUflljHaN|d};G*LSm%VpniKFKWlp3^AwNppy8{6r@>>q7ZC_I zN|85bZtxeUCg`HdEdr(yL-5I4B56~IPIdXun2B+gBbVtp4l2e~l4n7Yulc0*v)@VnE@Q&N-mk7x2UjqjdxBM=+@NJ3Fkk6BS$+oFqKWs9+j zdkg(JvWKK>O!zpo*AzhN1J!4|MM<*gO+YoS%3sXVqkC#&W8<)AEkdN;{4neFCxe#k z__8^b^k?rKet*{}>~J9_V@N$pwT|M4?YDA2#Z#$G;*Zwn<*MJw4z!=7qnM;+*=yHd zC)2El2g~49RJNb5IOIrXOuYyU1MK30`DM0h{Mzeq2OB=RjSU_dXLMySj875nn=4)m9WOBnZO_bh zN5$vZ$5#>QFj{QRbwP0(EwhMWe8wyj?{0>+vaY&GVl*fgp;9eiJLNlzC;NHKx4?+A zfzG1p=9htDqvxEB5F1<)ezqly9FtRKjJxJ;rIxGYmRYKsNJFfeR+)L!;%!5Iik>_* zc835fXd2FvP#KPF+l_u2>xmyNCVd^k??^h117 zdx5b)!&cw(#XN^gnidhSu%{oN&!&6bx+N~zSb1~sOM z?}LT!T0@tt%~-61r%UdAGWsjWnknVvu2nse01B^<*3P88V#&b9JmQ4ri{7#OV}i`l zjsqdK)uBy7{>;QO zugZPa!nOiFXL^M4fWkzt&V=>Z`x)@&9H}aQU%I)$NK7g-ukiMO@(#AwYNWLH-H1D? zyvp6o97+Sm?Rw?lfL9!+VuiJikW5#_dal2kMSGf0_W0XlJ}#~t zt~H3*PBlWG@wKJJXB(>q?Sty<;DnoFygszLq{4ft!V)WDI|Nz%;}6Mvxu*?6g|aJi ze{vgrD7<>f+;gMtep$R`OvQwIN8+{polzTF_xal^SogfJl>Ifnnp3fR_wU!NbuXMa zWxixDn5{+&X4Ekkl%{+<{Dz0P6}l6;c8tAutgr5ZBf;@zkq!js3F1!`6-!zCOnH zjDG@NUb&yV!QUb)(U_HvA9J5Ve5A19#NFt9DX%({2C6e_z{DuU%dU95;B}7D@Q+Uh zPX83TWgXbv;%0hP86Z!RL8xFQdwJ-_ALa>$-Z&GQQ6$^*ihr_WGuy4uRk1|s=yyx! z#U3A?7xxbjFAlShagPhPoLiw(y!ep9AjfLR-N@c2dsujt)mVV}m(^IM9RLzaS-z{N zY_sehXu(z#cNhtjugJ?ds3=xb3r0!YbbH2l@H@qa$3`V$AT9)|gN62Cm4caBi^Am} z$I5awtE)jTY@BzKT6*aF5M~w|lq=UCKYna>&N;?*~nwi$Nx677;gAZo4zq*(d@6{DQ zy{}Z=aB1i`{_T?a{$Rn(rpVosq*7z~X_tgz>7thKa9R<&G&j7pgI5z#r@zZ{XWrPg zjT#heQ`E8`+}`0!!4Owc>_07t4u5AFp1oeYsRlFF`MdRjtU#AzPFQbttEZRKgsPp| ztRCIwa!6ih^rG_jdnVQL?OwkhmcH++yCalAb9ekzzs`uImDS}iEe zTOrAp86X(z>O!Xof%4l4R><~&>BEuv65*q<)XT8^9 zpXyHz^9IY(<}W(=+Df&qc3#eLf)~cF0t?mGpbZ`R;3ar~KbRtxV#bonnxZk}{)R zxBxue<^K~&H$6S==~ab1gYfNmcDg*btacXR`WkWJ7AJIGuMMZyp@jE^`l1qH_S){Bnt7#2Eur8VQ#P9X%*IV736=w(#VKrB-Ui~#ajokYX z7+8$ZmcG%7zb+XWgd=ISKf!QuFNs?S0^H9VuMjXKAPvvp!weu0jhFw^Z&)8GF_8zh z4_HSfC8d`yUxq&q{QD({#;9){%`MGCLu#0llM?`<_1P(K^Y-?|y0WWf1P>nmWom7+ z6l%*(NE#>DvYSc|na(As8commDB}HPYqitH2#WSbkc4vNzRD*?f08`i_vCTv*8iQXX2E-}6wA*CP^5FfD`%+JT5Z|IV zTov=L@mAW$AuOwOE*_r}6df9MX9E8UgOB=Ne`(}^_v|5h$KtZr-!I+&=y73(n?fXk z_g}oSsQ0utYpD%-7Zw&?`yVkh?u@56<{Pm=E3k8rZT0u>w@!hU#l371|92Dnw&Sud z>5WHIzkjQtl(JX?#$Z~Nl)@JZA^Jbw5zD^;F)&AYMTELiGEYYL|16r`*K!gb|Tr9iU`_>0xiithIMA3xM(Urf}x#a$>d zXbI6MF{ZnJUu&4ltnZ$zXQMCPzb@2nXGX9-*Og!cv=)@45Nh7;2M93=V7Yngp2GAN5#OOK%b=Yw*2qd)!se7?i=NOxV_}k z#XZ!SEHpYfIob3HLKSFFJqTK01OccW1DI94pnAB!`J2{jtJi&dQO60ZLWK1>-j1Qv zr5giqAX_%gq=LRc#Wmk8aw^09`R&`cP@v7t@5?0?QKxwA49DoYOVN-}^CSwo74yrb z2p=~RkaC&!qyk3(pQ!yN?ea$zqwBRXBQ?7ZaGO=VsL@)l#Q4KEoho~ywTU{FntCAJ zqQYb`pWmDl*=W6M({Stzgo_hfV<+@zu#Ry$Xv7gzU*=;T>oM2wiG0a9dL87ZzkdBP zFfbUx=$$rG=xr_Z-@kQBbOlFBNMm+^EBgBWYLxdHjxDMEg1(c5zm6jyxANl5wLQ!k z85w~q(Ez%83Dclk?Pyk02WgI+SrysxlvHRSTbA0$(r*U-JqA64PK|Stuvc|p(*j%a zwS~nzjOxKkUsh0bOTB>xCkZhzwUB%1%c9q%flX+gfPjEJNR>JDhpRO>&I6DVMz7&y z@6Rt0K)u9&hvTLb9-xnsafe=pf<;}y38`YCyuH1-seD7_Ht)DygYCm{^iAf$_*DQ` zOMr`1YfH61D$$L-d-so116&seG^)Nq%HK@rR@%N;9jn46_DX2E;&`k^fSWb@yF=MBo}XOGcQjr| z8kPYtzMB=aSRGglJOYQ)0L0QlL3<8UA24lVtcr??pcSaK0C#Kitw1>k_?=n<>6(oR zk2OY8=653mhK9f+1b${BOsK$-Sszemfko8-m8X3~BJ?J=L!6L@Gun@rrYnJ0?PL;w zf1SQ`!_EUh>4Cw)rGH~Srk4(dV3tB>j{s#%?(w@b?&|gH^ZS)VfzV&`jAQfE)_OBq zASOM3ec-)F#U(jA_&rHrs6dOCiwlWWBT0RP9>OOi$`oKEG~PY{X7$ULFYVE+32ib_ zSp@j_kZe0gQ&VqwOs~qAX^I)t%;Kmf{r2j)f-23?Rn?!oRyD&^pFVy1{Q2|0cC>vJ zl6(r92o~*1+x~1>qCzMxsv)tLqvTZVju&!LbiKB*pjp9IRK-(Y(bd(R`!WYRJ@%)3 z;-fS)=C~*iC{^ZT(7z@ic6Yj}DOn4oi+zs%1i00>Z5r1NV)Q1xV*7LCEhv#E$BTNQ z!Uwq{z&5=kHDGUp&D5PNl$UQku{vDRQqQT;q@7O7@Hc52PG8Z*9|x`vVuP80l#=7? zrXID@;4!c=6zsbBm4{PrNYT1zcl%E-n~^d=!zO$I`q)BtpO=m#e?Rei&lpwp367DK_(cFcml2_UD(KmCwJwC)EJa z56vVr7Nvy5*K$*>{uP0k=N_cCLDyIzaqhxi4xts9Xj0@_hpyEQG=%T*+)L=ZLZ#(= z^-nJq6vDDob5Ze&b-NRz7(I~=6}-VE$QudV7CiPdl72scdjFm*R0AaE>ec1(n%$nX zr}t&!OO3mzc6V_3a%Ds%PGetVTb(uMN?G~>PTrV5gmoNwJkm4P5yhNCR5U)lvf|Z5 z!ke#I)?UbGVGbNJ5*U6Y+sS$p>57RdW7D}aXB=0@_OoJi7e^~FZVLYqDjU)sITnSu z)^)Lh>6<2ngwYJpmFUHUorjtbHqec;>Qt9XH75rJ1u6GzZy!&3q}{!9ClXoW)L#ti z6bL*l+I_6(MHBEz!=bgs+9FlRTGu~S4!_N(0SZu#fBK6yDl^&Io)#&gR14yxsL7N$ z-J_#k18WlHwHlfeen%`&okHULn9%Xu>es*X%je98Uv%{hA)!j4wuawoX}Tt>*?vy($WHFMoU4AxZ#hu(5OM?Zt(JDr0cOZ4#~DF-a_2(VQhHo)7;$5 zc$R`eK@@Wh@yK5daxo#|`z81=HMLg0`a>TdA7VtJtloQwO_B>2W|%MgI)Lo`=(TkH zK{fF||1cw`Rp?on{j|0fX8+Xaf{#}d~0*FpCTKsL@sOHBKBG!HDA5>kU5uN zwYG-A$a=ZKXXJ68(E>Qy%4VdG|G?KX7+Q(RzK1u!yihErmoRqr!Y$gn2!hrM!0BN+ z2P4og-J;zrL)=P{mXW!KAP_B|w@}tgzJ{1l2Ee+nDlHud+82C#Y(3BRtQnrDu-N|t zKg9o6kv(w@F?Gii01*IEA}7TbfU>M`?HMV!$JQzU5#x^ivXGty*3v^xjt}MIIGWo> zUm6$yx>~6K)*AAYFM%tFlUj%{TEBpR7*?&pYC~^eWvFdxKLKyfUcWaDWgQa@%F-Xm zhQfB!Ux6qeg)Y%j)~YvM3?~F04|a6qL4A--;DKUXh_NXgFD!A(D>0j5%J}E4_y(ttPcO2OBFn|wlReK#eGv`=W&0e#yS=-xl zodqp+H<`~N8z9Q0*RBq5k_AB-8yKkuoJ{~E7!~!M%}uB;(H!d_X~4Yu9)&;`L_x>K zhU+m;YJf^-C}XX*hu81SHw&2Y6G7F5I~OtUve%;=Zf_V`r|3w6z{BUjd=p2ZTddS+xSq zZ4jZeW0<}{zhDpIPMQAhm_s*s2bS;a)E)1PQgvv*^g21(FJ}{QUM{-72O!O`__0PD}h#nE)PZ$HLR^H{Xg}n|y@9kKdU<8EoOm*+Ix?S@kK=S6xKR!h>hha2IvLR-Tx47B1mxj9S@c^h8 z_F=M%j5@A;zyj})nR$L-qFZ~zE4iQN~_pkesVo;#ON0R-e{sNY=C=z97=9-E0H;Aufvg$2vD zw|9#HbjA1;zJFlTL>jhUi+ddeiLUp)QGfdKH~<0AOXL%HWECf&Ev~eO)I62Wuv!o6 zth#E}mnnsg1|}2MUucyT{a|!q$X@3(e+Xb+4TTf5pw|ee;HwxUe=jG`Kq1-e{|fdW z1AOkRRS|!F^g*r9R4XBlx zD)#R|WW}Pg>as_k;^E=JsK!7(h~qGde6`|CXVujVtu$^*)7QyN5v|Ff$Ua`EfJBH} zF4*^!N&ZyW=LEsjywquz~|EN;!$& zsc)$>jkmYgtw%~x zSIVTvh#H^zU-$&`U&w%~1IggLSu6u%LmVln&H{DTll9}(EaIUzkHJgEkm~N?ks{zC zxBVN}Ai7n8pgAb^K%{`nex@~^+X8GcFClFM$OLy1n6B!)3b=8M&X*3spH~qZDL|SO z8+B+_6#z@_zCF{?zj@n1(a2C&{L=f1D9wj&BzUc7j5`}S=pq7q=N zLu^FpQ!HI!n8g9kzYJQK5Npg;Lz5|$}?1=!s3ty;Pt;O zuJlcml#!9aDRwbIyA7URe1QbMmcR0@B8ny6^9l1~KkuZjUwX7gJ2GVM@bxn1dVn>5Ws_z1)IkYzMF?k)`7xO2F_Zx@@u zMyT(Q!ZY;b7tza;!tMA~BN}10#Vl)wxHmgiSKla;NWnPeZ9{G#ilcMd8wiQWio)

=KWV541I8Ua8Ue5s|lVWS=ET{haUP6Ck*f$$}77#bLU#T3bUiw(uDZjD}J{=RiNp zEgQ~-p`jt(M~{9qE$wi}o*mrvbvx2Rqcjk{`M8Z~;dWa#Zs}gt*N4xg!TmbpMSr}= z&d3}DHZ-7}0i3ah#@(Ik#6$>s?%BG!RxYpsIr9DrUH-BF zfUR1$O|GPe2H$|0Q&*!Roa$75B}}i(tL)97TU+T?t1yw%eDT7C+E)2{w{QQ11f@7z z#Sut49G5R&hMu8kbpZ6{)>g@+t*V2?JYWILb?}LH^gRDG zL%R;01gMSLREt}+zTTc2$LQGVy9*yI|0~` z3Migh$-@8%!BBM!n!f3?r9dN_!eFJxKxcYvFJ{fyg0YP5c{IZnk>joYeSs%89@*-{ zWx?a9MyTYG8vX<7V9t-p)W-x%eF~r4K@tPIu;MO&12P4P;Olmb_(f0FfGD|s?b;v| zI{?%R4r3s)4$9>Xqm=J2iEIOH1+??vnJIwZ&Z}b@DD06j24myjq7M`tsnAx~cPc^P zSmWgNu0EvjhZt^riYowKMAQI>X@Iu{Y`6guTQs|#F1I+{1gMq3Y(J%%f{9ln+>6$- z?)_MZ{{ya$BvX=6QCz*Vwt<~*M=Vyh8v(;DaXiA4(&-4m>izu4CzowL^*B zL7wFf0xZaFJ+_B)EGlOBJ6%3HKE~0YA>nk-akM5Z1_1X0g|sgdbso^yI71Htu23D9 zaVOxrO0*wqjw%r6i>ALg*jmW=pkEcZAIM9kxP1fw)$DM-geL{3QFHqWTX(hQ7cZvh z0i6e&kZz%N)m@2@qrG3DK%?V?1srq+oTh%d%E`12cHAcpYN`X*`Wg7?(poc3O>`1L z@RPuxZzpiFOTPq~awt5Z9*oR`cg*&&YGuk+hW*ypR8}C7nPSmVRi=8Z04++{FDVoY zfgwz2cPzr3yp&?0^EFS<9EZgq3kfO~J?;&2Vx<%8@%5d|unf#zNO5P2i z5>sr2S--fzo4Dc~JTZ%v`7Fgw5bly5O;cB_t4-z4vny&Ncw}G7ssOU)ca^zs zo0p82N|^C$`Uii1J?{293D8`(@L_&6XLsYsXXKf31mfl|T>d+p{`Jf0=m3@r03G^E zc5a4#sxysv!r^VFv3zHm8{N=r0H+C}M8WG}}x>(Ab7zEX#t%O3+p|8{*IiMcu}F z0}7!zwh*`gedl{gG1jOIY++^kb<}+BQ%{n7mIBjE=Oh}1D4AZdVfBS4c8Zi*D`-jP z0E>rYnPo4!UaD6;Q~YXw>|2Dt0_jcV)4(uM)Vs1D(gllIMkmlPkuN}dY3xVhWoUdi zx3LiH?dSn}aJ#CQ={=Tw{M-Olo{v?mXuBUMGs0XdGXHdcJ|h(1XiivmsCY^xN5l4- zaEWQ}ouu-fGuZS93MhA37GlpF>}*1~Eq^G)$ujSOK9V4XW9iP1*~7BjBYdL#_;|Ub z8*sT%g#~Jha)HG^`US+bxmsz74L3zW>af3=+G87T_JXpiKPvYnO4&d&jmM;?QtFuS zmd1}b@;z^eX3k>v%jUnI?&Ug=t2jcZJ-6+T?mc~NRwQLIYPt^A)>#fw}#C^Ikrt^eViCp<5#kUBpl-S z9E|6hZI6u>CZ_txRPB%xQ0M~sm;WcfpX5gNXJh{M3ct!Y~i21QJV%V zUPVeuN=T8@11~^>*nEwCdm092#|8CavilC=ams#=x}KwOjpl%2<#8@6O%kpJj$WFR zQE`v5lpRxHQV>#@Wh6lv^`zB?=V)?J$xwdtGEa9z-+1J!36W&HE*jx> zXQ zLX~qo#M$uZeLGCRyF0o?U>z|Z%m|`C=j$AzGDH9^6lKrT@$|CTryu z5#}I%_{R97qW2&w!11(QW?RK0)mf*+tMr%I$4){rqP&sG(zH^7T1fFkjr@B0^jVwS zb6;OUb4@xTqTKZaYp_y1^1Ezsq)n@`l0BTLnM$}LAS|xrd3z&>?)^Cts}JTLLvPt= z05z!+DN}ukMv1wTfp}Ogk&53|Z6;T@uG%ecKI^FiR*anUNYn+D z4W-5+?IDq+W#404eFs#|th;pB>(9VRbd5sa7aP{o;NuhZj{L4o?H_SMGb5X&X@+hF zZ0su7zvJFw$=0?zJ;xOm)ZtI#r69Oqn|v<;|7QLQK7J_YcsD zQA*e+PE=1mN@?l#s(HNLC~rTB&eKM5S=`B@)crHz%@9-99ZO=Q#$C3}c0y5PUTOMj zdx0TjaoL|EL9;Y>?xqfB?0FkA3KgmDh^ilXVdt|fyoyIm$b7faG`geh?Tlg?$5_Jw z-i!tg2ZP!}TGZ@_Q3A8U(6$r%eW5ikC(_juXJ{G_ac@vA;=PoWyipNpR4tVDD$i9Ik zz|CDYiwqWKlzrcMGd{}*4QJP)MX|v6oWDvRdoAixloW>s0^1UKo%?Zwc%*UE6LO2P z8EJ|gM>2gGgtCz8xdSLF+0~P-zdq!ocx$4hn$?+eO0Na7p{RJRMl!1ltJYQQ6rG;* z7aC?&93O0#Xk$g1by#DG-Y_kqe-w^jlvY6m#2Nh`X!IFD9JhDJ`;|v+tOJvqCY?cV zUDX$lp4X(g`l+;_bd~8Z{<68 z?{Zd={E%yi6FKvo_Dr|WSzpVafFjmY6tff;6eSkXs*Jr0|LAgS9qxR2E`c4|Ss4Wl zJ|oZ}P<>GWt$567@}@29_S0-2OdRGqfh=1pgIWoW1`T@R2ewn9S=peq%X5onO@a>J zA*f>ph^jph=P#H`f1A274q#-U@R|w>cPB`#WoePjGO=ws2P~0lp}@DSj`3yb)iC9C z&lXfn^qFKruFb1Wv#+QoR~*2RJ(?Cqw`-1a?-ViR%}u}XiYMX&q*6hvFqSEGl`aL^ zC{uB&Wnj@cV0#-QE3%GyQ^*Q`c^Uys%<6R($YWrpf~m@dB~F zmNci{CE#H0%O_hBE$^iJ`}r{*;}mW|;&yEw%VdRuPsr}>jVIb-Myw6e^UmLgzGF(L z0T2%kUp;)em)P6(8gMWie^C)gyHaMq4HA6@X|HxkQjm`k|K~KR1>|dC0kJWd0aqww z{CqEs{{UZVac%Cs%fZ*K-&vGq8D^w*DIxwt6X~4vlwAN}gg5RTYZm6NZL@=-Bnndz&r z(NlRt{<16=xG9bxld4sYV`!F6jU`ucxG=(eo+S}&h zY>k__kHEN51)@u^ai(<5j;F)UeVnjH6$^q}@@6q@EgoWD#?{qzxY(#*%ogQiru?xF z*pK?3oUAk|c|Dot@~dYX-o8CU{-7j3+x=1OY_+LIRqy5njC<-AUweJDbJQJ`JI}ZG zXpJn76K309=9W{!!LjDn#}5>QlvBh;?vtW!<*(RGrdZB9^jc>-#%Hi7)A}ZT*@>Qbq zM|*K@A9?H3v%buiK|ZW^A|#w*(Md-n>gmWJz8R%nrNw!u&X>ZH&dr%i^rn=^oym30 zQ-02$7Ew}%yvpZ&RJlu}LK+NC{TNh_%$ZT?=Pj7Fg4#9%p+s<{8 zHiZ#|#l#^Jr@?}l!KDR+j=Xj95JfKtA#nDtGwJ6}b9eNp4HW@`G*vnkS=ud2Y5LA@4ze2JyM?IE^A8$bnHI6~QY$E&XCgWpl{Nx~$Un z=WC|A#Rqzip!_ar(;sA`k2OyRBUt}~x8tVKX0k=&8yx3!J}iu$f%F>a1|0HN!KR~| znD93|awC>-2xV?un>Tba}77MePtFyzmq+_ zgDlcW9t6DqzXk(zKU;esIkP6%{^8UQs3Gp4wq@JFp;vD60d+tOzIX(z z30~mfk2e56!@@QcJ{NGsff>pjxILUn7-u%s4hHoz1Du$^q2hQ!(Bp7KD3B3@_E}>% zmd9oZo0o&<4X4v^pdy$96cn(7GG1pc6NZENxFNf&CXPhua0d#_a)RCNk=>mL4a>>; z<3TN(4?#g5b8#l1bKyYBhr)~7K-;!KPS>b$w(>J)XUBksHS&iVrl$>*5QM#)$}pe9 z91O6zyBl1H-awYB4wf-jNLS&GWJrJnqmQxfS4e`YZVont{du z6^(*b6DKNptXAbfW(1~~BO^Bx&SLS(Cktwf1J$WIz6WLAeG!$z0&`#j{s__UrW49mR61% zb`$R1yEl(*X=$n5-quPv%%lSq)f4x2bO2(9V>++@H>bTYa1jqUG-m zgNxM!I*c(m?b==do?j@v%Gq6puA8xWz?W+@YdHb8NXO4VvZchTHoOhMJ&IXva1E8e%7~1d&2v}P|2oG0jDSJ35vNH;h zJHX#x{Mj>*?__Cg>b9e&b3Mp;3n1FSl-x)uUpYj>Bou5-H&nA;v;`ZC^n zPJcZ=t6OHs6$CC^=yP)*+TcF48@!LdeV|^c%2hbp=wN5Q4Me*)$d5oodV=!ERSOY1 zWhqEVg%B(Q+UbS0_v!I2P~J7b{R1bI2Q)so47TFk2-V5lfqSe6-yJj}^&9QXkV&)a zz_$VJ!g*l0qmWmxUiH|Te=))tF9zqZfIZ(#O-;oyu#W`6>F04RRA}c1bkKi;H1}D7 zE>Mg>@gZ(>chnzTL~O*ZuiL6<7d&5s2xFSL#-{xv;uSd8+7 z7!716?@`pSB<{0-^JAIZbL!Q(x$YDnIMB39{RZb{1?TSJMMK2;+j^~K2M0u0x9xc@ zgmymYr>?@bIdVyzp}R~m-dnvA<_lw0CF4Ua%@G;I=*d&>hdLq>Y~73~2lyvA9uP^|pE za^uGG>@0Zr1;P2`Yxxd}GjMi~O&|)LwiduY4R4H!jLf`h3%`K|kQk(6BBEjhBKbB{ z#GLeu41gfe^06ZJ*7hoMGr+R~#a&iPs+W-uhv;o^e_*|%yo1P@jqya2)7Z0q{P+RJ z0g(1d{|aPnZEYadfUC+);m>q)H&B}V;QMO*E6iV`q@=uY!xBD+gc6^d%Rr0>BgY{F z)`t&!hhhTg5v>_pgyi5>!$TB*`Evt)0R_6dyBptEP)bkXARca118!7w5JtY=d>7#s zU7xzAVgE154QBekTdaJ?aLW((SXmLtsGJRAHVj%b9w%VQoik8D#R_7^QG1P_-> zgxt4G`!d@guPVt_X_lD-L#oP?B6kbL0In4?Vpx)G2TShyYdsU{9_(o)f`y z#p5N&60mJcwfCL()_=k;I_xDO0Uib7>^8;F=pXzHpp_fdO?yJPy$Dfl{$(Mg5+n(* zx`2kU15b5vuYsJOEt{ZO^~`AwS9J(Y001Qlr$D3--h7`McnBoQBskKbd=itA(h7T4n(0AsK#f6Hz-ASIRvpq--caeU zZ_Ul9r)fC%4AlnC!2VA9yRk2L_2E|jfDCgJv@B?mQL~@m91j0+DU>frMAf{D1ym8? z;UM{1i|N%IFY0HKryD zB&CUoQ;&G1?Fq0!Cs;@M9PX~!&_6P44Fe=DBGLdM#efLC{_x3)tPY397s$<~(@;~D zccB>2LbQ)?e|DL0li*Y-*n@$882P)Tk)>hVrk4AI+80t=AzXE?l3?{Nt09;FEfat$ls=?>JmwsjojcV`dqmB>iI zQbc+K!=ZylGLqZqd8}~vuP!Z(f@F*&&2Z**obQ3sZUUz;0L3Uj9QLksgle$TTJS!= z&rbu6NJV(ap5>vDZpglj2wMW=9@c?evw{Xu+zTE>fhE<_vkJdN|=b@u|0#evT|}0m39X7O*j_{qZjyjaEA#NahfYD;*`;E zBA)Hckb(M|hvZJMEVd?bZ z$IbmajNgq}CoKHOM9!U3xhP*maA59mu^j@yLlSg2WsCY)+; z#;T}+6X1F9-<=8JKIFs+7b|TdW7FQls_B(YqK`&lA0pLmky3HBdht8I;?=1bcOJz# z&LF#h|0LYnIW2y%3&d@eKzY5Eyti6&PVM{%xaqOo$ze zM^=MyKa};HLdC8i=n^@(k;qekx4LM=XlS!^%wo5nHVZzz9< zAs<0eMFUuX9&7(RoZRw7;ygFdN9*8|ASm8LrE9$UZzp)85X1{N3vcW9?XF&&Z;?_ zN+64Ub@%SwjEoE?L_9p_<6pJa>icIue}DT~&i~qx0f55c9NR1?N$~T3AYAe@L`vGM?4oOCn`9~`HGO@n*l3k0p`O&r|F?@AKbeSbe$a`Xgjh9oxyUAN|Y z;qcPbbM`tzXx?s-ZU%;glp}755RsC?;p_Y;>N|JtzzK}J5S-@@b~jGNm9BT!DmCo| zb2m`5d1T9LYc_a|QS2-%GLx1#ml*Vlx0t~tfm*&oMP*)9=*eT_6nveUdK3x|L^}%i z$Qcj`$AL`M0`P`Ajss7IgQ{T;THMzUyc4;i*4f}eYlH1q0Z%f6Ln)p={U|_+#^%1V zIMM?fv6_dy{RWs=;VDvuKXEn;IMmAd5f~m3sE1(x!=>h>0Td^kdG|2uPZEZu!}VWm z@c6lQfX7$iq;ZU}&RnbA{0`3r5`ffESy_oka5gvj z=<8MaM1CjpFK^EW;v5a|Fu?RHFts!wA^cdboJj>I(BZ*}u7f zit(M+yP>&WkQBgZfDD*kf?R`ph8Ix*Yso-qdOPBN^1RhZXO14*qUD}7? z#~EMM`dOhfJA`t9{p7TyT8DmMF#?{==-AjjNWZuz;@Hprm-fChDypnaw?KhzL}>$J zMAVjsLQqkRsHmVwP(l^42u7fYk|bMdtKA?-D_KQFDXBmZ34$a8il7aW1qmXkM9Bye z=GoX`%{Sj&>)u&2>$_{#@S|H>q)(l*_kQ2^N$2D8J?CMnsaMh0@*Awh8MxjVmqF8S zNI4exeLjvwqfzC#YfMvZjE#-M%SEswmC$pv$Y4NzDY8~lOHxk%X|_X0Y>j;bx5npF z>GDdKS1Z$EV`B$QZ>kFTxVhJfuNfE|pKN^B(9jU0y^PG$D1FW*glbR17-R$KRmBO6 zwxOY+fKxd>C<{QWBR4ukQ}d5Eq2zVAqmmuxqq9H=T;m;ty)AxEkt9Fzm@}eH|TG3y>v8aTOxDG zQ0GZFJ7T>`Ll7YN0r?0to*QIjK43}p4-T3G2t-iV3cJZuq@P4q7+)slSJQX*Gh+x8sjh~z!%)JOduH;jDk`xl*LM-RHVxX@^{Gl5wOln2?_ zIXQ5*oIQ)CWi^HMoHQ~s(F9;J+4_mt!Gm`|f4>^v2%yhRP;uS5UTCrLv8P<*`RhqF zp}D!aF&GRRn|LA0Eq+3@_SxMkXQ@L52ROSRb76kezIrA1T_CZ)6 z8IrI9k1A~0vSr1JGXpcE#i2!)$XzooMO9b`Y95W4n@Z*nPMn+6fD%)mg{#Eu0O>!Z}@*ehMoc zEgu(=k69u!7Y*RzGl$19Vx(3DjBDP#J`op=Vio0>aAsj2@H3djbP^A*9(kIw!#5I?4EZ|qQt$RTI4F4 z;^Vy(A*2!7dF-XdC`gDNgr{HtD+}Aa1}!*bXq{GGgh$eFEpb_Uu5Rum*2RP7#5RA( z*D6nD7sCV+M=u>3!8}7-lH33LHUpg&&`}^PFZvWeIPpF;Vd!&XQdutsSQ@2Tt)jcN z6($5Hg{yyEost9(V+ue}!5DN1ubzRn}uC(goB{2y>FzCk5 zue{QT+UqPiyjbcMy|* zW)Kmf0+>676@Z?UU39ry;0q`}PxQ4vX-D?prtRIgkEg~b%2+Z6FU8b}YnR6&rDmaS zzr!%S^~f_ZAI8-DX|Ys-O0y|v2le&Cw(=^h1d!l{C|!h~p)D9hNwQ;Ma4ujx<3AD> zm-6jtdPI!UXaS=60le<}8&fr+8dJwmSmoWJ`YXu#j{BDCkCvh+M{nH(>_xyCi9&t>!FT*tR1)De3d`Wv3XJCk-S4k^Uif$R0V&b(+V^k{}Cl2s3YWrVppTq}; zOAb8>h_Wze%vz3$c6`gc+@ehAeN=d3RhY$K<&izZ4q$4Y(9Gj@I5uRDKsgMZ87;1Q zKtF@68?U>(KONPORLv*iJF}73cg{iRJ#jl5$QKPD;5s~83)L_2=M26i zMD}tI2<(QeAdN)v*V?~dFO<$PkgJ~fb>&xKtCKi$#?4_?T8~)YAPETxI@GIQcOPE0 zKZkyywoA-5Q$IF--4!MKEH|}8b+nyI>Q*}I>9gyy-ESC~1})xX_+|)GUiZjOfKCvi zneI~{p6SjZ@cj=P^+6|PA2M<1U!k&HkTaX1?t8+M;a^P1;BLG+ zM1Y%}9`XuHL+w~N4x%$Oxu&y+1qRXq$$4LEF|OyNVdVoOGH)r*SlA9TE0SFsII0adu3-&{%FUUCU=KCqH$i5t<$6u6kKfUzRUfut zM-ReO)dR)#c4%KYidGY3n0Gf&U>P#HOo!oi8X?^yQg^kJz+6x#&GN%Ksqp_g%Mn_o z8rdSl$h82>neKCY_h&qgQ`mmTROl>;_E2{ildg*I94qxp#x0WH!pkxP&4x-=F_CF5 z^gFuE8a+BD7<>qOLr=t6`7)ALAHF9JX4;(1^Q^=Gn0UAfW8O8u&m>cbd)Xuo z3v$%m&0WvAQqM8Uu4^EGrrcrErg3)bq0uykygrXNloUqfk;8$IJP+3SpC z=r4)WS^SZa5%iZ3Wuv*IR<5y~Mky=!zrts<2frSru-vAcKZvxz$pyv$Ch1E!I1q-Z zJ|buT4WCz;gRpnh|7kivZ^ZoMB%I+#lWLMeuN|tC%p^uIjI;iVJ9g}tPhnVK4nYCM zjD%WZ)D=NAD}=k-_(|2ky{WLU5GdJFmJD)rfaTyrU3GQFslXTkfw$gejBPOW*cK0Ic>XwkNw>WpmC!Xh=!<6%X0-C?E{ca3H^$-l$*LwsblMXSgq*-g#`Jz6^A-eWGsj*XCX?91@RQ2+mYNX<@J`Y>boTc3Q9<#Q9RX;A2yh*W zVVS`&njlbP%<9B?z@!Ly$%bH?{2%f2!`3hJYMGt3WQ)sDASmb)t+7hcEwh)u+Cbk% z|B7G@yqUlaO(jH?1L)%r8x-c9gG}O0u+9SyS-N!TIL;^hD5#}KD}@T)KQwWp&X_3p zJz7iPEQTbl+~UBh5` zTy3<=2m2llcao%V*%eQr%a$oX{7k_>-`=P@Iq)#KalDtnlUK6!ib4<0csVM;umBS< z$AFg*O#yn`+NsznG(7UAWXRj{csu6bJycKXEjeMH4{E(;p5{e7Uc(*NkifeLga(KT z2Iw#VTSC*PtEs7JgF$#Vg0qkbHVkb@sIYRRZFLAlXu7iF;G4tDB?RH+jvwQkkf(F+ zWWKz%Wxb5d-d!|Ty^RwXSXNhG&mgQ<8K3zYkP|u8I}6I)NO-$j1f#VPcY@ihdAB{i zPT~%D-HkBu5*+Ny7@7&L-drQD(S`PgfZK~szypLDejOQ&RTUTGofBuLvD>RQCA}<@qLE!&Dc#o4#QOrd=#{J57Gki=w#Wy(G7L?lyzhgkDpYs0R)y zTTa^X<0*)CuHYZj9Y#XHtC=|)Ta8^0-3J*(>Z708;zus#3Fa7j?~9pEh)8*=o@)lf z6hA$|1o+;rcU4amZVH4FFm9jgF1T)06<6R;oE62qqhx`Z`|?LXs2>`P+7v6V#Ltrq zGlHJuXanK}ZxjWDceGnp9ta!PemLhytrw?)|Aw>Mu7Ntxo*b}I z6^?O4(M7GHTFINsmorx&9l)j{{x@$xr7tVoL4YTW-ZxRX3QNYbGA6R)$L<6^Q_ksJ z%$j*!aKTo$cy^v$wc^aAY(H!`I}0gC0Uai`h}V!_9X4$d*|bZrs*S-9OYC^%G+84@ z!*3VNnRA)xIdlG}!wpFNs-f9b#!#8;Fy{PY=JRIHoij(fw@PIQSmWp%aAJU^*wJVauO4@&H0ehUt{TV2KnT0)=?V=BlZApCg zpa*(^s23kfJu-u(zdwvHejoR}5i-FBim*0WM z!Pe&AjJGJVg__DD+3FFdB6=8IXb^!!`jNCXt1{5SLPEncqgRn!cu3`rBP`y$wH3ZWIc;WD5idGL>25%r=oNsF)q*w)glK7KQ`EW^RnLk` zf#DPgXPoH_1=eLRaE)gtU~Ey4;3=Qfu?GEy1HY2AvF=|tLdMWyF^Qh{4tFT_4VnGeZ@gV#yn9b z@E+xTL2;w)sJij@Xorz^cavGr4qldvy=WNMwAk^%F1g;Ujzoi$N^Oa32*N(*&IjCU z7@mI0w@}xy5w2f+(6pbUAA}#pPc!Yqi6bm54E{vd1m-(7s%qI*vgq4+Bh=_{MRQYi z^PpJwd-C~?;;4#vl5fG1`1jKxeaQAr^Vwkij=~G0Di(g@ryNtwEZ~LBsSCN2lu?^ zbddfl0l)sv~KtQo= zs8W=vI3#GJ2yE#kG1$DnX`pcVLtO1tKz~ z8?(j)YEFlez>+$E{WDbk;j9R`M4DyQ-#J!VByG}26v!dI{vR?l|3$#`fBi+W_F+eQ zBc24!j)<_ZAHUw8fcu{iy0xT9%`PwLC_oF0CPeUIp6B(FSF4+Do?XxYbKDFc^oJF3ECh#3v#mI z3aEzsFWBZW+fF(*<)20$T>2nya>6G)94)N$4WcYM@MUz=qpj>1t_oy9Q4l`n4s0Wg znh?5>a5ci?qa+w+oL`NosjXvUV;fW(bN#{;fIyZa76oXJAG$cH-@9Q5uP_F>0Wf<2 zsTttZBo{8^459N^WIX7>jII|>C^av@6>&)dCsq%D_P%+<^JO9qS2W>NQhnAAEpUD8Rfu_qEx%N)QD| zu!{$4RqsvLFvNRJ8hq$KfE9>F{O6*n95IP%2uHV@;N@Vfu?tvmTkhQ9R>LqS=gW;H z%$^fOkIE#7z@(ri^+^)oIDq&D{T^UZWzyLCH&stVGGFVY6-JGF!QCTdU0#N_U?_v8 zESTOzNW|#i#|f=my$CsjXLlGJ6nP7@_K;s(ODCL> zz;H@Y@__pze0AAoXX&n<*RNlD(fx+5zvkC{cjP0qA7sbWJ zMUfY@uy;ZxiJa{xv?7~fx+dt(S0Emny$3!4`LwHuzm4X5%1)BrH@q zS_n}NVOUVijNjBGa;V+hGK_I%66VS^fPlz*GtkLGEPWp^3qjy6xRUHG#8YrS)j^>O z(F)le@w$(SW{XEu;rSGv%dv4{Y(uVzEG@qD&3$VFeSJ4gDBnHv>+lcNG|RX~ zFwfxrtUqbVZQMvGcA}XlOCS873*LIyCaEe#0wB?zac0^7NhYfA-HT``M7jNhgr&{o zwR+IwV2_#rT^JZ_z4bk%Pu6c&!Sm;4NH}p;5vj2Zl>ZY8z&B}V^gx~kAx@cOXfFG& z+?1Auk@XK>LQz41Cim>$-7EZlD%e}P8k06;Hf(6Yx`WVuq1TUGG>P>K z4gdL{e||6w*1;MKJ30>#qNoKQ8%P2L@E>R>i69eIseaj>giM5I+{CfM@xQkJ$!BPo z5idmC(rNZ&h6h`VQw%&ss){K$_4dBKdrALPYyc&{o00BHBKJd23W6W7E7HCPvY~m( zKNWmqmmrJLk3kx8^cI}N>1K!#$@1tL#e0SDGB#o$oF*)$v48))uNdx!(ZxY-)``21 z74L-zfrkX=SLWb4aLN!L8t?)48?RShQXvU-sd+EmCdRyU?xKrp*QiX~JE+u!cVg)e zBiPFzC~T!%-2@CKFPa5xb|SDTdOs0$1dzhOa*qh5v#Z!{rHuC)$C z;xkg(YcHGe&y-(-18@>g3*jxE?3C<@DNxt2IoYRJ2 zFtj*7s55wGgYTe;{D^Y2ZdEzP+c7}BqAvqpc_R-sA8iKYlq*o;Ra6)&smFOm(XW;8 zrneT%P99EC>yDh4X>N!ua#dB$2W{6CPAZg$2ub|wyBM3o-U0>gxv|K|Nc%w{t50fV z9xO}e>E#dwC;<>1sgQfZ21{eqgOUYZ^cKe!yn1i^NS+tEepou*bDN1?UNk2lmu-aJ zT;jy@pv5Oa03!KTUC(4s?Mf`;L6W6C=n1(IR~(dUan$NPD3Vy%;tDJ;-s*%5^DMU$ z%l6LAL|V*S*Oc8Urf=;Y#}K|TBZJ^muBDQyI1&IO4H|uJh|gfY>_*&V{#>%Bk6{j@ zPwzr}F&G;OM!nC-=qQPp4#e2`y+CDvaYaCDe}%TVQhchFJuigjd)( ziDZfUmGKK0{g&KMwbOVZK1iq~tywlbr3?BdZbux>m(Z8=LFLN3=$`C2Z+TDg;>7s2 z59sMMPacDM2X!S2N6C@_A!xwQL7Uo%Wb@zi+e^)qk9+MDq)3YpaZ5`s0Vn)z2JX~A4{EPE4 zN>=$49i7>3F~8BEqK(e$fFxYb$eA`n2zB;{uhU1t(auM z**{=v){e=Jq%UD>y?T;bhG3jV2ms*hA%T}5GL7Ga zUcMY#!+$^MW|ti`EYx)1R`u9*c8>jYN=P8lsqB;+=4SEsv_qeuaQhH6ESSi*F)L)_ zTJdHpQ>o2x5W#;8u$Tk{lJ2S|`5lPhf#fwT?H~LEnv^i;s7~BD+XlvgdmBX~N^B!K zSYqB5sOfu&^+dw!-@h-vx$CSQP6WIcX?=u)PnjfU=)6{mrAr+IRQ=~kNyV5+Ho{SS zV(A)I_G-deaKLE+xnBuGAo=<~D}4ZUs~SRqGbOt&yfPpZmn27&{<-qdb^@ygtdB8$ z|LB>rZ_>^PRVe!1jz&dB+Q1s1hFT&HGK0m3TJy%vQwa$8GNDbgjS5&8n)y_y7|ZlxTQo5p)K2SO&eBtAk} z^M@NE6k5Z`NKYKpmHr?;o3>zXIeK>12D(V_#fuk%gR2B=fYKwrEg~Z$V~dF7qD4hv zC6o{avQed$Iub_@aWb2X}^PhOX!~dAslzjfbbw~f18C`V<8U>QnqLX%+HH0EaRK3$D>^Lj{@nR&<_F4iiR+C>N!mk4Kkq9CLD*oXkT0oLKuk792vsD z;Z4Ckq{2XA+KUo@*mEwgEudjc$Q_8`s1zJw{>IkrYA_qVk@H8}R7hpTVd^6Rp{nJ&|WF3O;S{Zc` zSQA8{wr4=}r;eP;cAYDS1WylOJjT0-zul;O^H#vbNKKBl)c}JMv*`M*D^Dv-cu1BV? zH5v{kwl?0MQH}QJ@WFSGSk*y7h8Jq0;6a9%pxK9F4Bez2&Tx!aEwEEV)?pp6Q$n^u0M&!e3jUn^>P_g`>fvl;kArU$ z%_z!3nec^<4AO7<62q?`)5uMMyv7}RRH80n%3*BB@lU)lXc))gL0Pcw@Kfb#Mmgj* z_&kga{=(lw&vF9T0(uIreH~_PAdJ69uDgv!h&zli*8{^8S|4llv``Sw{pL@cnZ$I4 zb`h37B)Gl7L&MAnAB3Ta(O?5Q6NuyVpFsK7mWEjm7X=Ane!(q4F@XO)y}j!WxdW`g zGaxn{Kti7%e%_HLTaH9ef{Gu|(1^q>!`$&w76M4}41vLHaD@Gb+$bcy791HAog}ZY zXn+74dX93OWs*T)7%7>mLJfeZZ-afqBPy@M?1PES-P6ZM9I>QZ($Un4<`-pgznA|M zeGd+S+lP~i3b2Zbi4$#3j}k=(%1U*+BjOBB8Z z2+ji=oX8ZH(*dcd@cKa~u|^S3_XOgCibIO0V*nzd*TSsu5lei&tZox#++_Lufaln6 zMk*UyEsp?U=T0lRy6NB%xdY^N2axS0lg0BNWb1faxQ#%}_hMoM1HvoD&9RG+pN*xn z{aP?+5w+h5C8uuLI*B&ktlmy!vLE<6uMr4sria%_+oYafB}Fxf)@@Vo+VSOJBCKa#=;z}knQ${%OP1u5^sx~onHC|+lBgWViS z*zM>UY%Zyue^B+;ZM3ghOV}{QDJ4KtA7HndN=0ZyY|+?-)%~67jZfZ}pf!&rAbkoO zwD37z$4R{RDIAM^a!`U#c~Y9z`8l)~mkY9d0|?OEH#n%Hp@By%RbI``RY5m# zmt2toVp2&90O$Q53G!sJe>jlV*7d4)Im7ntmYPp;fJBBo3Q5k9d$a&e(5j)HOvm|Rh?jU)x!A*8k|8Am?t@;N-Kz{L0 z;gWM)E7n!!7SI(6Wxx6XwZz?4XZ{Vs%`7ee diff --git a/docs/manual/assets/screenshots/app/account-security.png b/docs/manual/assets/screenshots/app/account-security.png index 67ecbdae537699f7b1e29feb5ac3a2b952015e64..b0f2b3910027516791f2a97468232921f3105732 100644 GIT binary patch literal 50180 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 z zy%76=)Hds$lKb{x^gp4k!j|1>mO&Js)o|+Jv@_K?w6b#FGwyg%`biNk{7C_mx2aqlfMuv0XV=R6 zUWk_*DpM68WP}i}`@^?g;I`>b{5(s3#||`y;ao% z13lvGLWO035eqakl78V%=r8-(&>kB=4THjCy+On*Yg9k>Vxa!Ab9(b=oUi1x(C;pf zl|f8#qX|@5&J>@X7gwgJE&i(}pFJ*fo-4xim1>!?J_tRFw5~fbs|y|YjbKtWeDvtqSQJCnjx!E?>)6|Sx08NueQ)6Y zXCE%N7Mx=LYC3rEQva9015e+UcD(l%-rNso^0#Y8-GdapmwyYxcJR7hLCpJSnF)Mv zp=5OB!eGZksCkew6En+>=c;)@b&RwQw3CO4N#2bx_^QX$&$FurMFv?H3UNS zLFa)$Y#uOI0) zl%qu3x=t-*4y9B@Hq0i(XO5S{C~tG3+TOaV$4y>dURiJq;KSK>ubEyy3$uDR+t07A z(Z2KlElU4$v?PKtjJdBtxq z`BcfZ_s2it`rhnzWj~WPY}oCx`k?dB?7e%DHm+r(%SfS4&s*9cfbpsnww5RV(E?&1 zk18#US6Wvtv3BQp3oh(82mA>qVTlvKClNAlES$Qf#(lu4e~!tKafj6aS3y!!*K$Gr zB9ylXBCTcRu*AxuU#r6ffv3l)!L=0B&%7N->;t#=w`N#kEqb?<&_wvb$D}L2y87?k zZ+fRaeR40IfArx+CME_yIo67?@7-HD`(Gm-*$Lkn8IumWdPhC_{CwBQU1KWX^Y(NQ zvhj*b^)C(TABtZlZR#X$H^jS}x|=RW53U$D+p$>Vk_FJ$yDdGfix(w#VoNK2+U2Ac ziCH5pj3w1qcBAfZd=t+$`~9Wr_>V2d;dOw>ik-Rx1ZmXuI@H$UKg!O!ZbDhLi!Ch) zn5bxKYC0_Uv7(-wd8#ztm8x83U!ONwqUlGV$5j@%tMFyIa6gvLSu4~ryCMg$dD$7R zHA2lbsX7puv`+qrqLgMXk*Iv;{^F+Y=xBZ6Gj@|r9NhC$k=bAi*%rgYHtw>aJe0C+ zZZTyQ|BHppnXY$xqCeJ8SFjD|W%PV0C}bwNOB*wMT<`3ep8JbmKJ-DZzj1Np<+rM{ zV$0DYJdhqCui27~DMb)OMrOTwE%sFZZJD!;L-*A*`;p5Yw9{|zJsXr$$sp5|DdbqW z{9>nBd)X{H=@qTT@NN7yWreA}1hyxQDoJw#tV?T+#+Anc4KX)c6b`~hU*CFZ@WzD7 zb|VCrAgANaH0D8lNsH@K$TY@3{neJ8jenZ9;&f_$)Lbqep2i~5FkL>@(3a>3`q}6- zQN_cQi}!I~9f{1-y35y!w2>w^!90`I9#q^B&X#-h;)?x1Jd0ePi&35`S);7UITMiv zepCV=+0l30hxWk~oB(AWxw-8g9)3NNxAOqvI`%{TjdVtZs19aV5svw}M$(r`ERFqD z(4!LBEbP%*lv*dqRM(l#i2sX}b?J=B5+TYfA|f(b=MJw0@uKbMe6{QT(|8MtJLfr*KU4wY6%`~hYny?Q5k#=t^N;z-=s1m(0D6&;hc_55I)D(dn}IS zG8KfD-{8_wvp!_u+~_07K9|GjU%7maZ+`Nxw8;C+jqR=5rTvzfi(Titk4ujc7zD2^ zVCCUb>$SP?9MkA#+jG(5zHb)1^(p_6A$0}mg2N`42G3c7M}_?CjzJjIJvSkR3^%g1 zs)o#qhYvjxx^QQ{T2c5Iw85gtxVWyF)Kpp<%M&W(zIfuy>@!T-_7GGL>jAA3uTbD~ z60{WB)6v@z-7+^fcX)WX`#N#gMuaSYX!|ooLE-imNSh;HzZ#sRL_h>EuZ=0(ag?P7 z@rX;~w^Xb9>X%E1G*$4cE(0W*>L!ar=|S)AWV@C z8IqjqB?r}ppj2DVTP{8Kea4DFi0W4zGLVtY0c1t-gt>Tn9`5bk4*ZHFC8O(zzXb6W zG`k|j$JaMKZBD8a6%tg-gSBUV6=_4LE)REce!5Av#iO3&el$i&O-=pguD{^v@)snP zj7$TLSsE&+7D5){$|k1c;;idJ*EK+cT`_c!A1N}&o^fk!SEh1nF~bKXX}Ih&Qc$ov zmnkA6sbT9F8J+ImzyIJtEYJND+umK~bzs65A$%Da7}zb~bG@n1f-rkx%8izQKVOH3 ztAUI!f6k=I{GP2C5yyOu4pU}EW+vm4rehgnOWRXuRWYd z$n@PdprF3w%U&Ck9-m%_d!t;ngU$}dEO3XGsVFJmLaKxM4~d|~?-wycOP6!L<)3ns z8b^>)K*5B#PV7Z#%kLO)&~cHIlZRxK?s!-t)WkQZoBZ~E+>&BsWK0Ly=Do+yD4VA} zdsC<^PW9*f=8Z={-4d;B7vQ^DuKA9D)&m6`p6v{Tha=K-@%z-8{X{I+8JFf~0{;*Z zWQl?1$rEtA?{Km`hg}ug($eB~NZju5;l_;{tskCTtZYRM^PJpOvTXhS9WDOO1pLZi zJzj?uxC+`+MTIb7koFKQo0ypU+DMV41P7`I2jnK3^Rv@&Y-TC9j&I+-^%&0eoYe^K zR)@trtq&Cf;!xaV?4?gzb#j$KZtN27Ym}6V4XNmo z1k%{<05q8tndEDg(&+KiU>)Dpj=q5-owz;H1mgU;z@5p-N&3JIKy{v*Q@na)f^b|W zCr!-NM?rYG+ZlC9RMXr0Xnmr(JBBXTm2<%GAz+e1>cvkl4f6yg19GcPqTj|Vj-%O4 zgd`sFRPo`qq-{(P|E}o0Mn)Ach}yOCPC25td(-R4*RN51AkVwH(PeH!Ov8F`VSqq& zA&7)gGPs3A@4e^7F>qv?SrBjgUA4D5XMpC-?P&%&%?)q7bZ-983XeC!utRR+i+zu} zo7$}v+5-BoMDmkxzxg!&>iK}jTZWKFUJ5Cu)C>jc%kQ;0rDhFS*<}|#$c#zaUHD{o zmrub%E7l_D)C07i@$O4GNrW=jV~_asmNwz^A$Fk5*981IWLJsnAY*l8`}y?^MKPi~ zGw96tz5Bg%k{1%r)39oypeCQWt!uf4tCA+JCa%`0CG#~mHzSNWB{8g1gjzx_$8SZ` z2*n+5fXt$kOjWF&vSe%|Cnv|@L70nMP`6ayfdW5`9|?18?4`pR+NVO$89!x6}$v~Y<=-#$(Kp#gH*WQ)kc z(h_S--j|uOUHD8QpdoU z1wnDKQ#yTJnTTf}PYiXidwUu4714LZ{3e&_~#Zd}CDKJw9H8PE0B2^s%2 zB8IDknZau8o>yV{k?O#Wb`m4HK!InIS}51vFxx+1gX}$iUP%v?-=Yt4wJqgHI5KaF zi9H*iifeJ$|_aI+gnnUiaDn?hSu}owBitf}2 za)lk9{`p0qFKW^3FZDVeVHHa+F3iLql72l$QuL~0I4BNEZ=aV?Mo>V?M6!P5UabVBPa(j8jUi0_f8wZsgih4cf z5Z1PA4X?R2+>eHb^3;|Y85!KSUW68VJfzA~p>$5%Tuc%XRjWzmBEL7?=(8k|ep$ab z)RwYXNMf!6^YdNWYe-|NY(r)~Rx|dET)EUAP(`%)J4Hx!MFxUNXoYWhl5{hurqcf; z{kaGvT~ak+?DgFKha}$H6+OE$b&^pE8ODB}*rRCpjkFJO%$|0$85!}5JC4yppZ8*$ zlrJhdlHyn4exFAiF49D~ydsr;*wDg{mSrnrcY~I3yjLH;FVf;aT0p4Mjq4IW`o(!^ zY7;=V2B_2Ey)$p*A<3QlssTXp+Z~F@CxoHH}VE4@z8M|4O9DB#6Ay) zvhdV-z9TX_J9~VL&rAMBJhYrBXlYSbx*ZIiN8agMLSd-LY<-21%PT|T&(Fsm!yuta zYD zDQV5E-=iG3ZZCW>l*5s=jeft%Qc`HlcUOkkIc~g->& zOb4x_H=<9!p~_Hg#?;hwq}({UQadjAHntn>W0kXx%ORx7H_mkelzq8osbF zUg+(+a{RuI^2b;`C=;0DC<2_dF+;5^7KO4-b2YxtDzP%LC%7&j))F@5=lO@rw zJC4bXG1O%u?%q7@kCCF=aJZ5$BUfuh17d3~O7`f4C;Pp&9NH$n>)7iTol<^rdIh>V zb8qns$OaxxHF%MH^8_6FS~G)B<%S6Mw+oIOlDD(Ait!PmWJkzbnb)sfzc*U)rdRMc zb{<+uxQ0>r`SzD`g+I!_@!n46uI8SNG68D;d)zQ%-qjyE0U=5^H#2DUsFy= z@Ebx^6Y}6*{#mP$CR)WB|8%R=xLSKfc`Et9>cQlFt&D+ub=#&7Jkwg`kwmfQ_sjzZ%G-`LhWqmb+cLa%P6Vuj#1kzwoAF;$c}{C zVstsEU88n2T;MY5apMs_gI?cmO`)UXXUa%3-i^Lh_WC(7S+xmTgNG|&b9pv?4-Csp z<%o|%Z zc^`m3w9Vu@B~{`Wd61X&P4gUlqF6Y67pDP+UefF*V`M#aYK9; zsw}u_)<#V@9y7^!vKGv(AHm6w0_cTWnw`~t>oD}`rNl)X9M<}bYkr1 z40sEehmnznJ5M(jT~kTQfH;p;<6nCY81Aw`X+puG&NejIv*1uxNf6)C{&c%}>Lvf6 z#6BZa*Z%q0ALpe_i(^&+YhG6l{!|O1f%&O zM`L-3fnjSWN_=OYVq`Z9v@*L2+mgXZeDj^ z(O!zq!c&s;Fw0iJ#gXFBO?or(v+h!eMgz1{khc!*C?ML5jf`Sg)-laGzWzg zHj{aRAyXEQ0iJTK5F-~W()?4p6orshiXf9h&EaRFoRNw8NA?{@&``IMF6``mr`zP~ zp%-HxU*WBkR_LY_xLT>%}P6%M|)g*tWj$23m^+wl1b zzFty`Luv8g<+hcc?#ZrIs8wEfMPS>LCU}rl+m1>=WWuf^J32>%d6DAA2Ms#Z?=b|S!*FK6K+dYxv3s#F$x37H_~7Tt&E2x!T9|)S(j7k> z=IT^;Djpn^pI$LLw$zp6{7~LVmj6&v4j8jy-pt~)501ffLGCF$@-Bl;biTYxQi*Bmg+6it1B{mVS7OF>%OgZ#g za--Mk=KQRbi#QyrjQmUQY}^5J+J(C{4(Ss}DVhM(FL&;*%v~KX;(rZf^o-l_(YN<3 zcFnLY5wtzc@s6WSH%;+bFzc#X_!uo>D8E2EnOgS*)yxMS#oe795abfQbdeXn{vsgg zMNwJignl3sAhy-!Y@K_(n=j)r{wtR!X$9K?p@s_$J`VDJNTyUuNW2U=zrrnhteswK z$teVF$4iL4dGihBj(Dw8WX0Hato+lqFaoX2>q@tbNCUpR@JAEI;9tdGea_d#ZPbX*X_0Qxg(Ur> z<1tp&^|Kv5g)R~;hOMydBCL`Qj@b8*HyV!+ zOge{@fl6fK1~S(U+J}cYrXkMKsUSxtiCtp!NxS zXN&O}0ViT%$36jWL#%h^VPsU)Q#lQK8X9hByRtfu3%0x!a%O}EV@y{(AxLqzcDGH= z5_pG&hQ_)yaL*NfH$gn`{MAdDY?5z+46DP}_W$b%49&YR8yp-Aty`cJRP8_jHom@j z`Ro~5$U|SpeH;h&363L$uE0@29JCrA8>_SFeG0Tte76S5XMAKNSusQGVqY?(d~=TU z`1sFosuSBm9y$lc1{BwGWsr@BXJx5B1;h)@DY1Wynmh+LbupSj{MeYRfy{8wP9iru z2jmr~&=}}d)I8d)sVRU#2S0z-BEDl}l*Fg+xHI1g&KXK&#Xv#BGDa@l9AqSjFFD){ z5)b{MqYmy%Y0q`<#U3(^+oY?@TU$b~jG$6`z*!oac$KG~rQ_N;1BIwBg7yYR;K5pQ zA|@>`Q%T!C%EfVLrE9#Btp|l4900-+5)!*hg=~JlzK~nrLW9#=5=ti0)%?k=uHj!K zPyT+gE{k>UYfVvE*??oc>;Aiok*bzCF7ECh!6O029k3WlISjILtN|yEh=|C|f%Lrw z0?`6*3^fCT%%dM4K@Fr4Fma!4eGI~x@&Y8RZ9c+%&?dk?NNZGOa}#uqn&psr;T#Xp zcXockdWQV{Vw8r~Q!JN6gwoXa@30e1fNEfQ&-aJ5pm7F@V1+8BkCfQ%BU97#x;lxz zdXT{t!wKaVgj!BSy*5p8W}&%e_Rb~yc7Z1J7{SZVJn zF4ek+-xXr(Zal`4a$CfM^LU$zPwz${b+J(c$f{^Tl}U!0L}&fY?Sg@`(?2rqD`2Iq z0Mz>M`}@%{vpX~V!p2EqJ2dUkc7+WPla>N2AzB`W>orG_+>_8$Xos668UbEvqg-@> zGM`|VT#)p~kd0wMNTJ-{2x0@m_4_+lY)r}NRcKO78C1SK@w!P?00IopKdac&!Eqhq zatp=@D8czdpFRo5w_HdE3J!|pr+*Boo12?!T2O0yFnux|586b-V;g-ufYP7kTmiUJ zl@ADI*RS)R`?Ap`X9@Rcf4zGr`OgPi#HFRB<>%*zg@x7E*M~)^vR+|t(I~Nk)B(!x z&cVUxZzu4Uk{Z7hzt8nl-^PZ+_vhLgANdk|A`h$etxJd}q`r!ZiizHDMmXjzX#Tcb zIN6h4=ZvsULEhx#ndhv+GKH;$WVPy73!%Ro-O$@_qQqeUO#oaSf0tLBva(aKle`Sj zf`E%t#`vJ7rY4!hb>$3pSyt?9@LD2N(~*mu7AKD?Dl2t1=`{cDpG7?wLOHDb{QRz5 zsrFW}!{9oO&}cz5C=5*swD7+5Kix(rjuL!r0s_&Eh)7{CTR5S0Sqo=3eN`;bi z?(fs8{7?T&2S4QXRH z=l11q8DFpzy?0b+by9Bk*91Rgs7d|!;95V11{V=#D%D@#e2yOW!JCy8hk(x}_y~%w zf2u>$3)*I!7jTm%;JL1sDu41f{i|`hcstLMJN&l>-2a*3o`CoD;(xRN zyPN-`lBf?P`<0>Ge_g!5*$wRbO98V~2)G2n5!S3}oZ~+)9hhA7U5a4&XH5A&1)(0mCHR zda-f!53p~5!t)5S$oBR&^dU;ko8E)F${Rnov#V<$RU{Yc1TH@1%D1}m_;?a}260C* z&vhe0y>jz_mBDOUZ#Z`eY>H9T0w!R0gA}2ntei8{Q`9?j)p=SLYC70PN1*ux%?>p= zXyBl~ORwPPNYSI+#hdv7P<-$4UrH=KywCA85-@OUAcN4!A zj4q(AhBInH*BF{{O5{EL*=DAu^YinKO-+fG4<0-qa~L}67i!^$atbzi)q)fui_OWJ z%Uy6^Bu}|tFw3;QV@-a)pxZ$ADyg7gdAL9;H#gV$`+M<6KQxkT zpeM&>Z6AhD^<2n8P!D$$h~DYpH)qviZ=*tOwxm0tZAVA4S0O$jiV#zXIK(IO_wF}e z-%SNC0l0=1T^escTfL_BO4b`6J5AJOR%uE3VNE%m)Is>I^01Iwf8kdaHw z6JmCy1)*@jHfTRSAP_b^Iqz+Zas@mU8OI@jmjCk{T1PUisG6O)Cyh}Kd=b%9mutbf zwFE5(wO&jc>+HVq!cD$s0sAXDAZ(6ubC%zDCF51L%NO%|ed3Cb;T!J)E^J|iQbwXn z)ImBIm96MR0750IwkUY?|Aa1vHO5ArKEJ@ZSp@3quKSIbS08}1kKP49Gge0CUI4(+ zmxaMyEEgFnLQ^1>N85920++%Z1^CK-{P+Q+3x)kl{>Odt>>6wC446(&W{%2fo*1$$@uIpN_(u~jeajktOM_#354iSu1fAv zOj}i07;erkU|t{hWzNZ10Yjy!$3>2S5#cd)N~4-mbAJ?QG8x-GP#^c9rV5G+S<4}hJD-L zfBpc@G5-5CI*xN28*hYAoLm?T+b~OWu<=d8=h6S3jY{hCZ^qC_s`__KEMN2j);9ME znxLVtobrok8i&6Adu94>We+|2weJxf8w>uLzco5ZtQ3aOPSI_AG&+Zt9%xKxfCp)K zvgUpeksQUw#)cWFKs`M@(AEU3rh&eBox39NzDS4z^q^CJzk1Wq=%@!Mkm8Obq!uHf znI%3Jae+1xU2uxPo$wIh@DjZu1iA|KIM}hVv4!;L!BPYfab7>J0@JI`RxAotQB?(1 zLLc}8nK)V_duwE5ln(9P&rw3$TwHe0A+fTuf^y{Q;&MYBLTzX0nq_M+07!6Z0R6Mz zVSe}S-HLw{fCo_zs6>iiJ6pjT0N|gG0hoq%fhJSvXJ|15PGH24UWZ|pzfkaI*Go&X z#3Gr1W3jWzZ{G}y^={rPHL66cmB30snr!%T1ke~tN$tI1E*Sg*O*Z|CeOy;lk-xt` z)TYXdp)ZHQ=l!k^>ED=JikLtd79r*z9{y%!me|YZ+1=Ye`NjWOvZ3otQ{93pj`|wzIIK zs|!D;JoztZ1#pqiDdfVMYxC&^RS=+2G$V!Bw!Zvy1N#D@HVctXEx=C@IFd==_{Lh+ zLqY+=Y~Ot+VFei4V37;tSJ^&1YzB@6dTJbt+$(UTXq*H3SMZo?68)5w#X3PtO3JE- z$v{%M3cqLmO)b#Vx+Vkr{JhI00k)s$wp-LbnB^>=fZetqjzg=(hqn+6-`ozjx$_Us2hO@xGm zdMw{;dXasfhP}-xGWTp-7t52cN z4>aA^*SF8Lti%$7b0(aKA;)0~f&xu+Y$3fC&P=~^Rg4Snf=1$9N4$L6E_ZDf<$Obq z4$!0_sSE=Pg19fLvu*~f!PB}n1y|a3fK_e6@A>ptCj0aUp*4}P9K3iNox3>*EwIq{ z2!0`yF27Ujc#MyLSSNwi7e%z+7V?`yl9G}av|%8;q`-CpJz*h>X8!KREJQ?dNrn#T zbBp;f$YVUuHp&p>W$4pG&*H;iMf)9QxcuNFhG5yLPXx5HZoobK#rQ=lL>D00m7ePI z1%Ddv>I$bzjnUD>#fH@|ZuH&(dHTr{XcaU2ua8%v@t3Rnbi!|#tZ>5tX~NLZFwq7G zDB>|hSU3huvE8$?40!x_(;f#zg1+&1vLZsatj5ND*!+d;NMKpc|bTJ zoy`K2L>TmBi&z>9r{ES8)I4S5`wUXH(p@><{lc^~I+HahpC6OZ$0PLdOG}X}+QlEL zrEH9Hm1DPm0sr0nRtKhVulBY!7+MT2~d7LLNe=g*)51YEKEm=G|i zS{>f|YU;fQ!KmHAAqW?EqfJ@1pT##>|Dq&cY?qL@F6|D$1L3U4e4Gx}TohuEGL2`I z1eN08VIBZz1JxxMJ@gt&E22=?JQY3b2%I||ofN=a01h87Xia&qfy>0h-Q68TVs;pu zWfc1SjGH862Py=N7u$b&!Kq%gd#H!5q+bR=je@*QmaWvL4{5ltuU~a!jYbL}1~<%_ z@u*$AaN)IjN*U0azkqTiH9GHu)mOB5F8m8@=eJ(TrnNMxA$c`{VaGE{iMq_*urW!~ zlRqQ;0`P{^)>a*kBfvpuo|}Pr2?~Ep(D`+!$KY_#S8#&6UBZL08lH2opn=*&S@aI_ zrCTUy?(CZV(k@Vdzdo zb5E4D47N~viS_}+?mTRwON{u#DM`u+(qLxHU%n~kHV!YNPS!R**KhmzYyM^ODlCw1ScjjF~d-vijum@=5 zHi3X9o!R+#4A_ROtZb+}lq1CxT9^`x68M}cmnnAdX;@e%sED21Peg(Cl-qVO%X+)G zxKy0rv*h~{wkEy*-V(&Z(hux;_Y6G}z+=(eh{oHZH!onh=(b>|JMb9D2A;e#s=TWd zMd|A1#s^M7kozG6wj@N_r|=tp0dWtK5`qn+Zr*ahh4&yqXfM%TbyC+FZ2gZGkP5@G z+NXUbCp#ok0|2XcwxN{JG6N$5N)9NK#O44p5g??Rp!2hM=4)u75x9tse^)B>OzWXU zFlxV#AS3MoW2#y(iHN@};&dJdZ9z=?tBANC-^CoCnreiC1+fpz@eI<@;8Qlma7aD< zLe<%>0F7zqp!3=Q_zO-vtMg*?;b@6r?%?BNfiGKVSRaJj(cKN9p{FS&Elp3t&dwgT z_OOIU1&TgXniSYBFF$z8BHzrTshT7HB1qJQy>r|jBYL{S|ac6(^ zwm;weg8EtUUs>i}HKm1SOW-M331Pyy3N%l6YM9|66dC>c^(qgK1GIO6>O7cklu|~U z#2~%syaGEZ>!fSCgk>wn+gxA%Aobfb59~P9)%#&xzKxGt8gV_yKDPP{rT^l^i-7nQ zYQZV64B3@O9^`X$7nf;a>`5dKZZ8N2e>v0>&?|{fYKs%)8eV|cJIRI27osHoW4%o; zdEOLYC}_4~d`4t}E^xbWvmmMkew$KWY+7H%ctiM=q-&108P)?!K`_EUTOAV~(SYdl zI9SuC4AjAg>RsDd{f~f0mQNhnh$9Wj=KA&P$FE?$A2do(E2t`0by`SB)B&6P2O zZv(0%s}BeW*x3db4?Hqu@gKchy*uUR*N_f+_mlfyf+)&MPApXq`O)v!CbTrm!PxV! zG^?gWZj7lctyH-WBm9$EY+0|QJ@A#x+HdzjHdfe$b5Zx;nI<_^(!)EM)IU>20WXcWRv?-^twj zk_W%c1%|ll-3xc8b4|I|Yam^!tgOrda5rJwo^#7-xaw53y_OY@h7`OBHu)6ho^1*2 zbfDP}_T3J@f3@eLXk%4X)ByK%OOW$XGWdE>830QhKw=@c*3%mShY9#YjX^DkdQTs8 zu?WP^5i_0dN&a@x!$C0o@Q!4~H2)Ov~ME+)k zL}Aik3SSx9oN{UjJJFBb-OG@RCM}Nf@bGT<85yOr4tv7M3b3F#&a2n}L%1?W33xLh zSzGvcLwo_QY4QI}zV{v~(U+EEaE|5zvyUq8g75~RPU$Y_h4YH6($X+lpyQY=fdLr- z{@Ms$4khu+%0nS1vLe{C39t*h%$8N3LNJFklOn}!uIwn`;^KlVH4l}J7ILXLdG3*E z#m1*hISY;wFr)+j(jm5Z=VApApeF*V1_=!O!N~%3MG0$eq!o&4@*>Ra{Q!OxC4}F< z1Ufh1&Nft6SAB_^$p>%RRC6>5;F%qrCcZl6S+}P z%|C4{_e(<;4uS&)+1Z*Ixw#8~$n*$c^6r3d0HT?O*Weo<2{cS}4V(i|xjIrr5e})p zjNdMVl@aWg+2Eeu)ms3qviYb^6KxIIsGC`9_;m>gzhpGPi8Vjsg3zVr1e$YgH5+^T z2zEoKt_xisc$s6^d)~4g!&y28at3l7`mbWd!p#pPuwma7{(^=_I)V*bQIjuj<>H~{zPs2O;1HPTB zSZI9|T`~M&q&UA>Vf%trMs6nj!0?z3`uAx$a!~r)==(Ed>P)Gm|M>j)NLPx}c;FV2 zDr08|g6aMvs4U(DK=(rx5GECC6a^rK!_xuBlxymSq_ox=_U#q=$!C*+l$~h!1t{EoxKcE^LJ=dll(&~~a2@&Si20~=1Giox^BpeTQ`j0& z`+*q4(Lx>Ua}#z1;%cF#daFTbn0R9l8zM&t?~ieSy~ih_7IK!&qrvV0?DFn2WosIx z{hAM4QAAWPFJCordM_)@#ttgR9O?v$&o0}*@mwJqVW<{V;qK5TW&z^^c!G=phFRJ8 zgCb(GM{j%@blfoP`$)15jO=3hxuXV{st6NTExD6Ak)}^VVu1Nn9B0N)3x)I> zC?3h?Znh(6PJpCQ=f0{Ns~%Kol^d~=?2oF?t-}jc9YISbka`-Ry$_Xiku*tj|(LX2Tp z!vrc5-%oh5KC=Pa2r)2oi7+8vVg!}xaDvWQ{Wr${lb-1Ny4 z;TYvbOhWe0S^VLAw?X|Fm5Frw3VM=jaGY>V8|&-vT_J=RzA+58H(Z?+(xuLcp6l1v z8Fd;dO!RCRczFLKmOPe7>kXObWH)nRZ;hTA@pK4qFfJNdkrof5Qg)keBvKSng}IjE zXQbR~?#EqA*5^w4m%7detBzABxj1gLb_N8MLO{3}tb? zbz}4$7~S(mVLB<$58qs|ds6ru8t3$#B0)QJC3LGBsM0dqXm!fe=+yw z@l>|$-)Mz0lsU7^LS&9q3d@jG#-hnsh6b4;W67Ag2&K#s%9KikG7l*-gpiODii``{ z->dt6e(!I;d+*PF_x@vlwtw#D$--LKbzbLj9N+1*E@>Xm{WLG*qep5r7`{Xoq(qUY zaCL|xrzauuJ=OWmKWN9eqU&Ow9zujq9m0SFIp|g$mDo0A9eU3J0D&jJC1@oyQvyO!fhec^PAPl!2*KhO-)g#ebqzK5*L(}wGs>} z{nu?@?-UX)!4laW^DtoC!Ez~fZ)E)Ni?XujvY(%;RxyDqU#?e^kNi2>&~TcXW@a5% z^gfvi9<2wj^O!D-Xp|o-w)y{;_Wp&*~(dvpFtlN9%WOYiIX2FMrN{?3j>eAcsN%42ewE;038ez;Lu+qrIBb5ntv083Mw{`9e_y1bv0T?ZfVRw=~wGzLg7hx<=r ztdwQ%IMi7wpa1sPxs(i}^8T5P*1aQr62|7$$98S(a6TJoHrV&r=k>i-lgIg99(4Lr z$KO0I+O}>xJ7ZAcvBE!vo%U-GWTmlQk5>SmPZ3U z38^zB&vY(d$jR)6<{TC^+vkesQe}#GtFuq+V88w2c1r$7{|?r-GL|VM-GFy?vS>W*>)_x)p2iNP|y*K7hEFKoQ(pd1%}O=Nr^{P1B+w=?R&ZZg9SKPVHbly z!!>gJ$$YF6^&encXlNPUHTAO6+QIQuf4yvPllE2G;;Qg~ac8?4`Q3L{a&m^`@+z=Z zd`Z^@w=q{TJX+K%Zpk5{JeoD9zc;ql#8telQY8$&h-SO>qh$QTZWPu-5$=c0f}MFN zIeb=07g_GB@!4=xd!>Hb)~J&$#wKn^DX@Z#WTw}83r+8Zb$t)WIlpIS+IOD-#a5^+ zMsIy6`nl_~r%!8icbXMtI1f->-AX6MOrOit!+erx00YOlI}&2bcX4!1aLS)+I237c zdDmWEpL^GJM9Bk}gv_51tE}TW+uZYYxvW$p7?>^ZnZ5EhvL8>B?MZZ7o#~b8xLPb=TGNf^ON`0x5Xv=uGldoJT_1BYTMuu}cHxq59XIb8k5t z%xEeyEU{drIB1XQ4C89&1Gyp|`qyn5dfIinW;wMdiM@=DjP!B~=SNgC^LgD2BtJt~W-yHmvr!D)qhwM!Nq7%J&e#|=+W;Wd z_lXGebR0K`{a`nU?h~D{tWnK2lHx3nlL%&Aj~k3ZPULlycxAkjSXcK^<5G$Z8;(5K z8(If5ou$_F{H8D>K~bJcH}CdnIMq+1K3Hw@^$oSaS5` z@DKul9enEz)5$rG@;W)~GYWzqxeq>1Xq22*P=QMJ>^F0N7{|}pp_K%e$bKATk zZuPYu5)LBWLRO+o98!|A=Y}4G59T}OWwv!PIU(ckbVdW1VbB0NJ){VG#0-;}4Mf(zSblZiV~xM8LTE4a`2$rUmQV?C1KPeec_RE3D%A z$?Tn5J_5^mHUQG+hgWIERBF!Pb4PO<-*TQc+FuN8C-3TCe1&1F*5S27{jpdj$1|CW zQ0R(>C9ZUC68sRL8|y61mgZr35l zpo#kW_3O$!N-|+U-2|Ehm+haX*M$tb0XS@4UZ73?KJiSBuH$uV*}>Yr$g^!rCqL0l z-l~{3Xw%$%WI3z6E%J{Y`#0Bl7O4jLAji9p*Em;P>ERBL4wL3rqW42ulP91A62udH~>Uy98);#144o3xl1_0PNWmHw!c zNfYmvworkL_4M%avUbe=Eq)BhZ+Pa<+s4~@RLV+A?G<*OdcmmO2VPX`n~KZV&0lVl zPy4OEsxe6EM4I~kJ#<~(O4TD3A9fqj**`~B$}W1ak$dN)4yl)AIC}e3YNY3%w%s4& z%^d3+*vozxh}YHc?IPu=K%L}deEoNE_hi;&nlGJL+Hve)aBas_P^%W6pxOSqmMYQv zlC!V+wJx0_zs&Nn($t+jsR1bn4P-)T@3KGufDKr~fX#Rf&&^HsC#{EU-LP!BK&KMS zwV!l+g`_brL8v6m;$^-sXG8(2iV(7H(TMm1f1!~~xpK<8e^HzeJ>LH!=L1G)s)$+C zoMNF$_%ECyNG@v8WS;0PAsRj~!MRWT*YDly`Yrk&y$GRXmRko<+U7a&!cfEV|2b=h2(qkO`$??ig!|J0u(obwCuC zq6D-*V!A}G0>0#~hCW({&3B5Bq~$lw(%}uMUjBTI2bAJhY_8y6ia?lGe}H_(@$zMv zI*5)Nu`dIgHrU73n1MpNZQC|P5d8fTh6X|!bqenkn|Qa9Iz}a6Um?Xqh4lY-g3o3waL6S9D@P398 z>$CKiG|zO9jS^2VqG@|;+7VjsP-c<$zN1ZBJ3QtNJHR0Ux)s;;iKo;bVvdv2clG6&$#>YxB6y}gwey6-(&A&lL(Mi{+}zG7Q5qh{DE}6o0U!O=QDB3rjGRhvvDZ3ixD^$ znP&t`rLi&VAA+F7APCJR2f$*2c06VfS4-Wx=H%}F6H2o3J+pY$@b077(Ohs|-8!6J zh#3`K#{ULd<*nPp$Qs73R{^_{K;pzYFlD*h)!O<}p znmv5tG%W{yeD*i1b>bahjPOm4 z1H3PQdU}jQV1eg7-aW0ZSWJ;f0p(bNx-I>e_@7j2H*!Gw$g>Pt&Y0w#hR!Mw0_c4LPJgNxj>Q2=})C0L}0ig=Kln=5d8dq@YX{3o&H<&v)v;F0{B` zE7>Y_!uYRx4Z6MBTiYQ5`}^fH`hO-j`@fGh|NF!LvwrQRW)4YHvo_~%7*uFBEJ|W-+^RqjsonUB_*S(cf;!; z9G{h*(8dFCdrtHCW=-6{8Vwi zfBpLPxIwrz{tf&tY9yPPwDfe)8`v_di6F1oFBS`g3q|F!v2iTFTnKDsdb~$CgjTKU z8~=V^-Ew;*OHV`9$H#PntGKic9f85YphAA#IgURfeisV9%uJChKm2v};||H$BM}R# zIzl}|a7fCq226Any0DWxiA=A9VSsshtc+!?zcs^>uQ4nc!hFLm zkpB>$EE-5yo;z2Uyg`)W|7vmE9K>1#|Npe_-(2xqU(s+8`Z-5% zzj&N^YsZ`+ucu?*nQ=6^0-I1>WfN2^m>$4u5CSh9dIG^kO#tzb_@eLDE39vnDqb0j z15hK@#H@s0SOU!)CFGxONSqZ^$?<7QcG63c9C20xKh8?d-X^KM)*4 zkMjfN`io2GmepSlNo73Gbj_}wC>w3p}bbX!PSm)x?CsMxd zVjp%7XkD~in6EVNk?YIT!E$?wCj_!7i~MQDK}p)gmGUOkg3B*C%Ow6>5D=mJv$%=L zV8wT7G@daIZ+J)`?XGu1`2N(Vs6^3g1cgB3IZ2!l0Oc6-D9VCPB^d1R(9oU=pR%Et z^t3c0E9O>-jwqo%p=9!ug|BQD_IR#jUq$k5)Ku`}V3Q9E2PdRF_vL>1>OnNqn&mo% zxrpyw=bRq4F`T-)#>UP0r8vbQ_T4_qOELBxKEbquw1R2*46R#_W$Rm_aT6as*7$7< zZO1W)3ujL;ig2Q*HV~ZrSSSQ#I{H?|9O|2vB<0Oe$5?wnJIHaS?8-$f4HU#ru-oH5 z9Q&uSU7)W+eTCQNG2nAx4RMp`7qEC*AT5I$Ms~xA%X*g)X##4->S4oe?E60uClUm- za~i|0prBO}DD^mTqjR{k;H&m}FBXu)|2A#i&emFhrfx;x4ZUVXI;JFFB!}zBs;FF|I2V9!Pijn?FS5ki*r*4GZe+h#d} zrgAtC^mjFuRjBPW>lyze&|2vqQG22kn~o+Xe5R~*tN*tr1AKF;KTgu1WhQ8cKN602 zRQcSu(3KYL5^dP@&-J!X+_}H0@=73A=48=;ghpzL{94D5&zb}46?ex|dL8xmHU1oI zV=1QOA)94S>2i5qOkbGYa3+0XP%4LWbto`NXEu1uaV*Yn&V|g-M^3+%r@C6;we0=% zxTj*>=<4{D!q*kf!;3GxC`FgA9C;*`yzCz$CP0vti__LI?a)DT?_(z&B)mJZIuLbD zPfy28LnmkrZXnK*$c5slTXLJoS)Th554kzkEWo=$^avr3$PeeyDS54_1q^^8KDvLk z17b8RL;e}eMO44l9~DFXj)5pq)_UWNfYt$hQh2OU!rrSzVPS_LMTHdS=7F#t_yM!= zx76bronBzFyNeN|`F-}sqO)PSx#ywyLZJRNxuVU5VvSZz{x@!nDr4vN2nn@T`}MxR z-v=%7jCjiiOM)oX`I_)TpZCNTWZZ+N@}SL{j9HoPCG4>11{~04AQbbZfwvtR%=C1j zGyaWf6yJpFVvMRhHGH+clmN@GDg4~KLs(AE@YR0ZWK^Ts$COFp2innt2(fYIE9%=b zRQkQI(&%q#xj54mJ`4FL)yRwNg7-h}sqf6!?X$k;m1RO*$0RLH@|&IQ>ySS@m&zG@ ziuD{DpZnUq+#9VeE;+ku9?!kO4^Tv|r0L0xzrDY3eot@wc*0Z}h3s`l)eAp@>#B@g z{G}z`?00QHd|y^A%a4Ub(n8dUpsKHCQ^12_YQ2N?KT&8!m~Txg4SKsku(=sQx6D@; zLBQ&maqLo3f(6tUD3RQ`mKT@$RZuu6E1^~4??nT5_}HaPkn+vflTAis=9E!?xs^ZdQT zt-6t0*Glj9X11{=2P%F^tFFHE(=|s$$9JD{@6Xb;>xD>2R0a%rw6roN?7 zP3tq%M(1nMS7O5<#zvc$S{5uhIg)20GjdLCm!x>05`L{R;gNU9`VI?&`5Kv*sYrpy z>AG1d_5?B2EP+Ow0U`V(Zzwoox^s6lHdPek&)+EhrWrX+vgwcgV*jz}P)0Zon2Cn0 z7_E@chZ;lvkIi#I!U%M6OeH@lnx}1BaHiOyR>AhF*1RKgZtnFneT@#sPCi;8Ql*#O z{le^-_o+5zAz4`it*do{heb)pvK%ztwn(ikL~VIhxO>;^jX-Piix+;C*KFhd3@&s? zXbgRGkE-zt&Wd@kZHOVtW7Te9t$6X5T+>3Tb!Np{gt))OhWwj|H+7ITV z8$V;vX*_ZE9|v2=x?OJL#(VEco2%OF4>*7(66tEJ!ENY@?+_!%Q3>qnqU>ST^*Qu^;Qzqfdp`=?#{cE5jK^WF}rbGss^?svUVxX0_eRuw!+ z!F0obg}$_0V)gl^ykb8VZ%g~n)11f+tMWVUrdM@7Hm26^IooqR^M-*Y)r;;W`L7Za z&0gA6+chm>&Dn|XJ0Q2tsC^op9x0SiJ#_4XcjH*P))s^aU@Oi)Rm$ABuN#5LQZee2L)w?IsW3x6K zZSj8oBC@n3{dCN^ug`X7lj}4C6Z=X}oZT6DJvx^=mX7I0>&|fBHDU5_bCvw>3o~8% z4=s+TocUN?ja{05VdMRii&ObQ9^4mZj2Tp(>nK*(e*S)yxYj^%1w{_Mr!f<}Pb|(` zq;vfnw{U%y-XQY_c>)B^$8jKTCM($&AT_(Q^pO+kv?nrkw+e0FV^u?KYdeOW%Zd!3 zB{l+{ffFcf*f3%Opj2-I?0l7K4mxlQ$?pNrUoFl?1d)IOH1mi_@5Sr7mFPxJrs{sz zrMvmDAy?-OW`}Q5?O)&FKn)aaJ+nx7AorbX;fS>@B+Ya+^=_o+QHC@Wunl_bCA^F@ z+Ev9s1%ujpw-b_*cMjQ;-qd^cmAY*28VBG(jM-G(rRuKp^>EiO#znjGxzqc~myte) znm?9fJ<`2UPitd2XZ&MXpr;F2TiCxbueBw7&mYcV=|IjY0vi@*ChhB~Oiv8Yx6(iZ7oK%CY zJMGQO-P-*^0mU}BCU7pYu^HqbVgZiUb84b3-Q7wM#C~D%TKlulKT}5;m?1oWhYr2X z&rRtQ^F>BRw$jE;o0{)Q&~@(i<=z^j0B9il!g&S3ZR!1(xPP68#x>0JlHOjrEn8|& zDfk+5b{a_f0ww6|b9=(*vwHAJTr{cBlJBkLg_gXvO0PnJpG-Fbm)0sCI~5i(seOIc z`f@w-#QCYk{oLdGg--U^9|=GtE|`dzH=HbIRNuAUuQ-Rl12>sUGV+vfqj&nG%?!P^ zjOaRBoZN)-M8>7iqxKb8-p~ob`mkQeSt&11!xM%bYq!^Yo{l8U)j`+SwaC|FScKy% zAL{I*_f_V=Q0aDf!(ORQaT&N@7@JfM@3r79+|Qst&7ibM6DIPT@s+mLh6Wx>g)YrF zSn2g&04&Wz51sBm5*AITMM$$DZ;YA5mf8dWR1*t-~=-X-n6L3#cWGdK4S5Pe!p zInmM4P!eitY7%A-pyz1b)iaMLsyff$LM|Xn%2Mw)`bi{0QKlUY_%|o{&!(B2dG~8r zBs(_2P+aS`5IfbL*$l`Dp4{t2GACjWrC_gnxT3xchz>zhPgEbFG7mYTi;TU4&XOkW zXw<*Ii&+RjGR81u6iooa)R)y#GrdZ{@DSRH(OE0wc?1_~;x_~mAUfK>mW`Cmj`W+q zH+?Og6v}M!$0)~Gyjp0POs=b~RdgIURr-e$)rd$)csT|9`6T*Qz?F|GZ7}E};56I3 zQuStCwm`aPc$H*`>OO+L!PhrG1jy=h`odEVJFsiy*U8C!wyiv|gbR16ySiHA>NqMR zz$lN4`ugwD)hqh4c%ZumwOo@6oSL1HQ3jX#=+UEUv)>cND=#0m@n2b8MKdAU5HOUX zc;yvviw4RD$4SvWCr_TlNnEf?pWhz`#WfVUqs>*laCJiWyoWV9&?%fo`co;(k+=W2 z{Rr+wq>6w5(eU5yA!~WyQ3Qt`;kf27Bv}y^6}8BWBVVqX7N3h)oY41!f8!p%U|IkU zJLpbCZacs#Mkekl$^00T93RjR4_@qXazBaQ)#Nb8{Qivq(#CKC?; zC*4>93giGba@^q1*mY_If85|KIC{Wwb#;|UjNFW*Ukv1kXzgohkxb28_wyFAD8*_vId7_~iLB&Anb^hfHFehZ{P_6e;AbJhJPlW) zf_;0#JT?lfWCuua;0$|ORHW{3>-p=B?k5M#JRrB`cwJpxja*zL8>_nw#z2RT)t8dJ zhWy;%`mkVg1(cS0>=kI;KE6RTo z)892bH7x{yhZ#NE@hx^UWLIXiy#h-BXyGC3UgNp+`JJWZcJV&T=+URytbbPxf^yRG zD(yI)cfI5}PIJn81vN2waWRDVs)J8r}Ja6sK@4Sbs;jA9#$uamFX!uz_;2z_K} z>oglS_f5HU6yNp_Y|vZ58~r!}E~23S%VaeV(YeFF-B2r>mS2NaJAL$IhUwy7VgN5s z7qJ^V^2m^YILRu>UoIhv-ISUEBn|ojHL}SQf30?nYp-`+g|8ef@m|!Gp(|8^_3$?m z8L80Squoak3y&quB=A<$i%WgaCP5j5oL0USUPs3y$Mh6uHSP7BwQD&GXN%nh9Q@); z=gd2^wdg$Nb0$qZ+yPkx2nDW&{{W&=UdVw?s(Fkh@VP`s5j|uzPmI)@l;Xpvrv$#l z=exK*W@F6eLfX@sl(&T7X_ky_~7#kzBmu{T_L&Y_^D|`$0Pp+;r(8(~Bw|&Hhfu0%lkN*78;XGQn=f}6R z8KuPz_>sB(ZY)&m*DX=b?b|&3d7C|p+lkFWjd*l7m5>6&o;GvkUB5zgL{IRUz+MZL z8C}ZJoHpBGAyM9gk1CzfV!~*!m^`CW7nv%g~8e0+Vs3O_)IMAbXb5YqvRx-&nOUfvmF8 z!xZ}3*cY9{jI_Z*ZT5tZlYV0s5XG34X! z;-Ya!3nz~m@dfDV@tutK6lzzrvW1I(`k9r=%*9te%U8h6$f$Nn7|JqBcO_Jm1jz4c zSP?5o_wavAMR#_w>}`V3vx|C^Scq~vZxWE!3%$_P>H#7tql=1m{KObJBT8N)ZV9CN}&Q&TkAVi%{j-lvf-GX|_W9-6E zs}q`}nQ>GacokDz6ex2r6m3Ry@Ua3wzLI@RX_r|8$3?z40d(5pu9t;YG~FUsSR;=w zZ_`|C_T#&>P2nhn8lKv`DVK-deB&4&8BZ7J%RS1ec+l9cFoD{u>Gb`GCC-Cxz0LPx zN83y1^_y!OX1dHnVw9`>4+=a?d)L(QWpW<1d-@F?-!HFz&CU|}0QwhiemoWDB%{`H zC_TC9#I4Du29BK&FsukFeBEiZ^NUJl&!Jdnz?qx*Qc+V_#dSe*pq5I9+zQ_#Bbc_B z$`))I_`>x2e!^qXMv)VP3a2j4p{Nu-k11_5THU-%{fuo`6Sq3`Eg9wz*?q5bb?-=) zLJDrisdZ%)`mliU9rfsoex?-xkVv$QKFR2&>bsKdVhA%-h%B|dPV2^Vn}~>!U(lbz zCX}%^a0DU#yEF1Y)pLAHP|ZF_oG@IFnh|b8oJ~j(cs})|0{iG$vwlrKn^?_VSeXf@ zs?hW!WSl0~z^4g)Hi|ivER0%JJ?r&^`T5rsR||6g={Y!Ql2usF^-HzM?b3+z_fuXd z{_1N6^>u~fIXnsUt)(e);YL!*`PZL@lb?k!$VoU9m>NJ!5iO;xeT{|ZBbV$1PBirh z-<#(sWf=!To7I5lM}2iQMHV;qui%pF(-?h1Wn&r(O}Ni^nR0*2;TeON#Ulz&t>Q0f zu4D$~)w_nRHmS+flZ({q@2Cur2B}A4-7g%C19@`H_3rIBzLT;6t)4(5CKgUA6B`Q3 zV&sQ3h=o<96ra8x5bmTEwJlUqPxd9#)Klv0L9atnK}%%<);4-t zX2aLOVC7R))VRB(XKHFMupNxE*!L8P=$4@nRx4BMkZ)~Ox4_T?DPGV`!8^xSdy63p zB&zAyhS+>h2ltjLLkpfHebZ+bIMd{|32xeT>C?xm#ROjF&v1ZVQ-1;-3~-l#@#glB z`>?jevquw|r)T_f^*AFuEYZ{lj4!{-wunM@ouWhDj@{-I{YJ-lMRQ%QcA}3u4|xS# zC)?!Dh08W)?kj2GX4xcs5W=gFEm3VZSag=|<^Z0inq%8H6A@1sKzz}LNxm@T zsGot7_dE);viXe{VRP5W#T6y62JFrlM8luL^nA1@-E<3W@OU@tnR-j3< zvAXlVxhe$7VY9h@ijjJZv{u<-H%Ay*Su=j$drIyVp`-~=vaSfqSudh0?64%H6eQ4? z!j|Rpg(pHRxT)`K=;GHvUk8$_g5cx1pM&N?ah!v)G)7v~3Nz)Z55uXmria;Pw$*-n zQ0H&a)KJJ}eo)h=b5ZzV1yNoqLb=cE^f9VtEx5+zqs8~$ZiP3laM$Ip>R#UWCsn$c zzh?i|CNV#qkBuqhy4t9~omL|EadyBScm! zNYAH-!pUH;Le1+8*LU`_`(U(*Wh?oRGR=FN`RPX20Sg+sv_HS!1Z5?qsALq4ZGf%ozy2Ubg@wm)Xga&CRZd z)n<0)KYF|mGkyLhoDX}u?odkT@q+}lEBC4N_Vq(4S526i5>=`figBJ#8Y^iyt25 zC+lqiQ{{0~U zvFPRg@beIfpo}~`0G9bfW~~8C7}?@ToZ?!LX@CEirNvU2V!^&mre^d%bkcRSQ=byB3r2buB zpH%Sdzljv~7qFBVg&Nb)(?^;R5tg%=L$Oa0QZ)Vw%zjr_m%rF`qJp{>ANw0{8GGVi zD23^N}NZ(x{N2etGB4+6a46>Xplvqh*y5-zH0>p2#MIza|79(iwx0SWf5{@@*wO!ahh;|< zqxKb;@CXnFBH`lT?V#`RcvbD|slJ{bPPfmHIsUVX6IGIAw8*uQxBq@W@XZmP?L{%y z3s16>$dJawJfdS@cwSKOSt=iY#1Ky(Hx3q?=%R}8g|fk>-OAb;2VV2=^8TX&K`!yf z$JS#zLuUVxd~kAx7T0F4BjlaBLy{&1|0N!&R=vf(pLnLYkmOdrm5s9sS3y*2*RDGY z6~v1b3r-Trx+KqE?*AxC>@T$%UB{()M0w@M3cbufuivt)<4d4gVx3i&?yvtxH$otd z#&23$!uM1CmuBSuu#{OV-@kv4fvd?9ECfN$>^$9DSEuED7Dq-$Vf2N{j@U_Pm>|Ky z2~ap?ZR^&!q6*IQI2jIR&x;o)M@EE0ZbcDZZhkovc&!PH+UAY7j}OkUw{5Wl8p!If za7PlFKeX=9+|_p1barn2?v4=>3rO#bsp&`9D+_aro<1cd=$Tb|UVC*hR@cIUfBw>y zD_80&yBhBA$+Y?pWcww`58P8OKo)4Sz&!H;Vn3m~f=j)Yd}&TM1b^J`Q?Vjl-sq#n8o|>lvPP z!I%J_-8HuT`zryEe#Pt$hbe&1`RVD`%E&$2efjgc2d`YeGY4Tj$E+Y~@!w?83fvLA58@YAP(TQ<(^W05+3pxz3`7pp~ma!-fY%u48%^iz`vmxCOT z>0V3~uJkM9s5CS{_(FQPWxK7@PQ}g)bDFRlr&IZLql{mD^S4*!_gpUtyz+ zVt6qW9^p6qWo{J!1pL6p!;^&a6f>^8XOA^|iYb`^tmLVU+bCjz0#JM`bKpoZ~KYBd8%ia8FLXAwX+KEIeXI>R6M z--z6IMyA>@k;UpzT?&3h$-p2*ey> zW~mXQPuEO81j71_@=RSvw&w4KYat<*yG$N%96np1Z>pdct3XJ#4q91SUOzp9(HGWo z!IOkl=k6~|nm9Xeho3Fo_Qx>+1|pDI%iesR{h`U_H!(l?pMG}13{AR>Gj18KbZE^E zip$^?pqbqb!zv!Ex$hq~$vX^`m)3oPH01N>sG{Bb6lhzmd9b=f8EiF;KJ816NL^tT zFfb&lugCyWrIYU8#{#?AZ~|E~Js}EV^T2}re!o@1y5f(=tL4pybWP^TEZ-fy48F&10MUh`bs= zWf@J+rp%r2h$A(Z3``fKIB`>!e%J^>-TTs?qXvD_t*>78U|~+&c_)L4omNa(!7jcSMy)u zB_{MGl)ng1LR0DkgclPhM5IJ?h#dnhEf+o*RNYht`FdTrSB)Ya9Y{vRC~2?$1X6BZ|iY}Q)^JVU_mF_yv>eF!s` z0SjAB6BJ@Mpdh>t_!#>Obi2onWB~#OX8i>IBV_gX!jXTLjR^EINTcKebG!h!59d-J zdIqBd&NXoW7Ho$TBET--#KQl2ztAs6A31&cbR3a|CGZxj(NOgi@)H61V1)HR#07S{ zG^*Y6cvc!(7Jpz(fy7OH$vzA)2g!4hP6#X813(pgP?`_)Yap}<1ndg|_#=W6N415| zBYss-6O8%t`&12FL7dH;sn< zZ5(@3jFhBgd)85DKl?dsGMLKa-yvslhvhPM{zb2qMW@jcF;Tj-cmDsnXcE&<+=S9$ zYB^(16tFKp@H2C-GLRyI>+dl?!FlE*TnU!?SW8nzN_BcUB)J9iRVcOng>q-3jTRduxx5*G0-!NOarRD!zSLBy~) zdE9tKGw+1uG9Agu&AkjAB63=5_BiY55q=1u_F+Q^SH^Lc5XPm0dTgQ&d0nTcZ8?(0 zS?QA1L3{5eqODuAh$lbBfQSu2n(cu9hXI`jZ|rc{t|B(F!gjg?`$d3Lhjv{XgxF^#Q3Ik6u)DbUc(Sx1spBi=Y#SXe+b6g!OuA^7$e z9bIL1fERO+Y9jW_4SrJ(2i$x7%Nqm#;NW`jECw#E05<`0bx4o4bC~VIE=KESk)-X2 zSeJ4hm7O~;fj~o!E*%{mXeDD5kJ#jrXecLe7yu1VEW*IJT(IO0!%5(J<>)S?!Mtv6 zZfqqp}ZU^(@yw-qd}!gd*<*;F(-&Y707c;^y>OvV$1M|>BDHbu;(?Ay?_jqE)?VWwA! zy%p4<>*dRgIdtI>-sZnV5tC`rms%HUI7uwPS$#q8HYfz%# zw_wiY6{@MJSzKO*R{8__@mvM;DO@Mhlhn(taUxbA?M39jJJ&yw&Mm#ELvQ|0-QZ^3 zMa&nE7%hk((>?B&gk^W(pWt1T3mucTd$v#lH&xa>Qcr zHS{NZuo;>AE{ULh#a^RXaAB}wpJ<3I-i*I+ddm@Oo2UFymH`CjCw?BYLC6<7(yS-$ zKFUf=J!7(rQN*S6Q=wA=ZwX_Mr86GRZyBvqsZ8J5r|r?THW(zx2_8dm6huvT73}3e z?SmP&Hssi{W4#j{g$J7@Go7GUq(WF0sI`>h>m#&bq9M0}i0Ob6C#=vZo*^ld)M8D} zQo~NDi)TY$f>gpnWx8k2p54wM%nVXAzP<+4BTrkPoO$s#-)3Ypt&gE$qVpo7F!?S1 zT0boIyPbnmDnYtVo1XLtYTVO*5-8s=oFf9@%!)NLkC*jjd#CA#vcu!FS=?AuVRDM5 zbBOikbLF%q;Mp4-bSpHs7e3L?u2Co)KvBjDA%lpvVtQHzW6oJ)D<(SGa5s@110gla%hwDg z*fyD*pZx{$L9no(JV7-l-=o_ziut_9f3N^3Z9sJImL49A$BMIsJoeJokHUC&QKL8A z*=Xk?HaTt*P1_r|5@jVf&;tROMV#Qb@sqT}^}=Z#HNCwmCL1j#vI^`7s_E~R%J^Hq z%Gfbxz1}T$h8@)*SJmCTauCHPCVhNoJg)IL3ao(u=@3R;&#ea7W!Y%2;i-vXaN@ZU zv&Gwy_Pc=($5=zmZrM?^7R>>YWjEQb^5$<4F#5jjWdyxY$@l~$^ppCFnzEez2V^wf zYx-)uW(gcbk1cpsz(sKCWoapn@?a`4H+ChG>xI47{`lz89^9-(Kbi0_yek@ICr_;O zSX?nh(O47N*fX2{{?n2Y&3$Y}2cBwKvF9xB^F>?Sr2K8;iP-aPEVLpWm?>YQeM!nV|R3J*HjKz3TyUj$DZ|i~arQ+FeTOid$Kazzg=wx}KiZo@KVe zqD_DEar0NbmS?=!nv6V#W6L6~BbLZyxRKIv4%QcI#_b}S4%cg6tJ9tJmN*U@ z8c&K!4*qNO_S{h!OZsn*W>9f=s zrc+DqC{%WdiWz;I$f$^>_9T%y_JAGC-M)Ik^lRZxt~>cR$VgOSLVJV^#|y7@t97+n zYrc4Rc<`Gvxo^kh3wCca6gE8Q=~4{px|KGZ(BB^w8L1Un>)#NNgl)}$bNdGLFTs{c zJD`b^9AKJ@acflih+43k%A2{4Pr{__2GMipo0l#x7sguoN&$~Bp;4r%SXv5ECzd{($0U&29KM8#BnSu)>Cf6ZjzS z6L7tID)1nq+g2~CB^B^{==#{)FV@EqhbDaJE)Wr*Xe*hgBT7A>T$~b97b-Rv!}lGK z*LPIrHjaK6?gkNXo zvx=J0JoXW4Q(9N*B#)-ouhoX{o|z~wq&r?28gqfP({Qadt38rKJnNdF@I0AA+uC16 zyKB>Mila&;%{*vPoKd}L_m({yBA8sSFs!O2vPO&t>vOP3*SE##Q0sjHwz%<}&-;tv z7N3KXF{^7z;=IBNt?jZ-5&0@~oeOST2KN|`QGcT-dR5oKOs=rVmHBme@BQLv7lQ%z z@N3@KG!~Fk6)JpNC>{7>I8e&(z_M z_^@7u^Tp{8&|PArzVSwm_OW{I?Osd6>`SGRo7;E$x2g)y`pB05nUng8-J75f(=_Q? zh!_mz3nmWz2W!3^vT<;ox)h?vhPlqb@GN>|kw2d)n?l8B|A?JAbEcsRAw2DgZbrdi zCR2>Ppg-!kE`PR30m|S%WD&44?bFP5a`uQk@Q7n2$eQZ=dpKWE-gUnYswT_lB#06ai&c6iGM{Uq2gx6DdE624)nM*tXt6@OJ z^!OL4rbYI6-HPspM5tQ99bx1I&i@bH1eM*yC#sljfAQ^+qsb0}bHr6*Xl7aOOvx>nMfN6G;HeQLE3!O~N{m>B-rMVLfddlUJgwpK(;3=`y=fq@4L z(MX6O-yKSFgYj5TFC5PQ^OZKjm&EW=hE(=FZSeKs@42|R>?KL;IxZ_O5BCmfb`&tN zz|iO{Au}*VBC>Nd^j~9?Fe*9?adNMohC$KXNx4hf7O^uKD!Y7dV_pGl+8*%p& z6(}o#Khmu)0bBsgg`g5px9Y<=Z(|hSy9}%kUV&nOW*%%y#Fap72OF^#mZGZw2n`{68`=fXA85)#vLM^NVTK8N z0k|1X`GEHF3QX${E|l}mFDz)kM`QH3jt-wTa-ts>4}o4O+@{-gCJ2u;@#t#T$iHEA>3nqOKY>n|4@SFi{;CQO{ ztv3N8dPqGm(XAPWOd9Vxv&{n#m2o&utO^(c-IT6?p?iC>`p#N&GfJE@9$aDPpcgDl zU!)|O_D7x48kC!)4#qp=F7X>F5vxR^8g{yvgBp|UeoQMP8hCa)PN1$l{%bu7IJl&* z)r4k7Oj=vu_?f+V`_9ImiP;O@$On0*leX~O6pinPBGYaO$H-b(q$CA)JheA6zM;8+ zz5a4=%uC`7B@hd{ji>}=b((82)Be@AHfzT=bPFvQ6C2AEI}M=+lp5Eo<~IV+oF1r*Xs0 zGtr(&H(rJQD^b-4G{Xx}8G^c}|NfE2AJN2tB7A~;fKq4R7iuc@(4~n6)54t7oY9$e z&Djajqo5~(Wb*c{qVJ&VjBDhY1WxPtn~#$OAPd4}Xxp;GT@GjpUv~O3Spd5|Enjlb!)m51BzkD30M80*s{MSJu$g^$fof#>RHLHFycb*2JXqPM6JN zl!j_$7K;WCs8ITN`Sm;NwXVDX>I^W(#A<(v|0@7eSoUNx8L*H^cK=<-zd>|l95ff7Q061O*T>1U|9O6Ve^euhbU6%)k-zEAAgnmCR{H)rP=5KgU>`kK5 z5nunGYY_ZP`32=J0#-zSpQkkp96xf~J}hjb=}tVe)7Vu>&QRkaS@)g}J^E~N()mVc z+w1GKg`0q50as%AY1iZ#=`JPmQeJC503Z1l5!;mb5}`;ySk#0Xld#Ic7+@=7G4DWB z0gxT>;GftAR>^KLJh-qM{*w5lTT~l21_cL)9kqw8hC0)kJA3;?kwa`tk`mV;-#cI;BO#Fk86`^9GswPA?S_j0z}0kjOG}<* zbr3v_%sCAJ61qU&oiH;q0b_t1BWFP|@${)+PkVeHAam62JK{zg3xTTq?=el8vzf=m z{NZb%rGUgSN$%`T{n`wIAx%YJ^Ae9Ry+TkxfJFudS~Qd@B-i;A8xR z*NoS4_`u*?&y4WiYmEzY<=__~z9pTn2TgX>wugW6U7n0mC4cjj@>{ItYrkE&w)zuE zRMU~}<<{M&-7kKaBq*XZ4V6z%wWJ@HC;>QLjLY{vo9r{+ctWL*m-ku59r~sGHFEyu zB-i$XyQv44THPPAdiR`---lEIDJdy%Ha#G??u{jb!^Z{tP(9a-*+{j2QF?7gnzthN zs(C$J6*vL@UGbjwL++oP>e)_Sm`-Kz$u9gwA2qMV@D9R(hG>CJeBQsdc}>K`%yAXp zbUj2r;r{B!X<(h6lO^3>vs;;qZVE*z3$C%r?FDXababEM#oWH69eaX#ub6Jc78-Y> zU@7}j<%jhtSud!#QZQc{tCp8-i*DY&Ut!AU@TG$``$l7pTKE2LA>FTu%9(r6 zIk@swX=%cDrg8grCy{v3gtIGKkDrPe@$NFS_4&e*yx11yYwUMV!SeIVA5Rb_*lxRj zxhFRMx7q%y{KL1!X4KYR3`ZWCX_S~5_eWyX_f+ZFgIy=%3*z-<-NrjI!t!Kkwnw}RGJae#MVU&T z)aG?FzSRyz>C2BV=Zlg&Ps&Ay$3Okuc+^*}^xNI@Pwe-P`7HD=6h`?>K)iC4PgH2N z-fu;lxh>ys6_FFRwm~4<_W$(!={0!oX|gJ@`*2-;L$f`DHQCj@MYdjH{>ZC4rxah1 z$8&SLHt&Z4FMv2WZk2Csn(u@B+?KlUPjFYd`oX5p)C{+(WK1KH&B9ao?RpI2`6L zc-j7RxS+Yb8W+Y?I{W@%(BM$pz_+3By)z9$A1E5DBNWel1>Ipg*7f{H)}h;L`xjp` zoU@_N3fuWI`;zS&zu`e+^4>COiqO~ge#6;+>Xa&CVkX^H*3Gj1Z|z-oRFip^egVZ% z8JNMs2qHQ(L4^?%q^sy?s7e)-qEe(QEf7ioM-dePacH3pEz*LD0ja@3K~z8mLJ0w+ z6G~_y5JK4de&6odb9T?!v-|I!?@vlzUVi1?`#kr#_wxOcZSWmt$Gt4P?|e$CH}PGX zVW%Z$|+M~~=x@)65m22Z|J;dXUQjU2UF>X(b7JILj z*S>6hXvS|WQPJmK-1&zVx5Df1P>vJ9tD^T#vm|yPc*25fHey^-6r#|g=3%sKeAnFY z^P;M5>+_I<($n|2je1;t0(tb~dq{dF>UF%_9_{wKq!yt@XQOtifIuSaYj#}su_!&h znC{nxIdih+pt2TCGJ~9#ROCj#&uuj^jnv#aF#379tAgfDP;q#4MUjsB9>qVgu0_*&Vna$KmdU6_M^d0xWqF*7HVK8%pzkf)JBuiwO6)r#E*nygv7U*MI2wN8kwc!DZSwx{;d|nK(w_xVMj|otWgeRN)Y5rJ9*nT;o$E4uKU~njV;F(REe*upjdW z(vPZ;Slj@2c*@EEG5uy1DEs-sC7>Yt;0k=nciU4-XyJmI;?)%Z> zBLxI+#&G2W(eQd6s|-IXEmcUl%K@vMe<16r-H1X*i;Tj6RcY_R?M^@_rrMU4QPv0) z%zO$o7ZF>M$qyY8kh+*@B*~20skHjpRktJMnI-XfOICoO{d63sGoa1-gT%o6{pG8S z>MqTXg1nVVidStYZ*8LEn5D2rPe^o2E?rb{d9SBD>&YA?LYt8WXu z;CgK8l8&GBf{Wj`+VN$HkeQdwW62JgFVZ*+qTa@o(tu@hjl_uG11mem{4Jl3MjJ;) zlNa@hwYQ&zs~OcEe^1vf5YkAgs>#|aSE=^I{?XAUXE7w`q{arqZGnQSdT?PAOE~`1 z1Po+bS|?bc?v$lAt(q%m%d7$-*&*9>^y9`;UCmTWq-N4Ju!CW2 z4^mZulDwR!%ww{R<2Q?yUnL|p@~6W8ds-i{1~+GUQ898Ap=&lZtl7N+3uL* zFEunud96jyWKjd??|FZ4wMm^?=}z3G693VOTp?^@jk#~r4fdnJG%It=A5N4frhK$< zvakqVl*T%a-mzXrD07Mhm4|@V|%o#g{I+uW|l0-}N-_%H_R$rct zTNyvlwkNT}MC(}ao8VZ8AGMgZON;G9NGH{E7&A53Zd^QGRllGmk+SAfH3p6R!k*nN zoo4tinY6bVw3Q**^3M?gF2>hK7sePc>%H8~QRm92hBCU(t1^*Fzn)Vua?f+k40bW7 z!TcOMcn2eM<&_gAgJk}_A5JG@0rgrBqvf+geUs8Z_P|ciT_U$@C>TKJg$0&*6z!9Q zd5Pf{g9ekoQClZb*{9}mYBKw-QuSnxOx^46UOpjL%^XC}FVV{03_k=+ly8)*QC2fo z%{ck0U3Xn(DLsH{v;A^!5&$V`<=+LZo9mQKEhu8H?(DT^^f2+8>&kV@-_CvU91j^- z%CrmY&3CUW)j2uK;p^+IDx9~@@W04fbRz1l^xV|AsLS~p7x?BMYyO(iARuCNzu%** z?$vjj58d@EWKC@*cxW$g%=59+I$-f7+M`HV=XGqpOvq@$g@S8b{3aU@Dla+&STzGl z{|ID=&7#C>u1G>>|EIyk=!Odr8sFBTW3#Q;evgYr9_&ElZ9}VV<~G@05qD z@vdW0v$&)3@|i(5Sbc?3V(A4HQ;a$PvlaPa>IEU@H+`0#6O}acF8Kd*Ro;C#arFH+ zj`Ne8qkk>DiiH#~*U*9U4JK^S{0*a-A8+^#ovbgTk(HjT%%r`hwEDhk^7JGq&$-~8 zV`nL?w2X}&m+@+={tP@0xkP~aKbXs{nV!cl^dfL=p5BYkos~Usf;d>jS}=h@f~S_h z=jCN;cRNsHJT zNq;Y{?$-5vwTQ+@J@0_v;&=RZ-kfjT(hm#-v>w&k9D~UYTuEk7^O3ZAY74|+PeY`@ zlyyd8X+hXcMoQm#j*Mnj~9cV%5i4%>+BapX(M*$xxmUL@Svc>+O@{9O@z z2y!?I5kipDKW}k_Z#_JS2q4I%EquQqNW`x{z%L`a&mhnZF8c@m-{6b$KSl6qR@8Pq z1f^PK5fagD%d{eRzQRzf_LNBT{W7EsRl{iiFaRo~_A-dTSydH~Fxi95_qAx?0Ku#g zj&y=v@N)yuU)zL!8QO2mhF8KrU77^FfM}Si_N^}myZ_`HDl>=z9WDN|a@EXmpxE6hVfag?Ps7EIUWwo3-j)kb?>?4X1RWldj&y5;$#2LBSiex?|E)nv zb_066-h%kO>iIQ3MB>4a100&D(hY>??fch)9axj z)KX_8uxgjjByVxd_OEhGUncA#qCb6}{?6}^*1l6j)(%a1L?9o{=|OHtdvOP%b_?DP znxFcZOz-hGK?EP7bPR}i$**5qAPs`v&`3|CbYj&0j66K^7NkhqyM`?yjv$T#-IbOm zb9?;H!e^5F@(5Xcj=3*ezD56)3mURCK zt&p=bumfwye1bngcQ_84>A?5m>r_{9au$EV&N>(XWO1g@9ygT_#H_{cnO@f{y6>lB z#mQa)#ke%nnfy(eaWamu&2w4#Uza>i*$ z2kMQd+*Eoq6t%*Xgj)>I?hZYm`(&J_n_CN)l~Y8WxHt2brOhekhVKTi+NKqx5WqWR};p z>#(nh==Pv9l%REwB#cf3_Ml*{o;P)< zfV3%;)n&~Dcn$V1^p!s~2;RZ7daA(E<4UWoXbL29=WC&W{CWXQN07P0hL&UAgCE?z zo+bf{G*GZYC(uS;TU;wb3AuMD$Dny0zh}euH0A3Pm4G9o@i)# zckBxaUYljuBrK=Y*6=Ox*+Hq5`b30K(xWZ+v-joV!Hqd=@LHruXm+>;0AbvH02#Nx zX31_)rLjHNqdOhVcV<9;8u36tdnfj;PUgmChOP8Ul5|w%Hx2|1MN&9jWoDryDqUkD zz2kekviw59j*NpDdkb{?wcQ|2nO<0yfQh85e~Z;$;zOLl7*UPmLJ91#bCVYSFuLLha=x<8>d@WU2~)0#^C|L}kV9avb655H zcM*Kb5A@znqcjL&!qi#$$@VyCxS0h$9YbG-b%V99JIA?3HRj{enc-|uqG#GJ0Lm%f z4g*GbiKe=#M!GP34OFQp^?-&UDt;Bi0Kkir(~AXrKaU5D`8JeR4%q(lwX3I{so}IM z%!3)BDYw`kq~k()OIb`_jp$b8)p9ry<*l9YT-~pCz@mHg3#CV7JR?l@6eOgo9#@GB zE~{JVb0K(%Ms(jif-W1$_2L8LpWguGikf>-Pxr47d>Vgj9RzMG7HSn?Y%I(i7zUB# zF5D)|Ve_P$hU{23g~!TT@1JNpHS!n;-Fz=M2DpE3I6a)USxKNmh#7%EEY$Rs9C=CM zM?MZ6x`NQVc!%0S;u_=Lr3tt9J>Rzd^O@}0iAN6H8)qHWlW(`kCg-Kuir^>prNp%Y zJq1No*idibz#SH2HRdY;1=PfCNreaZ0oP7hYxR)}R%>zy=(4QZ5zp|r-*F#eW#$vD zxesTHGUGcrC^jH;v(4b1NQop3u%nSV}f5!9ek*JIZx=IgfLoxG!LQWgB)yMND@QmXu ztamXrS{|l4WQWiB|9yLe&`3W4XI8kGC=0oUZ~zV6*xRRMWBKz$pOiP@Ed|3EoeOrcq^}QA46)JeVoK!(kFWi zA>4(noNW87J`i+sqnKhjof|i}$z!X_%m6_c zLhFv_hkL;2*Ni1yB?VMK_OjHYY7u4sP-qX1MJYjrZ!t4uZfb_@Vjgi!^=`htS@7Ij z&D+r{VCH2u0cMVmNNol!tya&`xgE`j=l<$oUsq{0C<_9?rT;0+)?XZFsbqdG5Q28T zEwV9zpt28L{(1qNV-}Q7&*_loW1E1SIt6`%Z$8l&-_zOvM`!@N6picR`*1?cKIr>D zy`PL?!*1iPDc<%nbDRl$R?u)vF{xVdFZD(mpfZqZD+6h<-!}RHZW8jX;MHqCDb>8D zkl!4-Ub2S0Z%_^#pDLIrAcR6D|B6myJ_wf;0;xO9kOj6o3&?h@EIu_W#Rh-qRT-g> zh8U^0C)ja=W~Ae^Re(7J*IFJf<7?Ir*{O^X#vi*(CcDc9AT z@sLGE_TGxH{s@vFVetCUO^*^@)qEf%Z*;xiuU&Ry92LNJr-kz&K>@3hjuGVP8HLuW z`c`zLO{9a+? zw1_BpOr?D%17PUZN}|~oUMEpE0HliDSY7m=fV}E+DL%U7M+$=(o&yd5`qMgAx3bNT%M(2Au6;q z{g6|wF#%`+EjECI4WpVy$Dof81^S^j3Tqw3L-oKS$_Ago!9|PS?|bnq3*b}zI-SW| zChFNieF;i0;Wm zD5Kil@Eb_kPf+x>N4-&7B?L&PW?nQ3>nwk{a3FYe_>pqkC#XD>n?$=&qR}yetQvYp z3PKc~k0GMyE#orHs3<^9pFLKG_5}j70^Ph;-t98g2J^;ZM=Dp1J_Va0Cx8~wox1HP z>TC59f+E*ZC}C0M!LhC$irT4&Ahv~wz;Z-&!_~SQAPZb3mxaxOKZoU&M5`A8h&+a= z*P;JdtC$*)3HAZ^q_X%t_ld#D)zL&#lMu7xkPgZ{0#9NUQ6vPN0f8iI|6F++2$q?o ztKK$V2dzr3B;4>NB!Dh=5(Y7Vuq5pQB;tM1Rx<5!e5Ws~l_YAF{Q*f#;e9H3jTmtB z5|3A=D&50s{nis8@%<;<;64Z({}cFXZxugmw{gxHag>hl;NUiA_+?Sl1r5orB7zaI zP0&`x;p?EYqyEGY0;p>ggIn&MCtqxP;u8(1Eo(Op4q;l?S-Kb zFYWQiAcCtS>X9I)If3Ven*Jde@uP{!AgDhKIV`R#)c}5+%O@b1C(MP@Q6wPHAm{{g zO^vXLvlWD_DdFym2v0`74P3!d2o`{ihTE_#gh5t4l02vY?d_pV1PZje!l&f6?P%Cc z{z6Ec1mz4AiVnhD%UUDFIeO8*qkf1&kW#`6o0Q#exYH>A6$7C|58lQ|(qf zRtjmoH`SI~ky@%RrW*cOAsej3GH9ct0%Q;Y{}PAd9Y{p%z5gRi@t^zqza}>R^^yN8 z74$!^7^^775kUkLFz3!#Gyr+@-UC`!CoKB{~BM1fK*8!GZ*r7$ML|kU(&PySv6F3@#0gLj(;L+$}hb1P_gL z2o|Jq*L}`>_qTiRt-V#dRrmg}Q&W|6Xu8jN<#|5#o`7eHGM6vVTtJ~vmt~(mRzac8 zz+XwfpC^Ss?la1fpiqCHWFJ3Jb5C3vKU2-(awN1`;U@MfttujXc{wT^yZr3F?4M^I zE8kDM_6G^cb!E}Cs;Vkl>TJTxYx=A*I3PlZ{5%8+0MA#F1y)eiIlzW zxPFIZk6Cv3o)0yPFOaW0bG{z_Po+DtCUQ$jry5<{J;n z`{LQlG*N!88)0@N@(iSRzsEC3cs{K|t>(5%RqeHwp;)hy+%Q*&;nsZ;d_x(9A_PV- z$;>hmNMpsvPG2-v@V`QT0rhi~&$RQ#$?msl_;3{J85!wa7>DS;{_}!9J;Aop@Qt;o zQU17D%1pS&+?YdY9M#H1uC~uO?W6al7A-T2ZrWv24cg8l4qb{tJ-WlWPHQc#MtDA> z0>`q-5Kq@1ww3!^o|GG|cPa<2GwwZCiLFjg@!cn@y|`Ju(LI{2k-x5SS-WgdUE61W zKI4&p>AZyTFPoaJAx7`jhJXHKT0dL9) zCNbBg@?kwMmz9YcZ_&9#C)+AwY4hWRm+z^EwC!s62K{!b2nup`d=16@idD1mR+Li~ zc@?u-rg0{L?DtD=e@u)0OJTp7KGq^Vu0^sqKKdxxi7{8HfA{BJ3he>Ur;#ZSZ?3AP z!cm*DarnG@#%Q%>dA)=EAocAD#+>G!VId-?bEz<|=#Zx`!eVvSn7eJ0y)1#zzLFXmHuf0j)t zI~AkjC>vT|D!;edcvat_`TGBT1BQ`wJsX|;Jr{YZSHC~H%Fif7vTsd=)kh)ykH;UlJe@P`Ubbk8Rd2NaGwda_b9L^^ zc%?1>Bi)PquCX;6ZDIbDjFpr$tU+W|r$rf?Qal&39LxPqjy>YvsBrY#6}Gc7`fT=2 zc&#J_s!RGwog8kkOC0SF*D12|O^RHvzO+=(%HUBaC%)L9p+t^WLWX8!c9 z<;2p_{jJ4e*da&TqjqKEuG+R0Gqtheq0no8JR84~l8OF_@oHBE}T1ha`r177`J^Wm2=C}?&()MJu6vk521J4FR0 zEK!$f1o+(1|9{r z+oqJe^{1|;(k2jBU50htN(R+&!4^ulMwI;a`zLC=GR)TdmWq0gi&Bo3jt^G!&5~UB z8h!=wB&(ZDI6oAz!w!{L4Lb^Zamn<)r?vgD`zlMb=mCDHoPgEyoVWhTmg23Vs4C`8 zF4-Sj%)dy4>hVS&eeTJVbcy*nWRs3eyG?k^W#`v+v=s~5pmXn4@SZahS!a{llRX}j zJXk9FG^w&p_cTm$-<$^Xx#p4o6%To-V48b(`g!UO*F#Qjbx>kkol5gY>Ovhw<|i3X zc3Vfn<}o&&h$T+vSqx%)BByTpJ%)*M;_$l`a#Hvz zV4B=K*1M&Ow0e27-{p&)LtZz%XpY#UjR4Oi7BSa_7sJ1TlNR_N6!8r5^ylUo1zF{w zt*{nS8jBAO!e8`t^qaE$kqq_IZ?};#l5SmA^kkLM?{JOUZ@ryO-|uLvM|`8RnMyy= z+!K~DD}&q858l^K0W4VI$#)dTyRA~Jm#nMTTH@S~_vU&s_Bf~8n8G@n_WnqTh~sPc zD_07$Ym*Oq6tClM{;`m9}*K)MWY)u-;?_soH6OCE>7V1>~f~pE-aVxU0 z#dee_OgyqwVs9EVU%CEf`dude@3nbsoN0Vk^pzet*t}a!UsFyF=VWc|YWGY-v5x!9 z#jx{)+PV6T-OMNo3y}S-RZ>rSF)AS(mH2vd`cn`$RFsTl#}4*(tn5#PN?!EsQS9em zUmLIBZpKKDJNGNJ=ZWOS(}{~rkbmQE7%@vpk}h#HFxG{NjeD-4>oTA$!}5>mu$dR; z9mPGTaAV_cQezhrCMWr!juql`0aN!+ORE#wkG^Ck)kEY| zB!+4v0Jcm@#}I3eS-e12wIx$f*EcJlz86IYnf4NAP^tlISL!eB{FZ!4!6`l`JT%yM z+QrGu(x=5i5#?<~u8%`im@#?8M|fmKQ?Ee#PK_D3vC&(7$Y2y$He=Lb{#hoBJ1E5? ze)3Z4U)UZ(z@zq?(9LW-qFp3g%?_b(CkF7q&CuFa^D<$^A{#nJ|56zK!0C?Fur{>_ za0;-2nXZd`jbS8Lz_FTi$YY*{haF^DNo;<9gyXKVZpp7)_(C1`%O)_KO3!_g-fc{I zG%#wZ@BQN;PC*j82D>P-J4}8j2L!f=bjdgr<}mJ)gNV_au2x3B#4cHW@=xn3=&4pc z>s#vkToo)q3M(eujNY;VZS`a2j<9*O!uyL#1lhQhc^YDk~{_-jxna zMi@uGlQO7(wfQnW>YuML8AO9(k@~ zvMGARG=b|BdpZm6a?Q?!Y(Tr#UXH%rL-i_0VP2*bJT5%^&2x_#OFr#~%3SM%d2R~) zqAm+jqGo%}0D|RBIC~ATnxnTHvW+*M;dJWcl^Ho^PP-;ZsQ|+-bo4ogtXabhGL& zv6%q2Wb^s@wpw#6n>ctd^}#z2Ed@dcO{L@Q?~v z*z{RXea^eiMf}{7bPAu754KP;-f06v?rU&>M=EmSM1OMd|jEk8#>%y4pnT4KV>}+*WlLR z|EYGi6!~Ovq#8UI@G>yonK94d;+m5z451!LEt!e?XI=ZdLS%T2=VbUeVVKgTuB=u3&)jY3{l~Q>8gRR#>Ro}{lcAoC4b#A0!dVk4kZEW@D$InO; z4e@pV{`=!+t$1D@lQ*R1ZtH6jf9K!B%uzU&G+ zzrD>nN=f1ir>hB8{+aJ+>Gr+mWC^d?em~96IJ5nCuenNp-1^8eWkn!SwBB9E@d!cD z4^~QiYOZ4xa*yvA$Y1*x40YkBYeuT}+!;NNzA3&!|xdBd(1>f$>J7FER)+UW}g3+)l`z$8c6n9 z-h&Dj{rB&8?S*>y)SjUC+K-|~Bb!;rYxZUnq-l-GW~jdQm#&u?2fa_Yn+uh8<}@G# zhoRU%^jxJE=_O02CyLo@taKU_ExA?#6ZaPuvhydVS&c=gR*zQv>JHx%Jr{G!01U=V z6;)XkR7desX{wFPUNgnzGg5_INfzMoSN)PBQqO1762mVD(`T$6<7hR~!J@?ZwGl|D^@|XFE}t_($b-HDxgj2e#yzTQKec zYgG+e%R>={?dn5PHb;E(X6-DTY*u>s9>}3eUE*zPGJY-yr-^yFGa`?!!n-;N1Jm zUhenHw&G9He!kq<={bGo`!cicm?Ms>-6BltcsDIZZy)dkv*01X7NuT3#s*b0vD%w?|otD>(k zYVWzMS({d-39ip)q$u9$uH^HEB_K#vAEWQGQw{CU=xYvp*ym&RSdYEw9Qx2ZTWIV!Vq3mh%_#9z~R`wPjk zCNc~j#*%N$bQOH*ZZl!-)A;wuW6q|QV*k8 z)xCgnn~2*ls@^KI%d%_UuHfK_W*Hf3En^l*t-j&4v^J8bUYnx1tu)1JBE>c(0ZRjc zHE6W0G{yvr{$c((#PRW$V*&PkAhld6>8x@5^Z=}0&gPC& zSz3$SRXJ^29n(6o-O`om;x+X)BZ|A@w9)Xiw&>kTw-w3lrVtHfOzjsRO}E4jiNRfW z+oOD=rICI%;OjXp2j)xNB&?Abxx0JvZ3wNp+V!x#Nl)~`BLlWCntyfH#yw0 z^}kph9qRjWS#F9oK9Q0tcDH`2OCtY({3)fTaRgqeVqp5MBUg5Ce#^^r*{{?iV?umS z1kJ_MJIS;5hicPfCjm<4v+F{8%156v(BTu(d#|S?~Xc9 z-zgE5@%5>5t{_jLP_@_h%!E#MDeI?if-p%f?!MXr+TY*j)~%EmBNiu#y6l6haHM+i-z&wZ zn)M`)SGn~__0*DILScsB^ZhT=V!y2bw|b7kV6GP4Pw*c6fI_{>!v23$ZsGp_OYI$~ zS?}Or0+AHTQ6>yVD4occhS;-+8ZB?LK;vxwy}+nd+;w@ZLN!B?9yh$OxF{3Gkkrbz zGer9J1_;LW1Gm;ncUe)xLbuO#tkGlM;tJZEKG%IDMSiCC#Q_x>dM}Gqh^*eI7RCnN zp1w?5Qmn0a{^;bCJ+%+6^;>ff@?}YuR1b%_Umw@4oSHjXm(|=-Kyjz)!%xypP0n(k zvgaamqB_Fu)fH@;Rm_jan}Yk(5oJBJw|>R z|G9;?+{k0iY40%#nS2QgZ!YIn)}D*-GxIRfp@;eFJn&bd64$mXo!2$^Yq1nP^21CN z$qg7|PJ%ds(f zo)$xzW=S5iaY3@V`gOiQ?I&S|r4Tj&Um?%35Y)aDA;X{vtos-`ZJEh0p#GlYcAUlE zA6n(tY=b1G7hDH|lPMX}Q#ksoK{y23Ed^7c8=L`h6xT(BHy{_B&VUg@No@_Nia`tc zlAY(d*(+1#fb9|@93rwh$jdx6n|&`E--EJd+dS>CGw#Od(DWxF*ymTMe5eJAR0_-n z7T3K$E8+7D?(kBdvhQ1gcUOJ8z@Y;>G)c_Oc1j1TWsYJ#N2zDKSAAg1vbY? zgaOG7IULOQb@@idW^52=g586%9QC>fG)9m)b%6&Wf&=Jxl1}$Vs}EM{PKtUY(a9+= z_W9N89r)dIjNW%QCZMK(MrdL=3?R{p+;OjuyJ~42K4}wy_MnAFIFZaOz>V!<74lGr zP=Z*FxXZ>I5xsS^->&x$bN{Q1lFq-sw=#Gw!T8|;&s90kN3Kbo?DeR69svsX?qJVg zK3axvpV=hK4u~SFNBTR)x2UgETN@wCYjj0&_vgWK)yjaXQj*J%rqvXp*TeL)okCk~2otk>j6^ty<5fFKzU!E# zpQq=q`Kwkf59jOaE%ayfz=V2(V9wy!b`#B2G5|Epz2OWMvU0tvSaAwpRalQ2uKIol z6fmHynrpLenW30OE~DCJ<2sQ|6V;f-z&3848hyU{wolS?NvJbogu zb~?~Gu-Nv*75*-DUCHiDp59yWI}wYXXt9!w6ABQL{wdCyLU0|mQ4(Kkx~`#90C;eA zklHMf7bgYyPxT*L@nNuk>_EQ+<(8+Ba4@lR48n#f8mB2YYFFoD+F2jSkyj2Y1i^>% z{^Im}#%+K&MRA-(#uGfC@gmj;FnwfI*GglKSYLGI6xNnR=YfnnEbvrUNO}8!aN?^e zqwCntvLP_jf6bytcq%w}4oO{{9go*lBX!dA{Q3Hv=gzW3E1gquVW06lv;fPucADW91VYDgYLE-IuDo(mBt(n+eq8D|Jw@63)yxmgyybG(q zb9}Z2HKYJeljT9z;bDC$V$^4GCl3)%U@8xSxGR5yfGLma-mR>xT>mx;s&{4LG3-Ny z!7|sP?3BZ4HrYBT42Qj8zG?@Bw&+|1*=@k894V4eUpx>2lSXL+D0s$Y_7lB>5Kx}Y zsQsW245nw5OeeWb>;b674B|3id&&_L+T%?5j_&iq1?I*E$J|F`VGSJzX}(qPcZRn0 zY2}8FV2e6I+h<#5aQ*QzD<(Zzyzvr$YV0A573-KJP+pTgH!uc4ULUPo%u8Um12q;| zq|p3N##ekbIHpbWJm!*ipt|}2%U1--$I7AQKKb`XEZxCcDaK)kT^RoMil{l>A|uyFqTG&9 z>rdy{#@{3E1bVnG&`fr7D!3=ubt`VKG&D`3ad2k<#t}|3DvV~GfTgw#egssX6tE=p z4d*~)?uQBGys6B8Zx9HF0XR`yG4C@O8q~|rKhR5Rj zYI4XqDT0I6hIhBsctIn$PQgZ2?XaIzeDNH#r8hpbB;^+n2o4rh#&?Nrv{Sc;S-xHk z;-w#F)P|rxK*#>+r+DUn*1!L|X8!-D7c@gWFkt`51^iF2$^W|Bd3}dw&|vd-uLqro z<23ZP3$M2G;6J0*P$)Q=`S~l~KcF&ikle_`zME)xdyZ|*)$%(mZt?wV=hYr4?uG2O zAS>f-mCGWwmGlGO0{Hc6D7D7b$wu(rMPYk81Exe}mR7TX?-rw5<+_DEujY@sr1ns* z)}v~%FAWElfCg$cPKKiQGmlhpgs3R=G~v=3vDFgExw zzL9(N!x6@pL`zwZp5J}-C+UY8idU~)ZNX^NrMZSG{>v^BFk1($DarRdvikd8f}Wed z0TAs?@YD-$Eeu*g#q}8FMB#ZLI?(g}5;Y%OCH&fhiMzVzFBjVZ{iX(to zaJ*o^pojBxG0ceNw|2Q6sjYy*gtvDA!mPn{>bs7Z1O;qE7qx+Q1MKil3OCeepM!0m z-yi*fAH|i#knDoTgjm$zp;`bnF!w{oYZF}8S%;|*AzN`rD30w@8$D9`@|pCmKR(vh z`obb?M@D%807e1S+Hn|lKs@qLT#vc>S4?7bOB>G8rhXmEQq4eGMtl7^26qDz)b|Op zdK85TSlkj`+tz%JuqB({i9+oYbN>D83IO9l@Vd=Tj*nF7BtaAk4A=mCJ>vj#hj{Il zOl8*3fbG3XFd&itz!Iaw7Y4vze<}i6h8+VilzBsyAIQLbTWP#1r4uUK-~r$g@!3H> zTSlk?fJ?wdS8*{aypr>&7{L)^jKGqN)P65~m`YXuAYub{KG!pp0IR2j7U^UqXOh7N z$`zOb2k8VP!b+eIoY3^ira}JFAwk`}u7b=8(v4NjBVRhs0@9B8vI6VC1>D!!Gzbl( z0_le#6!!&xcBVfg+0(Ku{ANHoxPXK8uTSxtHvRLbhxg%HtI()*5fq;oObv8(vUY&R z8wgwnQjN4k5X^J9is+637g}D_F^PVzWqJSGhO!L(b1w)dwS5{<+#K z64vi9vAb1-qdcV)#M|yU+~`R$M11uVfIBCXSN(1f#0*MOAr~QAN^MU54Dof%Lcl~{ zho4JL1@qT#4QazgDfF#8yvXGYgU5_zw67D)<7h zRLjUKP&D)8>omUs21}|ag`N*g+{BpA6$b0MG7OeIJ1LUD03J6BoN5B zSUiYT;<&DJ8LR6$^6m!+nYF~7sN+$>F}4--`d_IS#XGIcUOb??0A)A^j1`j!yjorY z7JNWt`N5pjNkyzP7N8ib5lnX-Jq92V7uS* z)tZR5e0_$}T-*^xHK#`OCCM(}YY0?4>s@`T1euW+7!8>JuK)U+f8b;GpkR0(5X;V8 zy77HF%_1X7P;>K#QP|PMPOU~TRIB}HskdtSgrQLZ<~x47lm4(8ET$q#4othmR;rs% z$H6-8e-By>!e$_xpn=h-awTdZiv{j~4TRhbuwfmzYT}$kS+e>vnu(6#h6>OLlP&>EhUL_73!8-*7a${U zp!0W@NiW~^U50c@Dgh~@`7nh@YRQ}v(p7v7P!xS&J6*%*dj#cxD#ZYc4DhC=Woa3~ z*Gt9oNgmW+s2H5A1F)oHTM^I?f4SX?>`>We{w{%jkOctl{pVJkz?+<2L#iYcA{82X zuQ$P+=(xWc35s$$aN~%rZ1xwg3ygm(EW%Pq+Wg7%gcIx;gf8*^87eXx3)tLxAX6Ej zwGp-&6n$3ZjHV=1HOL3oQ7F~xBsUBWtD&xVjJZ#RXs1~JUlr1u188naqVN7ZS?1Q( z78F{ABM+D}C5B`ZtY%=qv*Y4EB&O4LE65vBG3pK-q4Ac%=K@pDam~F4n$oAd>PEKv z2mSf_b#Nu0V+WwKN3@n!dzAWg$eIIfll^j$tN0mj7EGvF)=~Nu@mhW5v+3LbSe6H~ zHAucQRFF~Lui{g9YseRy_5ilsAo(kjmL3|boL;EMvSKX2`bcB$pCS$!SDvQ%;NwIl z@I5r^{~bg$5SLA7ux{JyGk zq&!Y0gy#8i?o2@kZ~0nWkb~>PN)9o6$#;|uh@`>VlEIbhaqRkg#P5#@dAe1Usvnta z)-5NjzkUDsXhTM0f|!;0UaPlOy`E>#=d-!UrHp(CmVUCna#s<6Q+dTGziMrvF&>nS zYo~&;^!dEMtpda>)Fn!uiU}z5#KRifUQ z1+E}r6L&2ySP5>!Qt8jzB;|*{d=6?UE63bCtV^8UuUBlH$-GD%-K{0T$c z#fDBdo;g!Y+>)$k_r;#+`!vqpZ+BrFY-a^po{etfIN^iJYV8-8wCkwgAk?M1w#0Ij zwAeGfN27W?ehSl8F(l>kbt6_FwDGj4nzjck@O>>~e^m-nq`{Y%)qcHG_NCcJ(pBq; z(z?339GNzs*8)@QAm zs`3404*v{rqR0-ck;VkW^7B{3}6UJh^SfdhUDI6`Sq%FHpsx&&C=fJ|1Ub+bsm8ar7hG-7L}J z3BY?weeX3xm8$vl1dtso@cpS(xuy68XwzRZLrb5D(U%k}j(vtiX#SL;i`di_u-*j;)eU3=N(>T~ImCbf z0U@>bf1C%by*|%+9O4Gg2!~61AD|83D9h$HSl(7NoAx8U1THCVQ{9 zBQT4tuHz{|MXnCQOZz)3Cy*xmy7jl&HUd_njj3;Z2!RG-HwZH#=s4Z>=&FRkbv%?F z=+%*;>m&OB6d@Vtukr>x8wWZPUf3&84ap}W1~_~oC2<%8%{4HdrP^=OCKR0wXsSD$ z;mPPbuZ(@ts`*z93_5sUKauPhWN5X2{X&GwOBsUjVw4gCDpMyC_l;_us9LE_XhhPd zDPK`Kqb;U8$`4ln$q-=CKt$0I(07*rBE*8~m?%|zS!nF*Zb~Ic#Y1{tL_U-qAY@vb zVr3Ppy@VQivC>^?PQPkI>I9I6M6F6|4>?mgk1y7vY?z_5dqP8=h4q4{15&H?r$F`| z0>Lbr#)#Z5Fk6;1?k5PR3TND*m$$KvpZ+?m?_lrPNdr z;AP@RoBd=rsW%WM5R{`Hd%RPhEE@}Y48UwVnMM7#i#&+}v9m`2P-G+SY_QUg!z3f> zbbJ!$T2^}>A6k7AL@?%e%-hC=pxTQuootuYAuJHqlFw?xIVfc%Lm=4}w9uG{NkJEf zq30-aTO6_haV=l>@-cuy@cKVpTf>6v{ORUis)VNWtq0g@0Dxw3&5wVNRXB~HVf}4{ z-+aUki2AD3;a#*Z{4#ReiD!O8mOZ`_f~m!Y+t)TI?D&ip_Be8jRYaM)xOWcTT;z@= zE37K|R2aGgv;s!E1hTPi5^ai>|RRsKRXa3-^% zS`b&$QuuKoN+q^MnQO==E;WfSgAZ!_K>HzF4|8g3(ZDC6_7tk$+JkPgkJLy=96 z3?rcqMAzT!0Y(_{noe0$5&ykU^Q*HFpgP~JY<-jRtxJigIHi9=-*ckid?jlK1Z?ln zv+=S}C+up_b)A1bC{$>fGjgUDS+5Y8+L#o+j&Ew4emyC6<8TtTwiY^YS~y=TeRU*WBDgWhj=co=P53kzbf&Tdcum^GuRekX%6 z1!oy}X!;(i$ml7kwXtnARh*6v2J+T}c~cm!0Avcu?yo8qR0>MAr*h65AF@StYFY(Eu-Pza*M7#%_%z4*{ z8yH%3)MVO_7rCXYA>iF0JQXJV>z~K_fRlN3qcY|voJ~8r zr5~XXy9UGM#K-byWuk=N8R~99#TWZrG>YlQ6P!hBZI8c$^m==~EV{1Hc^B4PQd@jp z+Y^(*_5D?B=qTp`#E4}oIS#n}*dcDEG@@McJSxM{apDLK63 zktI6Lz0xBGxz=iqE1(3YB=@uzsZ%j%u>W957;!U#Zfzc zazDCv-*p;CWAonIe4g&vR55%cH)P>0$o0IV=^F^XOti^Evzw0tU9x_sk96fp6bPOY z`CQ=qcJm1~Tp;pkTt#7^czS(8J72hDyV15|$NQPmj3y0RFGHI?mxX$hMwMt?#L_s5 zeO%M~gf@Cycv#1Y#g2;N=BPWAT}EI(>@BmIZS1j8w{Tmt@1$ukFJiY>K%^JxUu%^* z=1uF(Dad&kV`ih0XN~R<1%F9}gPX?HM}*ZU(kGtJivh=j&3Kx8!G0lcK5psP((j=< z5kh6tlCukX2ZhV>M{2}^l^|EfUA}M%#`)KDS4B2ditWd+K^+0Fa`JR`VPQptS{4Q} zA8x)B74Z|EuX#=*V5@SWIZo2gN;vI2Mcgw~C$|{0 zC`=>&?l81gkSu3YWpTwSQPiyWik~S9AgkXKNbFghu~$(re1cUUDk1;Wv8E~AYd>h^ zA>q3IqSyX`T`mipsyX`;-=}iCpZS3Ji}=pJkokpx_n*adDV>NETF93escXtZVZi(_ z>XhtoI}zlI=X=HSe^S4IBP)@#$Z-~M@Knf{OP)$KM4DOF2!L{*9uxT9JZduMHyECZ z2mXTQ7DS+N$Rb9%C+D4#R_6Bg&|2y^OPh|05$xl1XX#INq3@=Nd!?r;@iV1PB>(;7LaxuCcggk%MN?xtVL+q^&-R+OYRZ^q2G(syLa6iW=0_rlgKAWxy;yS0fjRK_#EtUM@K^Ay<;XWn!wSS ze$%kg!sR5$8Y@snN|gaF$}y}sD~;=zA9Xy-mcgu5msIkNr+yds6l;u@?3Xm9%65%C zI-P`^OJ41Y%}wl!cI=PtRTMecNW+HMd!c_|o%ChuM^@EVyCq;im*KM|?UvcLbfcC> z;VeQ2PLeaF!&ueq1Qyx!C2Zn0z?d;O1(iisruK)`=XaF=aJY%Gy2Y;Qw<5L>^#*t) z#<*kOY0*mQ%)$IwkPcP9lxO*~<+Fa*0|3{KLHa2i^@=x65RYRq);00?+FlwX^-YbH z*rKbpZRzneYKn~yH75?U-;?^BHdQg<(-8oM1e8w8O4NNEyOqxYI5s8DJzrWXvSrU& z;qrNBK7jU_t3odUYN%uFPdL7j80+b)csmt?2obv+B_b=~6x0aFTl6#GI*JhV8pBp) zxq5RC{G}?W*(@C0KNqaBsNsVg!V#Ib2cfIXu{b6p4P`Dya^t6;DZ~sEKkQFqlsXQ` z(*$3HaS{wLyk6@xfIk*i{jNJF@Y3^mUZ7TcWf64 zcJhWEfJBPw`Ao$D;s&sgVUe)wtp_$DNBdhG{T77^ayBz8Ls@k!_N=2w!)$&h#}+n@ z&T`4%)YRLJA52qx5o+g3WWSW2nUE~oz)!Lc#|O|R_ek7l&%*hnhwD!25f|*&R{`N- z<&5^KsuW5ko-bP)iGEe*=ZV&i(ZI=h=_R%CGXZ#R1^ss)6yYcluAAaS_Wa~7vY>aB z7$wPyL_TeeC!0~c6iY=_xbLwY-tqB{KB2E4m#=^M`DwE00q-%Q!Ki%%&Ck!2$E-Z| zyohzAGDRwa%pK*s`E&p?w>Mhl6tplOa&YWO-vJ0sC+=?l&2K+dM|xY?!>u^+xNSil zyf^GEhxDGkD3DT0*UePWb-9Go9A2$QDI_gHpq(D}r1r&D$15#lsUzOp-f1b2ng(n2 zg!iJPyK}f3IA7i)Bf8Zc=>8ger$Ct%!+H$0*C>uN4D=us52Fk$v)Ww0-(EB>l>pGA zxVrsWi`ky05d+2G@n(ipbj%~OPGC;adi$_sDSFle>Ridzn4^3ms`tTB+$N3+&6lZ1 z+qe>`6(6^wN2YfOeo~s5>b{*7uK;eBS9gne@sGz!W{rO+FC}U!v!ZV+sL)jL1~j#I z&Ty4u?&gE2UP^T%p#(%4=4cj6!1YNmMjC;ES;|OR@vr~})X9Ae!bsL$QIqnE_w4OU}QJpBC@|netk1O{#U`%r~0EJJ!<3blu`1@yIKd_Rp zf4s%m)gv;OIB<#zPM3j}X>!X+k(LPibT9T8!qZGH%>qnmMW##-iZJ)GP*j)eL@`%? zA$l>ArkbFs+~RqI)s7dA%89(X9OsTv5M-yxV;_fMqR1w1z;Kf>uB z0~pv7?7EG%#|*(f#}4?+#+gxGc6*w4)TdHp752D8?Jb325e`mO7K8}mGZQ5nuZpkU z=UU?iY8xO*EwWt-4u!-T56-SsV*U_<3c~mLIHiulA8W|JQ~OG>;&(7gz}96708vA9 zu^F4rNNG&DA_Oj`4|6!Ogv~_xBxQvo)`jZ!6mIFaWQ-tC12^xtU6jINY&u%>u2`y| zXse`0pkJOn7s<&0l|y@&t#ICL5R2Xjs5pAA;4$|VNWZArg~%3)+#OJE*GKVON6`xP z&5fFBlnKwL2jnZT1A|4GE!l0m*IKOZ5f3e<|B_Wi4mRR=XUL-9ET53sEiEnO<9`Y- zcJ%*|i(@opg%lP(FVA)A@+;SL2t4gY>C0ch9K;A>rP_N^u5 znE&30`i4Jt5N{~78qS;9F`z|<2uvk7Kb0#C7%9pBqR865IznYc@W>n~VIvx2DBiOf z5KL(Kr-TpgwCe3=MDqltUuJ)pZlh4UJ|1~`UY@WNGNiK7rN`hksyd}$iq^3ccT{^@ zu%pYt=hK}$fc1lb5%ar@`=Z@G@t$6AHkkZg>xk~2YKL*xgnKJ7<0ljDzEy4%+j6 z2P%J9p>u4ejHMfe&_QdxeTE4&-zwI8N?B8|q_Ra;N(@#bn-sMx+i=KaOIBje>4{iOx1BZ*%&=oS)7?u8(=m zmgAd1!3f+!DcKI^L4vaMK6e9^ZfLUGKBeaT=qE@da}aPEN1D)ShPkGZJiH(!@gWy# zZVeXOZPurzol7P}4HW2o|7x0Q&SV1e!H=83)XPvmtgDPND5T*te(FKL+jK&{9>ugZ zy8dProKlngkQJBHh(cSI`Ze)_ePtTuD_xMO6}exZy-_ysqJuH`HTyFUil3=juGFt^ z`ui4U(%tL+Nv#4;t9$l>Kya?$5~|11D#HQg4m{u$55aRhxWxPvxXiI7nm^W2@n}@= zidzY(1#mys-E5#O(0ly37AZl zZ{JgoW;`D;qIt|ylNh2M>|F1sQEI=S60;J1J)Aa^fP+@kxYwaHDA&~ zl9WU>S?f>XMiJEwu(X)Zx`A_kDZE+D5af3Dy(^4Mv@?PKr3FZe_m-KTce%coG+JqA zY-H72HbNFp3@nU6iX}{VHeO@>LhSkg_ZtPu4(0euBKAM)vzC`>|vyAFRPe5JwR&<{})fczX^`eN2fEdci6%B=-P? z7^06V3>uim->-Wv`}z9wzSZMMci-yg9JL6zxbb3@hy)&TYEd6-lA%EU%%`dn&a!U_3RN!Ty}+S=$r~3;BXC$@GaBnFwO5#@RQJoze6!x{y;PYGuctc+3iHnj z*aH5{Daf%3hq4h3Fx9_;$jn-exY1KOYLt#YUiwEj$_@bEo&Xo>h40_xX){W9U=R=B zsOb;)PST5<&*U>fq@6|1p2HdLAlYf~o{6xqAS%-zf=KIU@qEMJaUc>(qR;M5-HEQe zAmSM#hoK?A`3upplaZ$#SS+w6SgXiqvUYWo*pE51Du7SdJk9$n#Hi(xZ}wU&&$(+C zS9)vc^vpwhv%TFml}Z^3e$pAOZ?c)WB&Q$5*3pj)9ahXSiYgI`cBdYk+&elW=Is-J`gzswUHI2G&J4+ z;+AK}aC6k1=bMRgyF@#`gCM|skl#&=br-4N0vj>lcQdm~+fDy#nAYFESHli}=9ub> zZuvKA=o=1*r3~nL=(lo(&U<#PJ)%l7>#Xp9e75SZOFB z$~d7NIWL5q$$?Xu9?&;iY#YHuovY&?gC}J zgD#}*zOSy9F#FM;0m%!`#arbe3A_1r{O+ZbX*MZMpzR1C1+Y}+dfjcL4P0A+wBt*h z5XDAP4SOmb2ka-Ne|4eJmJ|f2GBc;y=NRB*`pAOyIBa3e?4Wv#PyE?VI5z}h;v7_m zRUk^n#zkGWg(10arbR^S_O_iK&X!=??X)wqH;$4jTC9t|{oc4jdz>U9bF!75ZztjM ztvN}lZhi5!QZ1*e1(^PF6EE3(TNOaZk{{_{`B1$f|!q6zU?Bz#8ya{ z)~-CC>1Ny29v+l|3bWRSYtB@W_XK$B-`K3oTh$y*S1FX)VV92@97|Qj|5SN`m8IEJ^Pzbcd}EvQ**lc zU8VIFM3t@b;T`@KkBAm}9i+%(YY+?B0h|}JWJhe zaw2`3Y#>+$EIcgBtXJzc`_Q25??8}Auaw!MV#HKvqQ$}W--(TVkWV?KqOYHN?Ql$6 z-%tGa!E;{w)#01lZZXcs-uE4*WFPH}xld2dIWveCmmg1FIjQ_SMSbpa)vxlGZc_b| zD`m%oGbY0l11}>yS`z)5zV_@761^7u#&0am>?~|c`e7BYs z#q^xDTc>vDe1DfRb?+%<1XzfmivNa8Wx6oEn5%rP-1Dyh>>*txhf<8@yN+x67)7!n z(9c{kv*s^#_!7eANVc6%j3p*r&};Cy21`|G;UPp4s@9Iu_*Rg}*6C6t!Vc;K!l(H7 zF24ZKYX`eK4Xbs>V=41Wi{*|)^P}v2dSsoSky}$uZ4j-_{HI-K+-m7FPy-BsJW-iRm|Ub%4Nw0 z19|rAzBio)E73hh*Hr}CWaJ@^8l%|X0}olq8cXj%S{9<6CWZfqyJfGgtZqw-Ja2|P z6=+<2(}1`HJb9t-i?zk}z+8%7@|YLWBM2o(Cse(yNFv@)GGaO$T*RMXV}`B~Fm0=n zrtOrRiVfR8)L3(EYWt+xYWUtF@Kmjo<4M;O(_x#U*-QluhQ5-G;pWK-GlrveuM;;( zpZPN~C)WP?O1$#@HoH5%y;~c{Cw@0!(>kO{%dC`64)0tQ^PKxr`?zos(s*{B#f`oR zA!EM8e7}(S8YP*;-IZDXuc%A)sh>zMeOHHcwl2fL=6wIY@eI#Rmg#F0H_7-#HXu?= z2T1@&JaivH2%|8czkYeRsT-VQi0B)BX<|Q<5vj@HjxE>*0h@y+@5_-M6Y_8io5`%@ za-}jLE0$Hrnx!3$#kBKQm32Jij zHta`NXasJf^{m1AfI|@ZVhbSiD2@&@iTwvbNe}SGiitNK*3AHxT z+M-T}cuhiy`P(wjVII*QKi$Xnzg_js@GLt=`wwRl7!EuMKIIi~|Ix}{0sz>T2ygrDrJ-=5IgL>XWb1>stOhs02AX4C_a2goN|kS{`5+5Vg2?P>W3Hsy!9{m$r5c6-;ySPytT zO46ulcl6MOAz4GLj>9bjp7WqXiXtaBD!Lfj=ifqws@YAq)9%G~)8)GkDHU~Q^4M}{ z-LtoMz)jKDmi1+}Fi|MjiCX;7u36-+shWaj;5%FWv92mrm2Xud<8qSO5HB>3isghi z4n(gyM!&VK`b7Q^k4fT*7yE-Ut2ti3laZ4;8RyxK(=$=}J$yrM?`g}b5fF0b#)n*_ zq5|#GhqE2IO0Lj8O$y;CO-Af_#7YI5bM@I<<`%@oxAOZHz_u>nf?IECYj2TmOv<)& zf+q&?{FzBtH|{z*KHMg3r|u1TrlSRoI=LDFmJY<4sxDB~)TQ&1=CY`fol>jv7<@~( zor2h_GxzcRyWIL&H>jp~{uG#5WAdIuao2m- zd)K=6{&Chi|Fx3c{yo3v_kF(K&-4p4m@R@P434?M;;@q)5Tv=omr&>4rc`f6FBSfB za#|hjna?z_x#Mt5VI2FI`0$>-sQ>B^eY>Q%sPpFU*YS6jZ`0AUrt5z$g7TL<)|FEw z%H;)ab(t3rn5%DgT)iy9vyXk-ChlHtk|&|LLmkd~X3iIvu`LZ5{xtT!UVlu#Uo64E zVHVOn?@GDS6-&-B@4C7VVm6AslX|K(k6o-?5OAkM(0Ao%l@Q~}gM{IxtDfq;O$Qdg zR-Y`lT*4fBa&2VFnaT;CQmZa7$R8AH?kY76Ip%OdZh^BZXZFjs$|uA1-o6$N7v=(r zMi}Wg)w)|_S2txeFLte;o3VYqR>pd!Yp+ml$FJ)Ctv1y{9#b84>&Kg&_E2Xc3PO(g zJ(f$Ke$cCS)%(NAy2DlR{HcR2r>f5_qC~DR0|c@m^hD@B0>DW<>E}1H%<~&HO8&mTa6U24F5*^6ANfr z{vCM1cBpoVkb#qOR`i~BV@_ZR$M=bnb|J}>+5Z$%WE_Scb|#-IQcsBPCe*DXUTMD- zQR%>>-3h>TpAHe49)0im;^$wIYGS*lk%$zBkTo=$q`A~z-U00SFgU=SjCBX3j)+{i3k-q zDu{8bNuBXCA0J#nkrhuR1K^8rLdJY+dkSMC?fc5A=&(_Fc~+sW>`+&0;zlJRZPa=` zexX8+43$GwGx0ivwm_Uwv|D8KFd5^>`6I`kX7^6)FKM^dCs z-Wxd*-wxbzgSU1s6&G{S+U0q-ypg$Jy~iQyG>*Pk^Qi+33}Sh+*OYjTLS{r~_Y>iXHyxKnP2LU>&v zx{6oDvy9$2Re4R?o0wKtS}6rY56tCumh_|;=XLn~vWg-b^iHo~bt)yCHm)bSu$r)1jW=uJzZS*rchEzQU@N;p4Xi5|C@rfGL?phfiPp ziuF=P^Wg#6ewohhD|}^JL)OWrMJwjrUQ#|e+<>r$27KP+BsI3Z>P6$tF7vdKbS`Mv zDomn(#K;BR14nH{H@%El7v-XNwQ`MTv&Bx>-k9ZlJaI77RCi^Mnul>$kagQdSEHqE zL8`C0WkrZH#F4|>mbmd&A9_n7BM{&<;J1J}ry7DMHf^y%j@;(Gh2PU}55}2Y7ckU~ z!l%Rxro^tAbUs|4)@CY3`KPUet^`7%tL6u>vmD++QfXtxM=y()e$BEFocW${XKIJq zqt-iCSI!;Lyt(*Mpw8iJ?DvqE!`|E!>DHzLDPgK^S`1s-N|MMf1#2 zb(aqi+!411wz7bS3P@U*o9r-A5QU~(_jN&44n#h!fRwQFR3MKeipH^808&vscI+zb zmTkD|ClGp-l?m(s#EGh~QJ@>WbRR}mi&%mu0tpc9pN2$<-Z%&i_nuzj35B$~dV*AT zI9VNOaFcvlpbX5?Bfk*L045`wNuE)nm$TavHA}Kq3(ZM# ztZu3^URHzNF`mVv?u{mgl~aIcaJ>7i*mEc}st70o{2DQ#sjy8AJ<^|<#r2Y!p7`6| zaiS%M=8vS`VHXgQ;}hHi03==(dh^P=36VC^00JRa-rgkgs7Ql#CE9GJNB}J5U!cDP z(>n!Mk5mS6Sgy;GT(ZTsjH9p}vL;&wiCToEz3i>it~i=%h%Gu&BU1c5jP4Qy!!-`Y5-gw;hXuseV@t~P!*K02-~#BDLQT8do5 z(9xr3J^MCq_f-whQ*B^JC>T3G|4y~ZzcJ%7uSNq0M~>m9qa1Dhau|T4_>kd5bURSZ z?fa<(qJmKM&@=}bk>!AUD+mtC&!k^qW~{S~_G=-=hk%hgLa3KUJ|;H~T0H7vnCkZc z@LPYCPoTEXvz}yGV$gj8qfkFYdufuQoj+V1w^VK?-EI#Iv98!NfaX;F+WtetBT?}+ zvMVJG+SDGj&#?*q?Uz@Jo|z-5JMcUJU<`V3lQFqbTRzPbp@#c5Dn1nz<7M|aomr({ zP`u5`ebsUF*?OKM2%=?Z{0a&SiHMJo*Lmhe^#i-E zSE%b(H9-tQmscKrrAhrwn^#3!MsI)8_Rd?oiKL9rcB9njK_bU&EP+`C8jW!wP#JmZ zw^gzgAFKF4j+ZOrt2~gN*xp;CT^RHhf_~??opEi+qIN10d4eRP$Cfv+MBTuFafD30;&q)@kjr|-~6mwY^a`VtcK$1zQya^fl z4yI^Z&yim2$Xj*{CfjloGe$g5oX3&9cZ8>{IY=30HTv%hno78|R3cyf z{*H~*%jIF5+NO636^c`U^o50J~(;8z+O%6 zft_JSmP76DY<|wMfe4y!0`*+7cE#Igx|Y;#G_JZvNgJ5$onuZWOCt&KP1TXOUnQ-e zR7)#J9cq`$(?UiK1ei|p^6TqxZ`iB7u#-6H^->`RPiYj$i=D0g9CnAEPvm2wmuK#W zoC~y-Nr@;g1(%{kOb76g0xpvgSrca1!gGZvA1ZgCfwi#v?XrV`=m3LD<>^1VH$6=j zjQ`0bxkZ%Z#*m^4gm4gWe2=fV)O(Bld|MXiKhvY`TURdWt7GgcZ@tN&gT>kxDH!S{ z8X?L`5v(s~T?dA!xl1Q+%gdgm$vrPHUT@x+&U(1KK3mFokRbQQm)KR}#SqSQ()DfS zO?%R{p0E5pZQ=vikFG+{jW%)b^G@{v&M?zv@-MP^96aJ&dsr>IjMWjsqGzX)9IFcw zKWE0US+m;r<>uus>3g4HnrI~_o%`59D_gxL)ai(tSX~>(-fL%hT;o{Y*3;aNQdxR- z_VvofFI$$F2*r=pOEV9SRM}rX^>M?!(anMsQB|@;L>|AL)X`iyIQU!^MW#JuooSe0 z2IuzbC}?C+XGNW}E$yc3pUj3A7h*BR_6g4?4{2^hwBO?VpcQ9^8&q0FUxi!F>VDfI zXqy5&;_S|NO5)T_`{}q+A#ly>*!m^e3{leml3o|`h!Blj= zF+bx=B4;WmH)V85c~_1wzaWxE#NRx@m`Qwx@@^7z)CMYbDs{T4C#lY9S&p!ej8YG0 z>LZKqW>Hj?G`U(46lc-&RC{~B_aD!g%IoF1#N;n z;yr4$)0iTTB5#yt>lk69sMvoUL_`rv2SJKle9(l|U9#ZzgV#LbA55{scDR{5^ zyo%vgYC)4U56gI!9B$Z>H|=s-r{Hm!#H7+bR#Es+$lM~flZD)tPu8iOH!*n!!FnI8@2tn7>e}@>Pj(OtzA*Ox9TpS$^eRr)A z?Kf5ZfSSxUY`5595JpRK@Wufyx>kgX$bLvzU9GtZ^Ux#oK4MEm{fJ<{?7d*so#!ETddn%)z*(URNZ??rAF z`=a5l!D@{6Uh5l`;c@uW2vMG)JlIa7BRq0l2P-=yQET5_f~P1Ydn|Hg7}f=grY{ zAkiS8qE~`G)*~{Ui{+?p%u`6Re@VQ)*itelwl0*lc~oNf7?~V_sXUQeCA2;Z z#{>p)eB+3qKTh;1qdAmn9i3=PK_eJCT%BO3YBi2!cJO5#IJ*;Rn%%er0Q)wAqc zqP2LyPT=_y3z%k~x@cMJ+f;Mry8vDOc&+{NGqn{&NNnSxHa}OoC>D)WgtzCS$v4LFtNU^RXgGF4BTkAyp=_H+6B&nsI{%%(oU7q z7-$#TU@+Y0pm#yN+3@2>ad?V}Usp6cNju2RJ|KDoQcWjd4636wPIF$IIIUs)IvA9GzX*-xyx! zIt)8mg4>OK^YU3k3u{$NIAXV)Vup8i7u_q`{;at+J>=%MzH8G`Nk;VPxA&vA_LCP5 zJk5Xk`X5~V$IK;LIoy$VS&t)*AM!KR*Q`h>p}DrxmyM_KQ)jPO^LM?j6W*oUu-c%s zcVMS@Gxa;oU613#Ikql>u9TeHwX#0M9A$Z76y7RePao#It&$^KC5 zj@AkR%b*3$PrO|ueb@$<5}h5!xiY_ld^c$E#q(D#RN;J*d(r-maK^POdX8fTzB)7X zZsz(O7OEL`4d=Qvd(~@dCEPo6Lj6rGX*%YLkC`>)nXdLLr9xcoO-u5%bGuoV6w!@b zYtCzT0LuUhbkhryVH4|_p@QmJ%`EO>VFY?7e3)d&T$pbs1pF+=N}?F~9e<{h{g2;4 z@xS;TS${&bl9Zsj`Q*gr`qm$f0dke|@90D`5C{*c_)%qyrQ8YXJ@r|wFijoSRfBLy z9mX)i2x8V-O__6qXZ0#_+|j$&!zy~RmgvGr9Lf2yROlm?kVGOhZCwx{-Wge}yOHDN z0b5%Z1Qr1{7VNo}Qi!;48tF z-Ti?){#dz=G0@JYlf22td@hk}fW?%Om*xo&MveEL7jGdkViZ|vqHy|dy->HkP>(C; zGVvYGo6om}l6;)_hPjF#{5OlQTg7{*Zt7MPmK>+UQfYbX$N5yX))+SfFWaW zm91dFp(I&NDNcf{r}&JHBCvyBnAcI;)k#p-$?fC;0Q>$DaN1kzX{gh@5H^qr*(f<# z@m`QcQ2Xd606`wW_?)Khxz2(lR^kJB$mVRdx2{Rp38Lg2ev9lU7^i2@*!sgM_Z|(m z`zLTsu#J2LzEEWbyVbWJHu%E`8u$iMA>4>q(x?TQ>%AJg zx$)wOb4`2YlQT12kZ-pC*Z!7=!vwblAKZb+s;_9#EXZ{9BhD0p6#$w6P_p5WU_-o{ zYgWBqGGpWt_-WYKh$Qp|L^Z?^uXi*DbDX)`Q(j_TW)Sx~zKe7_*UN#etu(;v2@W_)Wu*JNQBfxdK;B9HrbW312`c;m^e@ z-wT)>W0nn32g6H~*eg2Hd4O z%4;y+snTGTH-+qBG3EGnfJCwvNV8R<{PeDXs=Kx7Ew-C;Cw!Mf%+_hq%dJVDj+{LR z*;A7aQiZaXEJL0M;0o-wT)BnCK7uN4$06w}mrIjKxnf44bZArNpLczZ`R*K=5;98( zkjXxHuT}@Zs_jAEfN%sX@Xic!8Tguo)DEcrmU|P0|D79ZcLfy)1qZI5mm(X76v#+T zvVcSZa4;TMg58wE3L-)%?Cvd%xyXj@s`obNma>e-A#1>Yz}!3B@MzIs&5wEZxUG%^ZC{sx?TYZ2G*GWU!~3Tpq>C-p^2kdd`zY^?E(~+Pbv^4@}duu+io#5 ze=UA&o@BTG{3)*U4oq{r#VgYA-Ox_i{!w8%@@jT1UJvvfKj{WAW{Rf*pRPqZdU$S~ zV1ARPB+HU$J9rQeQEKMHh|MH{Gf+s79<;Z&=rp%MVBz|%mKLHYQHJfBno43I;Nl1W z=ZY=AMKqTfd4YKO=RO4^YmAgGTI7i4o;1l{uE^QPfqfwRx77^A|*!mH~(+>e) zj@7@5fBd%*OQ|x5_CXhi3=gCP8Gob>e>SG%7?AiN&%TCJ$S%Rj!pN^G^jSse4# zxJp87FX|l0Wm>ZCD5d6Np*B%oxkmD87QiXhZ;9~)u9bhxo2Mx0dJ~f4*QPdHH6QEX zw@yVI6HX-(wFIyd-r00Y+d5DJI{mOK{E^ml zXi_<=y?CzHQ%RtwEqF<@_S7B;~&(cK+L2_V@4n%cRr4*X=L7 zkrEJhyXcRsxu)jZzBJr17gG;pwpHe9E&XRWu9!H)0>s`U$-}?V00`M=llmC+Y rU{(mv+}zywpn4kqFrPey^C+hi&P}TIeh{ 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ɱBbRlq>8ia^ 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 NQ$2YkS>>GPzX3NHujBv# 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 0000000000000000000000000000000000000000..07901801c793f90abfb0ca240055308753d3e74e GIT binary patch literal 114469 zcmcG$WmHvb_%BL#BPB>IT0l}lN>Wfl45T{+l#=d{E>T)gKu~E=S~{f>1qA7o?uPR$ zb^p)#aPR$c#~EV}_tFJx&H2Xj{OX-ychznY;$6l=K|vu@R+7Jmf`SPz(fV=F;D0w6 zZlj{0kf12b%RF#PTm6Ea^u1-a{PnxCI~!l{m5Od}s6M!H5A%}z&6{`c-Xyt1B7X@# zKq>y}!yC8bCf>PbA54s);d>oybX!jBNY^admp3LX))^J0b(#q%vkC5W$E%o_e|_0W zCX2%qL;`dLFF*s&Z;4lMQ13-GRrHjv6lFzCCyP#q;2rT^TD}T4?ce7h#8)79SsQ5(XKkRG4&Ec?HnOQ&Pxm9L^%hW`OYs3`9PR61{!mLO@ zQx9L+;xx7Xt463ss4js7;T4NQDeKnM*Qfsb5&Wv#lm6AgLml{N88j`N>99(NSt*a* z4&<1%I59CY`n3(i6%wal+<`Zf_)T@-jUYDz3Gd?vQAH%vAsEHTkwspumH+?CYnkqr zFmhd{Op9$6p23`BKBwb0>w61(Q4G@0`=(FM4eYdp+QukMpZN$rnJq2ZvDgh`Xusig z{y~IRr>O1VLjt2kU2+Rh@Onj&Pj+8NKjE^FYi%vvt#v=F&Rx3wT2EMUrc zFZ;cI&3>KtiEQzH@?1w8GfYFS+UwFLoEj|{$EEO$j7gYoZYFtAk{8-6Bdj)snxwT8-UTIpB*wyJDRM!v$5r}_4!^BH!k?R&D7Bj5#LdPar9r_S|C4l~X zf9$EoA%pj>M&dPnFE1|@x34cbJ@;3Y&05lHNI352sAS5A_)4*NpGn0#q_S_@oDNAiZ zkCDx?iWy;!l@+Rsi`ADI82gWxtjiCmd*WO2l7tjbFTS}>FJ7RfvX{WUM$WWGz{tA4 zLZ>{m#$-6{EH&?H3nOnom9Ipo<=+jAb35Fe&Q-rSm{Km(t2#eBFyzXdIf|WajTp{U zh>*TG{U)~g%XRYXWTPINnErG*H-k+n_KnWx#|g8sF#n$|dM=MG6VjQ4+)|n4$DlFY z#GD-AHa)!9Y$irUm5~;jtNr1gN2P$ln*i%uKAUZ11EoF=VZArR`KzEH{YES8qe9+V z56Mu%h}QQ8Lg{0K5pCXCBU!<^85kvnW~Y@!)z2(u>RxR6bk_W6Til(?CTXIbX^ZmQ zo>MOCmR%pO%BWIqi=^4|jAvJ`dpcR?FxzH)*>```G6?E|=WJUPyIPvnM6H-V3~us` zXTLw&3?EjRwBnh@KKc2{dVh74?Fx%wYdFQ$?%gVKcC}jX6JdJC&KIn4GcgR(<#fNB z67F%6ufK@**!#^SPC9XNu#q8P{xuMr7^nLsC;9YfnPok^RqMXvvN~ccmfmGI^j@Rd zdHI@QoeGxA#>Co-OQVM{7GW;%Rs5!HBt4EJv#P1Wt@AZdphC*61`ueY`tfY4+lzgD z+X+0n7Ck8sEA5RJXZF&CvI3 z@25=>k9KL$TMU-1)(9%nZZ?Z4k}|PtT#sYt9~pXoe*FET^V_X4PRez#qU5}<29^H%Nnb3 z<$aVqI8kbTr`v$Y>HPHQ5*BgmJ!D-pmMy+A6|PL)9zE@nC(3WCsY1DXrJ}0xK zJKENh@Or>C#{ofDMvA9i7iR}c4D!~g!ge?;I>Uwf2dkqyo$MJ^zW%0JxCRg^u__YPnv<>1mkO3AZzix zcej@^(9gQjRHw#5^-|J;FTaGz~p2G57{rMUH4o%e0 zVq>|kt)nHPD>tCxT{)|FoWK1t+xs$l?o^sT^xBK_lZ`Zfp=sFr8!t*8e7N_D>(LV^ z)!Ft?<+CONYj32{oURq|N(az+WSE2)=2+kWk|ATthAI&G*33kQBd8nUKu7c=!zUnR|T9xJz7 zz*KsWc|=P>;%CcO9i6f8L0=xfcdjQ@q~vF; zRThO^p?3W#fxos*z(+I<)3-&TOmh~dC7Z`69v9&Y(|mSvpy6Ru?6B?huCp=T2^I$LxNo7+oD+GAk%4+z#2LM&*kx|o#%wK z+#$XZ`*IxB-9KC3im7|G3j7}Od48Ha5(W+~5wkbPlH=IeOaGFWu zFT|JPnTf@DNzQE;Z#$J|`jsHPo>QTBF=*SbrdPn{q{OtHTA+Bi+P1Y4eYi_c6EDfZ zs>H7BB80}U*umBBRKMJIG>-Y!-MyE}o-ultuhHa2F#jGdV!$@9;zXA5ARMk;8Wwy=3%7N7mP{XZm}o!?y}5CS)rS#iW^RDK&YsL@#DO!m=k)wR!Oj zJJD!op@+IW5^ZXRh(@5rk;7O~Sp|d0?)T6K7cW5568S^K)91={UM zKM^!>`;tq@jz~>ri+Z~8%M`fMoty9y>PBuZgSME2-C;eBY-p&A z5!F!YLncPpHg1uMZaB5f5r zj*gE_u4I`-j#@FMB~kwbaMu`X#^YfNO+SH(geEHdfpD393GWUim%y`dbh-znJ$=wc zYVKetC$Qd0VDLR1aC|Rs#`L%hYWC92o$7qjiDUtDywkKYanTI3w%QL}nrSyZDeyA< zQMQ*)TS{L$xvoBIz%@&nnvIb@hsKwYjP`g3cGO76x`5`p+ph>{1g5q!KPi8-87^e2 z@Gpv%)FPqQn_1{db;|7jKHDBsArZh~H(JVSjy3jUZl4!hM$yI9&&o~FAabPb_hPl} zXlZLp?#P=X{7W306P_De@dL>X|9XT{F@t})@6pdQlnTSiFH|HqgBTz294-!=VL!=O zu3o$d!jUQTD4kv>zi3|s8vX{3yVc|D4Gh*g;R6z!}oB2y`7cWb8K2jCG4?Lw8d zo)owEd8QeO&O)3ixAso)x}c0vmeUAk%II90q)xPJjLp#d#-^>=_An#Ib;k~^rG4scOCew2U+xP<(qmbcs+XgFc6EdqgL#(mqM5L-N2+zZ7yxPg%3WEt>?}2NnjID z-wa5@7?CtK5lf z4%Lw&oRa4!w5sSrr^Vj=TdC5AQ-OeYUSt6DB?)z3FZ>Hu^Kr`2YmeIsf~l^Fbd&79 z6n5&>N67Zmu6tM8m-#k3%G5JSST=hjFsi=(+?#5AuDivBdG>A!+vW*bBbltc*j-iM z^CNayOpOC*H-Nt{`wO{k8tI9zie??~xeZ(m>U%Aos7gWxYv>sI(!e{MVZc*bM4SQ6 zEnnrBywUxfk){s=)83I zg~IfBo%dMR?D2F6!_RU%n}xcc)z4O7|7#yEX87XdYZhF-9YdF*?Dv|OM&wo2P3hTZ zsfh}NilU>$OtVa`zlXx~n8tn-=-w(dSok_hdke)WQ|fH@zJyEkI`nK~z5xBQ2IYo4 z`QYAULCcHtv-Nw-0_Y?hmI7^yQjRBmEvA zz1F*E3J%A+g!lB7|I-4NVY`=<4dke%!rEk99nRNoe#Uz*TPgP6ehF63QAy4>dckUt z7RS5Tk<4MtoxA)7JzYY~e&KQc&pp+> z*qs_AJg8Bj6>JOPoUz#6b4a;$Y~AoJgnb>;%V~76nhl>6QQI&e@a>j6ZZ%Ob<)NL{ z5cm4KgXh_TN~)e}jAdy&y5;+8W3C$$R~*b+UO0K7#rZDmC}8b$@Ut~hOIJWAUR&1+6qjG3`T?K>mGETl!xRLI!Xx(6`19zh2% zec3p#TT@q8SFXRD*4fZz9UE(W&pu(rAZKcE(ef#UsA+cRf`4upp4T((cIFxX$s8ZH zUR~qtTxehCjxR(i=@{2+fmI9_89vqImuRH!fp*xfvlzIi2gqaq+p|&=hjbyxj4Yr0 zoY-)zq#iiAc|2QhA|EP~=hI1T&$ES@JdyO0>DJuq^AUu1A$36k;D=sd}`!9&s`R%5KD{2iNBWS_Q8iISNlUP z24TXDXO9I(UkUnuFPdkImyvyro@e;mwaz-htuDtwS*gn&K(pgg)xlkBQ=F;@}X+v zRUgZp1a25Gkr_VVo3@3Ps)TO2Gy-O!e(}WhBazdCRM+&V^M0h8@U-x;>;eX0RY}?$ zuX3WV+sZTH4C3oRr9_Fgp!Ip8J7lr^mlyD?8AlelAD!KQfn6cp1ubE#b< zx)+^OT0>V7vWwsor$H@6@nw+qiNsp?!zGhM4kJ-4Mqw~wM7yT67N#wCG4G^}-!P#i3S2no|~d6pZ(!xUs6xKq|&qUWOv-z_iHc1~vm z`VbG3d`q)eUp%I?biHoH6Pk-QiBT(Drk1i$<9b3{T)3|RS_#%!Zr0l`K}?i)t9-aN z_5-fr;Iv-3I4uyV!y>9pYA2E4Lp^;QeuNC_pce>pCuM92B(iSu-;v|GCA}mm4SM9C z1uuWK(vIK%dSVnvDJI&;!8_cjxs{@!8QS0c%Nbe~3iiax;Duz$5+mTCMh}6wvHj`h z5aiZ)irk<$Oy}5N7TaSOQahgokuWVZ24cIekB3nK-4k_)EuD}~uw5_;0er`*6wCNz zywc!Fhh3NEpI^T#$P1Tqda-mxmPt0OzFykG_63V_{*VwgAoPlkXp2fq?LvPRwzx|E(O9t`7K|IJ5S|oDgDwcV zX!HJ9kkqHf1d=8jfFNC)`$4$mT5SvS=Pe{IVc#+p@T-eKMghq(F634W{Q#TC1!4Be za8X~Hm>>w++GQ5+>@4J0f&7o3K>7Awa7s-Td73V089g6szdF1=BVzM|+xPr5VUq}p zPWoiE%;1Yh6@k2>J*XPzfT0#U;+n&C?8P|x{BPM?2?4}^jZnbb*?~3t;ods|%yiXC z?QT!(gN;c`#=u7NL?P3*$mO2#Riup0S7UoED0C>2LHayiU8)o4U2mFLd-N5XU{XiF zU>tf>RMfstmqTv~-N7M!*0^fI2!Kil+DRGuomW>sgGyKBv?%;!d}L>#=!OyM%Hc{; z{rW)Gt-)+%9wvmKkeB}+yNfFbsdsD5yAnWDBb{Nm?zy`(0Han`u2Z}FY=efDw0Q-V z_ek+HtU{0si(o8{cb9SX>U|`&n_O9&ITEl4FYDELxr64FBRzt2uijLx(%2lTou?nd%wR&Q1Uz;ly=`{`Eg^JIS*F*{Sp&} zc!sonVBe>Jrrq|Cy*xH|AT1uQds=eg>#GPW3qw$(r|B~5_BSS_?Wb-=x_XkE^rlOu zcE>*49ie?%Yiz5mCY~FtO8wSt?ej3GZ@?QfSfPlCM48?g}Bg zQtdb&eiW9k@VtcC*Vk8wLV7D9&iLYVrw;@26OV$7q>&?LEdqa7^C1C{M`l9 z_Gngbl_orjtFc5OMuWG)vr4uIA}F|?e-h5UBuJu;qOjX5zA?q|!YDR3E^qJB=k6FrZ(D%H~}pA1Pb_mO*U|AS5Fy zxjgp~Fbz|6K8gRW@Mn=$EjFL&*14X4)DC6?R{k#8KngVQ$mnnEH_gCDnEX}!>`tmM z&$L!4h-a1?y&ElnsL8FvuL{QGF7#zEa8c*}bgx%3A+X!U?X?3W)xD^T3%*2R09Cfowcn!jgCV(v#-RQELz0G=FAp4BncS{ZX4u$rQT@Mx&hX@zZXqL@n4 zOZ$v#Vs^-$%s#fN3L$1dIByYe2@hEJa=!VNhRJuC%&rQ`H<+1ao%I*z5iguaIrDHn zWke_zY7yCi_n=K+DSl-pP0Xq7^i#&q>s6&Y2`tKFia$@}GJU#46sj28 zF(O?~ig- zQx)mA7kVOuuB+d2I*%LoJ>3ck3&atb&JG)`0RsZW%p`IX%Q3ad`BhYt5rc9|{Kfvt z0Aq1?$Djaw)x}rFq-vyDwBW7e+9q<|x0q`1XG5#IVV(s|E%H*O=SR?jRgHua_yvAr z=$<{t_Q22q(1Ih3lf;-i@3y-{D$rR=dRgGE;nK~Q6e*}X-zZ~ybCMnKN2!E4@wZ;z z)UQ z2RLbhg(E2RHk6`fdb8EBiVgUXPL2YK-hX>f73`=j9_WSOx;@nGev`QQ`R;9h9?gwL zeXpbWM8GUj4Z?y7w6QJwfu)tvI1&=Z%zj}OCna=bB~Sj-0y^{)m5ciwsUr=&wt`0~ z9|>-%X{?riwi&!t1&RtDSE{+EEN_Bb)*5=*vlY#JfFolm8SkC@wrE^OYTX5IPb}$j z|7dU^d5}hlMcSP;w4mDvjd?FkU@^)`3wqS0VW=C>DFaPmky%7qjkPK$q{bYW3}kt7 zJ^W}UMsUGMK4)8#Ch75PE@Q<;L8Fqi5>v14Y2>jvM@Xu9{~MXpe1Iq5#L+=*O~JdrH0p!09#q(lngpYbcYwOI2z0ae3D0dmV@Kzo-}4>;Py;^&l_en~ z1DxEW9Q6#JodwmGW1s`ooo?Tm5zS2ybucM6=Rz<(cU}8Q;TAK}3mu{{thJ)n>u4K1 zej+;2o1)J_FFJQW-nITDVnQL(=Tjx3!uWJ9jUfK~=VzO>(fV74TR{IAn69F< zY-0eh<#pnB72y zkWRY9U5uSsF_O=Q^FdBoDu)JQosDYGMy!oDb8~D!7Do>7{s*QI0-8aP__$6rUL)i; zHy2WacHg1-;%nfLwkiRXg|)XzB}1y96%0J&X2)A4nn9QSr-7I$E=@tWN~(es@u;{| z0GXi6#|jjtoPC&vCaIn*fVrmH6F6c8%^?Ck5?#4)Nvqf$;0ft>D~Uu&lV^i#H`~-x zg`=D|XCn1D4eKP<`OnB+vS4wZ;EgW69+zq3tEM?@A5a(^GsJbo#y*6W8LLdEBk>#D z`!07WW7_Cor-1oTuO^Z@cdP~uFY3f)4)G{|$5M@y-sQ}p6R8trol%uj^AGhrl zXqPR3(d ztr%z?zdfyVD?fFXy6~6&Hc#}zM5*bQ>6~on`^L9&A<)XynHBb)NK$#BRRr{z!MuQ( z#dE#%NI9xIi6ih0xPJpjJBzA}st=$S^>AO7m1kYrH7f4cD5GP*d8^;|iYxIB9YF$M z6RC1sFA;?TQ^6-3w875(vz28Le_3vq!) ztX30^cI;}?5y6s%rh28U|iVid?h54_qmW2h6t#h!<2r6I)Yl@PSVJA8Cg8@4F+6T*o zM&#Y|6iH7PU=%8hq&^pCu6<}pS>{7&Z?!MeVwGo%^0`ed4P-s2BrvK8GOTrr!{b*^ zm(Ub-`aQ&{DA_|0k9aH>lTb)-4fU7m$vbgNj_@Q|ru9QSrc4wU8)`JGyY`pjNc^#f z^WS6_`KPp(RVq+R+&|Na4)H|CMo2fQpP^N^;<4>#f1?jwYrrE~SJQ|M*_f~GN&5o+ zuLt?JD(1ObI?kb@dC_}cd1kpIsqFn_ch(DOoJ#1Phfjbgg%|ivKFU}<^1=%z3?=8l z5HwQ|o|7`bMihEVSt9XtLV|px`k4)yWbXZUw<$c6!?#~OGJw^jevXj{`er=g%c?tv zy!EF7FlCFI3*AsIe75I00U78ZKFVpYAr+R;;x@051U270&4RR(vymi?JwY+VRpV|a zAwGi}T_tpGMMhg+_#aRk`fN_2mNE=W7Sr-V&j7Q!+haXwRJ_5$%@Kspg+=&O;83&f z5R-oLw`_C7uAo0tW=;xx=hH(zixd-Nl0G9t2JN+lI}9~0YacZWQWjNbTEgOc z8oPMw_2A+`95cB9MlqQWeuh<i}Hxk&JtcbfjuGXap_H zZb5#AZ`$m+S|^pEmrMebb_Q7=_!FG-xK-F5PXS(q&h`7}>_nZI;WEjhHeCm<-iEr6 z9zK7at@KOPf#+QL=T~KLPi)11z3#AA2qV8SydE#``NIpBIF8{HOuY> zp!oO`Kfz%_P%)7 z^VP@&W?7(DC4e@Pt~CQ(+-ex&A<)vvHL9(@u`&BOIBWDSc$+$2x9WcbG_8aWC@#Tt zpVUd@CS(dojG^Z)nI!-h*8uw2wRMKxc`)&p6Y78y%W!RN3-7N3|KyoK`nL3WA)E5 z^EPar^1{f7Tf&U9)z<_bn}nXl6BzLbeOs}friaBwjlh~AP4vw%)WC%w%v7Q>C}0V* zv)F7p(4wP`-=()k6yALsfXiNY#1Rd8az}*w1)o8!o6Bd|h==yggOcBv`(<%>K^jkE zlEXSxPi%k|uiKc0d4o^s{wJG-?qmp<=mCUtX7lZO#T63axjMq%we=3;+Y5>7LCuW#s*Tl){&XF zrmscBJS>rF=c5zdhl#Z57?S2;(}Ql}3ZNPmmjb$O^nsS%VD_UHjs_@y=o`QvA^5f6 z%NOanF(LWbZDX;RLRJF1lC_F|OR!Qo>i9c8NQ)bAiM6()0-t8sGrUZEetfuZ3EGP0 zW~0OF>o%IuWoW*Pm73#sf_Tx@{oYpUEhISDvKJfXH7`@XNCIcLcCYLAo zq@|@3UT2_Qk`1*advP_e5XGd(d3msxNpsU3>a{Nb(~Xsth!uF`Zau-2!AX zwL7@j@Z5VgN(4HCj2qNvkQajAn8(0~gC9_)Q9!Q*3J(m!XsjB6CylsEJ`Ee4$P>B= z0nb}Y06cs_aZIWXVtzFVk_k*;TLe`R5FN0q@wZ>SnE2vpSnZ69+q19-D-pmCm{`c0 z&_Mh4<#{df?$Bid(E7AT(sb)j@P&n^RJp80?>i%7(KfhU0)|?;>@!^ z-T%ngun2-eM_Pm?QSm52`h(A%1Iews59FBZu-!me1jW#@Fa5edRQ%>#!g&J3k~)au z4bueO@s7o9r`;{-6Cp^gH+A%q3DAVO1>{E}wSUBACI`R&T!2uekQ_qfzarlv<@u{u z|3AJSNTC&Z^3Qk(eXlS@d`2yV3tjHduR;v>53Ts~w*fR{7WnTw|G!@cER+PYtEX>@ zFfvX-wD#W@&a_U?&XU7c1ev)ll$1FKohu2fXR z$>SKLuNw$fO`qY${mm-RgF@Dz!x-48F!b!ObU|_c_xXrS+2J#wBfx)m>Q=6ya<^O% zC-7FtjaNGSA2WnNha{goE;t#-hldh^rI19vXnt>)&5Jhj&jgaErY_=ydlKBc#tDN%Bw!0p9yNTjD^zd30#aamIRhVSy1SM@p@lrXyO0e&Wm^`PcVOn zM@Ll=Bs!>ifXbkGj!Hvg`r@*tb0<`(3C4v65e_}}ua|}d1SP&R4Zqs`<@RuPp(|_D z>z3PAtr~sHQH6mVuXQg=OeLo|ID~A~wQ1Pl?5CjqPOvzFn}RT%8D#={m<_|&G=vVY z4PdH-VUmlv&iI5-ETuD~QVHwr)f>+ZZY+JAPRm$Gmx;ov202BgqR=#9W>t3#X}=f6IK&JKIQ8-CP<@t!cCpo*uHfR+y+>0u-p@i0#lw#g z0@97Azrf0e|BxXG28IlGwaqf{G`N$n-vUFQ5xdSk1MBvDtDV861BM4gO_`h?b=x^W zO=C7x+*)^`MT3qw#sn)^4tG|DEGb!0Nzh;usl2K|;t}-w-EPiW zg{8PZ4jC-ztJ6oMOGfxc#MZ%#0w$kTk_E~sB^_ok94|dP20NMj9>j!!p{7auPMoIk zn_`8Fz^3R8dyucS1r`EW49>id_kl@(#$3an5Z3~@SN`)0H;~x-(j`wIW@`5JC9VPz zG<0qC@4+;AfTyHMNGt5D<_?$~T;=tl59D7dtH$2Q1j1Hf^bXsHhS!rWIp+BO2VlLo z(*uR89w4^CGA*IYNKjCV=Q8m*-D-ywYDO_l3_BeLEs@u70t6B0D{g&@wZ7dNg3V_?=qoe7TpJIwm zMAUpJvk*8chvjWhVuIQ9oiz@0SY4uA5fDXira+lTqz+72Edy0x;#3a8f%}zv1S$a! z%%0uPPY?$DF>L5-r&|Y(3bLQ3#h<~fl~|7cf*4$LAINF?mG=Ik5YR2B`ziszhK-FZ z#SFZTPMz^zu;b#S0*-1n>X_A#@HvENve-Cq-p!hZ${Xkh+gSduay!~8{VGS+={d@R zCofvP*1zW^)`SfI*IzIqs5ZR6Y_-uIHYgchViq7fNzB;)3C-S^v@cbp&ynMv?L(?~ z%DcT*KjBKRh&f^uB^|;5^u{q`zi56~8T|=r^E#{7-``p28Gwh-H*PlivX3 zuxTAv6O*Dh3K{ic#%+Eq*lMB@v+t(tkX-AjaEA z)=w#af4p{Ixn&4Pq=B26YO0BmI1o1wA#vVciYRWFh(h>4 zQ}oMwYNiPvp`wN8%h#{Mnkc{iI8Y%{3pqj%#U*W)$pPUV)9Vr9jSrk-uoT0X18t@J z$*=I0ROm9YS|eXPE6!Z_1H_=Az^)H(dLZ5!(C-CCE@!@k3A=+jegNcMp!Z}h)KPbA@dQtJQXbN!u2D=-gdpzlC9v7ryf zp2)r*a-xug6a};SzoD0ZKyv#%6SH5zUDHoOxIdeCY{N*4KnTrOVJd^3CDJ@+)lUD6;542ReqGG+0%Ub>+j~r*+P?H z{RJ&L?d8XT@5YFauW`#!dS8o+__C{}eDXQ-pwp25<-vX!=4OiOt}K+eobt;8p-5o5 zhUIfU+VB$=FjFARo8rwINO!FIewHQ}dg^!h87fL8A#|L(So4 zxMPeOAh-Uq`Dndn14IUXvksC~!*1i3f;TARf5>w^D%1mPGmyukTV(JBreB8$b0S-r zd{=mM%67_>7Mt2BNnTWAX-;I)OAtP{O<4^NgF)Q|7e)0(hg-BO{Ptnn|4f+#7%;Xf zb@pg`wD~b4?tw99WcdfXzrW*uu*h+CyagwFVtsLBxwy3|p6G+|HEIorIsdK`5Un|f zj&#mNvbThu79Hm^I zMgCvvC6r_nFv{Xi6Y_XNn&p*!EMhd#re$R+=xN*E)TQ64z4ie4h4e0HWyD86C*}Sj zywIhaN%gN!yj(5~Kw_!s6^VEQDD6+d-PcD08vc7@ zC!fRV5U}N==)_7P8_;;$3(!>2m;DrN0MD*)^5)AVc+I-srOCr;qL;O6c?o#=)y)$YRwE+C6@>;ioL z@C&pRegKPZi< z*KH^l;{Mjv&B*$CUpUftw;p&H?DW~I*v>Cbc9#R$^$K)8=RGRY>7#;M4M5hR?k12L zKq}UU5re}6+fHyRHLe@wS@S)q2aw#5a+nD^xV8>`9C9&naApo1G8V;1YB=&ST5gwm zCk+HFZvYkmI#gku!x8|`(4q-=YJ{~vSN3aSYG@!F=wbwr}a+KoGPF$gaLwE<7 zRWRY)z_2YKd+pK< z%bi3kTZweH+-Z*SKDb|(NCE919&VYI{f3$dm(R*qS#;yi_6~jD^Q*=cXsw^?3-HYn z9|EE=fb&Ek2>4@@mxh_jMFI(HWCJo6LB)H%-Nh??5(C~-RfMepiWF3D!T5L4d{5;c zF&8B=QT%tk z9>ze8ZjYeq>yDXig2|6LYln7Uuo#NGWUT8T*K~nmhh-3a|F?ko1E||P?Nrb6E&Y?s zPzZvxzj!?Jf#VRVr(Xa)g;Q{;1i{)*dOd@Zs~q3vSZh5Fx*iy7$pzfJRzE*+`d%E) z67M*{?mX>*L^WtQwWAU+0E%g2oNxM$kRTTp@t+7LB+RfC@A#u(O0+j7B}@C(?a=`$ zM~r~7fi+!W)QFb23FvY<+T>ID)5&w7aBTfvBo{V;fJ$TY(Fh zehQKeNR5_Hze6z@sytH*wnXORXJ-TDMHo)5-zW#VkuWUubRw;O=>FlL+rWZp2SjQA z4U+u~&;{Z32y8&MY90wqni)?_=kaBEPbpg4?SNNfbCm3#&xylhpuJ5$))h z^ewp3fb?&*9(x~2qPcex&c?@o>1zCNfTE;v<(zS z9^?c1XzvFSs)ZPaJ)8)gX^+_uQ@vyjX-Ir|=#lSmB^>8)1ishOj>GA(oj2Q{RSh(k z6Ij6R`2cI+pj)3hwg%cSgmI+H%sQL;U}qxTwe1vuMzQe_(0K-@00D}Wo$mzwEk*V@ zGJ#l7;zhb($G$mOA~S|Md47K0r;XADJ%t5^AIm%iFR5(W1kG`KUUnj7hIrKf`Fpc=n;U77eJttoKJ0u3l6K9L7#-g z0q8?`((Vxr0u3qI4UkF!c!Ig#ZvK_a5*%Saf;RH?{ zgu7&Bu6+iqzR>%c*4X!C-)<&px$^H$v}Xe-3t`5qU*^H87%+ckum^p3AW${&+AlbF zWFu*aEPqVz?_M&3!S|WfwfRpAXpf?$a$7@&Bteq)pp)`C6pT+1b7J%L_VPljXEVmI zzj@Oyxi}mLkTH*D{s=%pjn>dkYgq<}On~2onSx zHxq!@jgde3gJ43JRFH}67!(V5msHqV-x(P-LXJRCC{|1OURw81@h=T(oGBzJ$p{3` z>F<}r4`kkul9H0~iDt0{L7zyxv?;n@E#MhAl_0mOrc2PA)Ij0`pt|bo8=n)$Kpo%% z@yIdhCoH{^Nn0OS!LTjCe2j+>DQ`TOTmt~_)YC*YpnJZT#EiqyZE_*|CkSRz!2X8b zUebi^Y|>G;LbD%lbOt2@;!Yi}#SdVU8G0gsc1lmKkmaKeqbObz{6X+ww z)d!Hx`vwYoiOXwdu=h`p!P^XJ>0Wzq4&bg8rvU6o88x7EZGBG$6SN-s2NZM&Wh3xG zyGPK8Pe=DU=9OY|*@u0d1)j{WrDTzbE{!doG1~fjpU=U0I z>o)Ez^@1u369mFp9+bUM`*EFf0f~l>aCx8X8wBnlbxF{*cDA+}mw7ZY;k*d54;f*WhV!5-(kNZX`o$d#v_~N2K;}1_ zv%GBHjpGKi#&t3;;PtTY0M|cZ>rw+{2u7R#{TdGFZVu5Iuzkv6K|@e69pdeRjVLHy z3ii>GL6(0H+V5eEzxp>*iy%C*!T=7_Lv^ugc??8Ni$muVhsPlldBwD;`I+Ijw%RLV zoY@PmlipPDU=@`|yOvWpuoLvlEC>r7kqP@8)f1o;9%>}0Dr%9@*NazAKd>j}b~f*4 z-9ywm{Ajp`d=?8-v``~bGE{I3O95acw+BwT%|-!tSc7kF*hA_l0H|=h;MBiHJ%D!K z9F(>g9ylji^BG8U<2ghD-@CW%!G9 zAC7~JzAVCT%M-BrCded)mG(>^E<#j+-*6IAyJn#19i>9gfqOAV(|wDsPW_<_fHhQ% zHQ?dM7(ZP~LOn8V3f7(zQbrS8t)ybOlf=E`o;3Yc~D18zYkuTQ~Z2&y{%Oy_jFCY}Q*-m?k{#gZZ9iJL!NDz*Pc z!*k@}_mml4okSki38n?}Dku#~zCn#kRLx8AA8=?U=s(M3YHD`2c@n%K z5WqK~Uc(*{WHMGGfKv{LN-^+ImKGNw38#Xwc^~^D%)d{`+4X*P*V;yuWn2&sW zMq7+c&%SpZaz~Tyu72!0CkrOB$kj_^A&xum&A8o@Xaw6nL9--MhIEW!P2R#6y3;9kR&=J5fq%FU1@lP}h3azbmL#+ih54rr;etyR>qJ+n>c*D~x{^YhM zkZ(<}+yk`}q5$F{NQo5g8p6Pp|LqD{c??vq=_br`o(>*D!Pw@C%|ItG>GWisA;G^?|Co&0BYfw)V5$_W~q~EK>#v zw|-#fP#vH%t^xE9Iy^ZcUxe}xWejp9i%@|LK*A8V?tg=-jX=A72q~zqN&L)A|8>hy zB>>(Hpy$=Yi44f;^yfX|^+8-eqLKDF1q=JZ#|O($Zpvkm@)4k;AGPjI3H+XLY_Pcr zzU~5s8?*z{wG=C;*#({GMwjX#62+|tf)seJ+@PbSX1u$Th$K*~@-2alDd6@T9v#1UqC*K2BgtEFht!! z`qD%L$lq${rB7}NCJe+4_OrF!>MfW+5`=>}h_J+CwV3)3dV}IrQH z>yz~t&@OyW)iKEtQ; z37#hek+*NsC2+ozU5S{baw_LuQ~uO_s9{i%W=a>}>>W||jT&&TL8!W04>1^KhpXcv z6K>6NH?(fT(#pOn^n>ug63UHo2wUuQ1{4qhw4iVwIPdU<^~ocb@SHUe8Afa7NxECi zoYu*!>&J<6I!Zg?Hp}1{U#h{kc_II;bD7XKj01I8va&26%GNb_ew60s+Z45UHbVT! zvMF6IG=%SeT0n|GJ*?$^{3$q?kE~pPQY~(V1ZKv@E6e6n;Z;mS+!;CmlD9&Z)3UAT zQ4u{eTAH!*1~_4W6<+fiO4Uf>6I9Frf$T2$U0X1Z=^Rj`U(<#*Ycjq8chUuxkn|Qg zU_6_!H(x0N&*1P?@MVCFji$Beym%M#!d&jO>*?O0;BWkEmoMLRNMtGO{VO?FeO$?5L0mWi^S4sIL3- zeV)C)pYQK>{l2&BpX+x0*Ll{B*X#K_j>qwM+}BYOy;osmqfxib_4V{q z1rnjgFXPk|lA>Xxcnd}6yG4YyROnmb@~4$S^RRKHi)(s6Z*ggr<8yKODE#P_Gd^<5xwnL z#SCbN*O)YV_@by>GwGP?iw?XqE0{Y1=5ZU`6Y)*L*(|6%+ZY%ougFZ-p#)>Oui$!U*x0sMcOVC_+S_(JU zTVOgR-S3|J*y`RkEj2B`3BVEYLe;R{i+xF=GydluM@+zOuO5EGV~g1zQ#lkJ(Bu9t zahB!G!$c4_6`#=F#1cVMS*zLaA&c-57(O%}%bO*pB4zSfI`m}7Z#Wn|_dR~1Q`FjN z;(Z=g+ih-#A0x3wN9JZhc23ef=IHAw##~&#fdW`u&TE>9t7IALVWqYk+|T-+a;hyx{;CybH}?P~dNb3KoJVpm62-c_WM8CL>&VAa1iU9Xew?&*%;m*w zti%bM<&?(S5vT>fQcPJr_%27h3rsdR4joYhUHY>PC&S68OC`-$F&A9YWOv%LL=teBWEJ~= zL-!52tuv1%w3i7pUj7_UXg98u$yRx#o+m#|2w|p&WeH6G;Lx2oko7pYC9P}shB;P| zFL?YEL!)wCtsa4NMiG5=%=7&naWg;nD<1ux%%{z6EPm)ZrNxbj;kBAbN-~@WC4X2} zmBO(P4}S&3X7VGxJOfAmG?Wg@!kW8I)yv5U;#6~@caG#kYp%;QsG!Aap4e$-toVcT ztuZ<>G0bVfyr;Bv#HSJrGby>$e{c`6KXkMc1IC`9T;P5wx0%Bwxd%?>@3EVAP7jXM zdG>H$s`pTkq7jqozj&_lc;oj?hL?qZi7cm(ga80~06rB{9Chkcm^Z?!J;xB5`X^Tcrk3vXe) zL}tj>2W|}vN!`wB0LPu&~j)J3)X?G&q8eN(Dv0mmbNhx1fr6;$! zo2!oUk1 zbHidxEu=>}6Rr>Pxg#`K;I{<`r+1@J+SbYhm2z1E@D+A^-UPkeJ=e`puo z-fG_^LK6+dhIuA}`{Y?oa`q$;zHFq8X_OvX$M0nq4JA!E*ss0}_IPM2DG{TxzuD_p zuiInxchk1ThJ~er>&8~;poZq1IO&+pcUDp}PWLwyDn1nDM`+IN% zOAn0Jil2}Aom~HjI_-W>=~X_>@GY8AD%xjU74_dAi{#-vPg4l$YvJ9SQx;V*0zqs-$>e?~efcCE>_Y80Zb1d@zf&TP*x6_yJ zM_PS@`qI0X@|NiP4CN0$8RoA!yH~c`B=JSuJDqNC=fY>OzGL($<7(tuPWkZR#!tC( zqAsZ%KD#SJH+85A-nO?r!apgum1%qoxaXIb59p6I;Mpe_OOc$?*mf%KY|EMzXuf58 z);YcwDOJCTp%vf0qq6j8GCsJK#WN%(&zw6JFB=2|6ot!ibNL69tnkITx0^ovwB(z? zkKA|`NoRkcO+01gj_83@ZSwmZL0~n7o~QphRW{?d+2`05s&X!&=PWi)${G#PN6pg& zE%zxv5+t1zBU8LhG1^v-dEgRW3JCS?J%XAo?E8~@v~)I?UR&_)%T`(jHCIClkOR%H z^?FL6OH+G8{yfL6gEWWI4R_jp{P8g10$TPz*ONE9- z1#(}GqW+I93oD*Z2$*_4{TkzG)Ir-GkJ>%h0J1Ny)iGzQboP(AB)7bIlUHuog zn9cPwaV3j`rAgPmIjFA9F7NDeL!p}Z{Kla}?70tt5nahdJLLVa)MWmP3+Oi64~OJ? z9i4^SUUNJCXyz29l79He%)s6{;n#RlL0lPsOPXO6C&ys6da$yvgiv2DZO^1L@jQ*U z+dtUdPfNRX2Af1@;^}=T0bb)^3WpqGsLe+}D~Q58#*LDWHTXWpyiq==*b2o==6H_B z=eh6*2Yw&eA6M*#_6-oZbkgu3=fS&dVrIR8XV?qA4(ad6JjU*JUaFDmU1?vBmebQ- z&YyU^J8*X6OscV9d$(e@$HmuNAAUTZ6MhelpSOBwh5>bE<(j`vj_yaoo6-UD@-qs#gw%}$y*Sh?&_L#EvQ(GS%j#rP3io1ang=~)Op4oaf7h4iXJN# zKiE;Yx6V7(#U*`mt0iBHwsg^P19J8$hsU$FE=s-eMq3(wL_}t#Te$!ym>r|_PpP=3 z-&p%i*>}FF1yk(DqXVjbn$Nd?J_ntgSLlV+DGKFUNoF#iaxkBrEhwkr`X@t^Qh{SM z=$t=9K5-rJq{URyz5CpJ<}cE-L8#8|WwO`OkxW@(W@u`SqILY(IwR(0yA6lQSi*Hv zr3N%j&o8`^ZAAmSPzjxFRR5+NWj1GzbgO}(wq>APF>Iu2w}`a3_s@Fh+yMpxJ9%^d zOC!D^zbXuarX>fsPm`N*m?~JduZ+$wy-jLtN~)G|h)d8{Aj%A#j`D9bl6Xaq=dL>b zd4K!9>6NNOH1Q5y8JVvaJcwxxj5O88W|IyNWj%WDgtyd(0?9%07eb zDrUC&)&|8UXDtOutD0WbYMu-j|A9QZplhB8MHlAKY&$bGLiXrK9uY?^%7c2+Dd&Cj zWE36ulcudRVLe8%GL0KRAPYhuoN@ddys};342#w-3bE{=Z{L3ZJfu;!hA!%|6F9!T zo?e;ajL&u6$#f@E{P+j}j7aRG{k*a5VLDl_jbZ;)F*bpWb3GHQT`sO4!i}Nn(6=wX zEd}O9W#dM=ZDG9@e-$yk`m-4~i1J&@?b2D9LWZ)OSwsP-H26=MbH3ty@6A$3k_`WG zTubI8lbm?kPf0?te zN$$rh!oQ9zzZTZw@5)>zIUWDPwPA%JXzSCEz$eaD@0hija5$+zyE5LKZ_ljNd8)tP)+MteQD;FCX*X!F+2*+W1f&sv&lhf)5?l)P2k6KgWU8>ozsc3;xbg zWC^@YcE>1XQXx~p=U{(cMUMWraYydka<6w~NQLn&KmPT7`->0Y`X(GY1f>N`c@E@6 z2exvvN4TuSSzZjInmg)ynWKLdR=J&8=6b$GRM&OvEn(Kn!?-6LO>>x&epv~?#;l^No^ zs2-s~!MC?k$u8L3(Y2TY*8ItF|Dm?{md*wSRwICatefX%*rqchKe<-7ZhdbQIPf~C zQsj^iwg5&Jvd=}PJBJi6eq6Mvf&1+%W?TSBY}S{~08s@gH#j~ec`X^ZoQ$PrNdWp( zmhC{>o9JIi7jiK$*ah}Z>rOe>@pHanyjR3}f`qLS#N9Rhe2xL6XxZC^ajV5|?cXKR zww&M(F0LWu*Tbc!3zmaBZk&(fsurkK@oT=Tp#EpWCsMMAFX3h;1C8y^wsV>ZZpqQS zGs9rb7B3eD&2Y)^k9=e0H)z;t$73XOLji3>m}`8#OMV9>7olGPMg7vpwKiV3fzR{& zN5mtR&*&G<$u%E+jI4`MXk|cOXKoR%>rLZ%1%T#)4u4sYm=2)a#Ix9;9yF>QiSC1_?P>ZaUfc`* zABK`i^pZf7f`ApDl}4HR3$p9%nZ<8Le`szW_74ir_>hW8P>K)JQP9RJAtsjKYe_|S z^q2qW(i;h&SjF!kY%D}PEJCW70SKjX%AHW8{)of4C>clC7pyk96C?A0fD@TT`eCR+ z@(TQ-T@c`dn=1h9jS3CNUG?L~e*i6GAb$ayS@|be)Q~i+;X6MF?1Z3Cu|v^;KwLm5 zYGJZS#lW#oXFGaB*MA>EOCFjB3wwPUv0IZLK3KsBgr$&~M-=k0!Z=)A;G_ku0s(gu zB^jF@XnZN*p0P|6xuH}703HATG@jN_6F_D`t~2{q0hIO50wCVdVXd5i=*!p7go2Pq zL!j`lpaseLS!`c65kd1n^_Lm=(C_Ho3`97L2z(+Rz|({NO|Ag1_icF@rsAzr!vEsl zJGMwsT}wuOdJUGTOC8#OG??nt5H9@2g!b`n_&X$!!QZB>TbbaPaOr=T=NisYj8=}& zY(p|Fxw_O-w&k-Brj0({sCw~vygL3dx`_|(Kp~K2JOl)*;1SQV(_x!w4K{s!z;bak zH~d3h__^Tte<6(>7h5C)QvUBsXq#X(qD|Cl0l#q<@rf6+<^IYGLXd-fVUX{Sb)imd6&MyK!k=%1o~W}5dE_>bu0cd&6JGI0ZU&t z9lL8Om!%&w8GNGjIT+b^vh=w;J}i9*c^?O_pGDsZV=*kQ5Sew1nW$4xF)6@VgCled z+C-dd4kY+bC6JBSUA#5Wg+{K(jsLgc=yc5CO_`OBReP|>-(=;70(5DznUtQ<%LD)3 z^CCCl&PR0CFPTNzx(k1h%(p}ujo1<;nEYOs2=Mbl+E4jeKNvB&C4VC`mx3rYe`Fc| zN6&PN?GL5zXq?q)gz{;YO;aEvH2TK>T3v!V)ldWe``Ti7|GJn6*6#n{q5S79|3CaU zG4@n^{JX$gKukOU38(oZKtrGRf_buHyh4Pmqlxb4gYAg76+&~!kFe#iBYU?pLPxxY zK_Q~L8xSTO^zaYK`!kx|w+)77!PI5{xyiLuF-A^MLZo;K(BKoV(53~#$Vu(kq=mNyW{kVo7oXld&PyA~x{gPW7AdxAii@aRguVRS z)%xX4P<@Rf?`y1bLi@G#xNl7$-MU=a8b%!)?7iIYE1tC86AZ&zHCKdJ2egk_GQ!YFEi$HKl7BOt~UyNd+- zNAND|tAw!=)ZkhYW9!N4F;GQ2LRR@wIhc9Qsm~ZilG_xO(mH&FGjRY;I6MGZ=O-(u z*VnvQD6gIXWmx+hJ$NM-=?)NLPrx$a>)VE`&0vwf)T+W19c^p<6>|9E^26 zx4emF(^0_M@Ij%3!XeM5-25s8XV1rpJCz^s0o0>^wO~IlBRlp?*fj(lLiYu`o9c41 zfDE<>6x$j$#UQP$5-r^zKqBhltNI75LVJb0UHKN^tCA-C60r*;WV6sP|9$e1+8n%X zCZQ)8tR zxQ&m8(V0Or@v6Px7rS~8K^`AW#o02g<}fw=p6KwG#_*Yky;q_*R=w_R!j&l(nIN*1 zs(FfURH{W}_>1k=lq^1w!#Uss+B2ik&GU|63pRieMIh7#=ZwyUq6KokTCX$MF)wsP zNI*`f_~=O@te)j|S%G347V;nL4KA`m!Uy*wh=-xn;0Z)WLsvyKQEEc1x z_yjuf6Gam-(c;*Jc(e4AL&*d@gi9vq@?zyQzqyS-alc zJT$cKZyu=y@BXTUzQaIzo@aqhLUk;L9?}` zr?_ug+50X>sdJ`SV)`mx!OI0+6)X+o=O_tm3Qy_s&mZek9@{FMCruZ1Hm0$13PVGy@2II-<|az&>ex(GTdxJ z)Tu5lv&&QHl{gMwgW3q~1EiT_%K>|}1WYggMDvFIPk#3hB(e#5-+J+aqa@q?gXz|t z^-+^ryks>jCTX&=hES?xpui;Hk_+S4o;g_2*jAES!PgiLX6eufM_1EazCak+2@NkEC)XxXZ69lw%y@&4-r;TiTN0@tZF1Cy z6hYLyjJIg}S*M?wSP{x=o@o1mtt=9MQ{_thO2IV%gN#C_6SkHIK!Zr?f3AzX^8iI` zqpghu;p1%bl7)OuwptzDgrB|KzvOsCg*w3DcaB_jvvhbpo0! zuy08nNjpr~n(wrv=QB5*+k90<1=pfP1pG*F>dT7r>@ijNrdWrTG%xmJw&GRwV3KPJ zfmGtj+R9sesRMs}ZbUU7m$jrDZWub1$(Ecg+i_-0W32j@7xz?EJW4KcDU`c!w?^Lp zq>LETOjL+nYQ>)s8BoV$pLp-!^;wKP*5Vz^pBU2W_KLGeSdPqmW3ZH-d(dogHiVD) z1!KWFkhsD7y6zeJsSCx!DaDoh(jVX)5YPCnR@B%*cHlS=T)<-CNzl&Lxa)x28>xz=76AgFZdDfmIUrRmc=faE+ z!6jR+a^Hb1O`mW<-@NPGtK5t2>RzxI7wwWo|m+&qC8^|9i|>TGf!V85wQ?y%FE%? z9QkFah$7-^#`pRC)3qOHq)K&!IbKaVWNMuX3YpUs(w@w4x?+`3AIn31>#9|6L;b#` z$$9MPR&&(|9br=P{w4@q2i)K7b}dHc)I<^&-2-+33(1m^2G0ShZ5Bey*|Z_#nJK)Q z)7O^u-U%9BF+cE1HicQwXTOd8#hus4EMp9!_U+@~C>U?244%1q%)GcL1(U@#w{xZ$ zM{30nvmJIm{4L8RA}W%xx#>VHtqoU)nS00n(qT`L|NMf{3L;9|)Wi3u*sYp*UX3P6HJ}eXG zZYzmkO(?ubovnltWMGOrLsTy#U5s&do>TWk@Aklv$g`9^pNk9gbK8s|*yB7ZWGnf~ z?Pv9XVnQSFdcH2KTTGY!YG(lo_S`TOHw>|mE-_sROY+}o#pSRnHypVwU264~^-p7eo)onE;d0T28HTzRZA z)Es7ym6|p;oQ*ftXRmB>ORVn?jqU#B@U*#NwzS)JGQvC7aH*Kh$w=Kox7_{oYFGFUIGoMwzu~^(PFE5;#&*RNd@q6zilOz!Y$rZMo%9qQ{!6UmL)C_fV-4Ceh z6B5zXitbZAV+tJxufSlAte=)$y~-ZK$j83D??rCpKae^+-DNR6!P>~YkXPci~kmbsC&0&k}vCt zWH`_1o8)K~AEFu_Dsky}5eQpYvVG0Ign2pY;^Bt#KThrXgpa87_{<4<5*Gv{V|?{Y zl;8Wf{ICki6-RuCkx%S8kakO(%$Uj!`|Iy=3_6(3e{fXgRq&ISXt-QUPWw_*IE&1C zn{N2sd)X(mbV-rwt7@&MLygSbU0`NahlK}KYfkQM;nPyI`JR9F+?RBFvLie0@|DE% zMF1l+yLBX%d0)lnvnO?rEPC(UH2=)bc+!daC+AMQVg5@$ofTa$ex;fpkSQEWO!_HD zjT6BD^S*^keslJFJ3sP-y}`mUXYa|`=zrF4Q}vJDxj5n5&mjhhZsn$$&J~-l-NzG- zvu#eWI`w8eJG$)Xt+O_aHN<<$%)fE{?)$OI5}##`%>}Gy0@z14_S~h8_^eopiau?Q z>fbpk`}P9nrGhvG`lbxU*F73Fez^{OHKZg^*c#pVIAN`%AvBW^U6(@|F|%*j;C}ef z;oms5Uuh^BR?KLIs2o=vrdPCA+%4O?Q@3p0M1IeE0&!wkodOn*?IZOL`ex z5q-iy&mxg6%GH4Go}Gd|xW(k>;Ow(8h~8fbEy0Zif#8Hvo4V+4H}TH5Dn$WusYw7$lX&{@#)Q6+3`d+=l0RC1%v%?u)&tf?mrRupO-U8 zjJi^-V&6#zUjOrYrqGYC=2=tg_@T3BKfL(cM`vn?V{dc{?J2HzlCNWbsjI%_?I@C0 z+)cseuXbCCFFW^aD*SUYn%W2UEll|pQm$`-t%9{mHyx>Gee}B}=VNQnGs?)Cf&v=_6mL3^nxNS$PPJ5eVwL zrmaWwJ)Tv29Q$A~@Ghe~pd-|H$Hhj9wE(@yJ$Gw^Kh7RL2MI#qu7Nu1ScZHLmg?sB znV107E0UkdF3MtF+n?}Qw7Y7*wxT<;ipksilo{VWspLzy z-Z6N?Ah{)}e%bC;;Dm-xndlDR;>__~1`K=?bQte5m;bXp4u=(1{^;VEEjdG`_EFhE zOTGL)VIfeAk9MnScX5#IS>hSPENE=S#Usb${Ec9Y61ioc^aUh7aAo4j+1yc}D7wyV z>HEl{D^KIGTG7>+TA{m?u|778QEsr!@Y7bM65vEhUI=INr{iuhl75&pgJJme zxS!sgdf$a`>3#9J=taRdR2;WlL6~S(ScKGq`EY#UU1!s@Fx$Dr5WB@~?9D{Txe~vn zrhi^b(HZ_~!titB?XC-p%@koLx7w@y@D|bvy?1bge1{*f9P9h&5Xuuu%ko(K!faJ; zm!9kMUk61s>FGT^d}^$MY-jkN?p@0pCs3=2m>cC8w>oI7Oe0IXZ$9YzaS6zVy+&(- zd#oLz;VH;sPox-y!2hZDdx33rp13dktb)Oap;e5imE1rZ!bYyOVch9_J#Q0R>8$uLCTA_5| zU?}?dOLbpVzi^hHyimM~2CPj^uE(h<%l%&KRpc#@E7f|o&Ub?gtM*+uM3-44n^Wp_ z>+(FG>7wZE>%@(ac73nk1-ph;Ly!1v_Wby}=%W1kmQV7C%v-QMNR_JcwEgMOdzJiZpau>D8YS7q zKVV@4>Qr=usqR@lNGz~E+js79X3vnfZzYThax_*^qwW%bN?k5;${2cR(#LMR{9;B% zMS)Zf9ey9CdF8}0=4e>`omO*w^ypfF$mnb6cdjGd5;lczPyN>FZjnTYU6xo6YT1 zV*}lqRKxw=zUp)>MS9a4ZyOJ1i>6sm*fk2y;GMtKwS>iEIn!NcQK!P%jmasfEjO8? zyf3FchJI}W`o?mGJBv;j<+2a0fAjmYfG8p2g}00BXAw5enX}Dz1--XFv#3q+*yf2N z2_!P};Y{BflpN)8q5%(0;`Uyg)I70&y^-a8HPCCo_u^e8$4MNgeKa!o_A*|yH|Xs- zWZ$~i(n}$>U&_Exi22>!NQva8uziZ81Bws$&d)|SdmcZ2d`>|Rie+Gu?(eq+Za+eu zAP^b**vK&dVC)W!M+%q6sul9CmlWy!a(w(pU}7n|=PKpqhk6IMTbi6cv1|Ey!ZK;M z{D`0RndQlHSp)GC$6ETtB+e=qFVSw)*uyO>cOm)LTNFS=jSn9<;>1oyFCRFm%5|sy zs8fa&Z|VTo#UCMk`#CgET;Uz=WR$zK^Sq|oHT|MbO>8kkjD)8Oq@&vYEd2|iEexx% zpBGCn*KW;jWw_AzJga|iS-AX#n6ysnX2vDpW}n6? z_1`PBJO#21YQ(%*5G;hPx+|MC|8qfe#VTX&3`P>qQ8XDVxR?}nU7sH}{6qnV-oJle zB9vgs?pZ5EahOZ--D#OX3^5yF*Czx3`*t^upsWI1%b;ZMGH-)eQXdtU&^yevaU4AX zb^t6pC~PMs+w-NEMFBS?52M&ubI~8+9U|fkY+$(jj)o=}$7R(9!Tsb(&x-{U83qr16w6G`l#n27~1L zLN6san<(Ewb^z;>N~}2Ga=K3nZBUIr0bOQ}PQ<--5!m_>X8Ly?!Ok#ycLg}K0vwQ^ zegRp1e#asuLe7VL^0bdBG>@Mkiu+oNxevtN1Fjtn!s>BE7}vYJgPV%VK}e`NbtR*h zrQa@}a0le-#IlqBzi>Mr$uj?A(ggKvP=%ltfV{q)o73-d%?ozPw!L%U{IuU`QCLjy zgnQZ48E=3%dlRp#IZ9Y4!cZGfrW!zfPXpFgU;cYHeHDHUwC@ai(G5)W!Fcq zU+=5_(nV^v*Eh-E+pWd2{Cxkoi_0FbmMfY^OM|JF~ z)7|Cz5%HHPb`-g++~4*^;#eOYPP9La{9vM4!tTj|7^@EHWd)h)uLG_GS5+Ns2Xb=( zn5m3QZ;a|GfWFVGVndneym=}K1vVYk$&m3KdYy+}S0ONoD}5b`@Mh+YR9sK?&&S&m z9U~Nq*!T^!=E!ReaP%Yhu-ZQf1_b>f!@%>%R0*rI=t9A*ftBUcU`80Tgq6Hbg+=_a zUQ7Dpr=hVt+>_?&u*~=u3y?SyjOwP~+RxQ%8!1NjNuLfG8h~KFfmM~mD)ruP>s+Jd zm_obUA!qlxmYE*azJ{i9ZA|JLO(jpDsv5G1%6xh;CU(wVhk3c-J6j|;>Reos# z9v~ub5mJJWuN&84drYA0Oog>yIB*ps8I+T@Y7xy0^HIk zPN0~NL9)yrQgtmv)46S(s3l!4EO*LrWzVU;nl^h5*)@ohWazxWDlkqF4hZ>^oF~$p zaI2EN43XAx0n$m7TfvR$7q+rH_6loGOrkR@Pq?vxaPNO^7yCyY!N?W#G2Kp^v|_Ne zvh8y%EG#JU)U3OF5xWtBYQ$_M2gXhuvb^iCB)X)DdzxoIgZV+^&XFA?>#%>Js8nff ziTi=p_1xpx>M3ky2sEj5Um@Hn$VO)2tFm-72Qp-rZNOM}{agMeE~ktsAHE@O6uZKEtkHmi{ca4SGB_X5`}GBGINk7axPXM1QHbNr%MU zYF#9Yo?CeZl`8<&DIj*20FUb?RY}ly>W<$w{!1ufu z`_qR?0fnKTK%S99Gf0m4`(KH2^0C$5tp5Z2Zt;@?e1dboQNd+$DkT$CV^J?=^?2q-2#v3N zkCqGmcwQ;=Du6_3?u}eH2*z`_!E$yJQY!xDUo15cwqJPQ=bM_Qwm=*7IL)8%*<#&v z-D=uB2Hb%pAP5izLc_M)_=WkjKne)6+iPjKpm$Q^J)sB}2bk4i0pKk=h7qqr13?d7 z%WhRU&>=SB9)fWUm{_sQiK;GNU|)b7>RcSlPSYCV&Xk+TpopxVp9AA=l_*jOKiaJ1 zzR0ZLf^LX|Mj&81@)L&mV1&<``N1NL@~Fm*3%FKL5+CrBW-I6kA#G-XtILF>Zzn6y zB+Dlp4`|HJQqDk7^$AOoNHS&(n<{?fyKsf|1d*=?a>7bWF<&=-n;+ej-Xu>!OO;OB z-WQ_EUO*ne;@R`nMj}C=dB$>>%@SNYo@`DLaj^{{0Wsfue*x|Zc4C$j0^ zLI!#Vmoh>)@oW|xv5=p}F?s2V@?T!(Ciht97pTOrZ4c_B^>-O*s}&wP0T0YtF;gfQ;O0-x14Paxnr+Y4fNN|W}Kr`HpqTv;tnLn7D}D7D4q&38^;Uq^_e zqlewLRYM}mSz?OEas_b%m`S`bvT=VuLr1pJo#eOu*dDz6+t#vO1qBGYZXK(%M6hcHj~>0@e|FR&Lm zXW)u;u;=EUJD|aLTh-k0vEY_F!Lsu)1WsHs+!_zBvD7+_#(Bdbqk{ye>znfP$Co5(XY~Rji9NXb5nzhF zvBg1>BmDl`ys0$*tAHTqmwrX4@s|p zHR*lgjxS>FMGk(CM-&Z5xXbK)pGi>rIPxQ?Fk`nR2d(Gr<0KMO zxiA{bC_aT64ibh;24&_EMo=HGcT$KC`qm|U(@_(Z7<~l~dJJU~*tR8}a<4Of*w;Vs zhOdq3Ezrs?N+X$AMVMPK6%{9%K&W>OVWZNj`8xOzv}Mtp_o)|Jbg{6(XL4|994Ks~b65r&-k*eo^FacXyTM2kXCK@x`%LvR0&^D#aS$Hke)0&Te;vx$e+{ZKsc zxsmq;QvKi6HH$MPA8O(RteXN`*a3<`aM+?Uo%OC#;D@tC@eeb-fPqpUN=QHCuId{oCgX~n;N5K7R)=@?;_DFfk#HYF|7 zjrdA7^jt=i#w-$QKVn#K+NG8tyC6Y9wm}Xzv9C)LYU$vw-6Av}>{#~aD1U>!R_2Xh zQB_tnH@oaE>sqO|D-&)s=Z7t*zAo&3NP;tET1T4Ul*wYQ#)Xi2nHzbU687Y)V=B+6 zwSPl!GIQkRwy^p|lpj;y1)j^@Xv)XmPHTQ%fyLn_J-Zy*p1LL z5gEU^2BV8~hUBh~;AW4}EpWA7E0n_k;mJR@n=-~r_l0PMtou{JTl%sxJ?#!{Z$?fZ zx%$7$HE!x!%*MUlMo4DQDv#X8y_mutK;q)JyGIXPlcU~|^u7vNj%XO=!=TyF89Mlb zpK)vmRnu5;8*|j=ll(noTUqkyPv(vjQF37&`(n6N|(x>2|-##v|Q)iuoNglK5K$wRW4wy zAlsMB&B3@1R6oT;Rersuf~!$j_2oa2eVaw18l-o%&920MJV58T-@JOZ%Io9GbH(i| zHYcqVyRx@*=)`PSkeqpLy}SOct9J{Btlve};s(3Ty_((;XAUUTg*{1(qe>rL-$Pg~ zYJL(B3qyn1T>2sONKsb%ma4;(_8m&4zjdHT`UdmWRWu)>q8&AIw4vz>shVAt0r5HsojUV2;-#*YD;YTa(O*i)7^o}s|`6JTz8@_HmD)s5%oWr)E z{3f*O|3C5$?Neq`JcCCr;7~e}1Hf|(3J(tY!prhsm@6bmpN`b96d*P)vrBSexTuh+ zlfC3X$xz{%^xYveVxRo8-CnP{=eB)-zAT5TgT;+<>1pig&o_Bn$C7)8wY(~s!-_|a z$b3F)#Cvsn(E`^_=iDCGSg)uG%?shre_^1_Q9EA~5E#>AgT^e7$-0k_!`$yyveFB%q6D{<+Fy+Wv}V>P*EQl&|m{BSIvMS`%cS z8kdL|yQWL-E{Y>`Al1?`5l_Qe__$@ms3)xox8v3x?R$`?7`wg_^)jO7Xx%{s-lA!7 zKhwMJrrBb5V|mY>ss^WSE*6=NH8Qm{Mv?z2Ko&MEn9a(mdjzUXM@bV%1W0pRLpZcc zlv>r)VJG=CvZwnm7C+hl*2Cicc3Fjf7Y9kFV9JEUgfZ%cPPKP@roaDA9xfYjsfa_2_m<|6@*>BT zfaebgTu_pQe(-I`JFnXIUZ4B@|0hidx5j@pA>~Lo5D0>faAiZ%4cFQMs5l)!OG*Xq z)@e%*U4e)ZW5M<(lnkU&sk^Kq4t}twJy_A`Y5nQC=-wmDwH2$4up$+e?6YV)TU_t? zXvHJ&X~M39L#8DHr#NiC3h&d@y3mMbjZo{MpQ)`LWjvo?J>BV@&}6}X-OiGq`ese3 zQ7NbByAkyfW3zW*iIg{TIy&||rTOV4Fj!e|G+)VHJeD)f>z1Rx$?C91{=F)$`)w4t zpA*j~Yi{OL-DTZz_h!te=$$0m(Qi>toN3}MWAds@e6P4MjtO>oHBeeoTl)`dPRV~9 zE}=hZn5^O~VWZY@YFRno!{DeLmMirU1W!gqn-`NBzicMq=G>q?EHT_M%$mQ?{-J=c zDSPB4Usm_)dM`u|Ildk&5_5WwN-Z5-?_oh9hj6QD4GCe{rteBPZKLQ1_%+03cK_^ux%H!-kI;C$k zqLj_f`Hb#m9XjZ=F^&6*SQ|(0C==RLY&&*|JMHlPIJkXvz zMHRg1zao@{hOvj2Ws@X&c{p5&NY#K(X_8x7GRX9N!aus3C$9NQbV7)tdDv^D!`r7> zNVq-g_tl+iPORO%vbh`n-jn_0wBJkpT1K@uDv747cJzh$V|ou zHJrYB7VI|paRsWGM-v2O$n_))A5UbZ=A=v;9gfI~%LrrMs&JI5J%>*1TYR0`{JgA| zqwMSFu-O39oFIC?rL6m0pxIkD+t(0L+QfFkmI>+^>bxqX^!C z?+kLJR=!bg1w(~y2K8}HG14d-n#FtdpNJ2#Q-la!;p+evHq0ZHVVv@|u30a7`Nfg? zrju^3+ed3oS|v{%VNB4Js&a1`da!X_h4tM5&KX8szg0c{`Fz8U6j#KN^_)3(f=OT3ZAu-d?TZWY_WC zSgZwG;02WM=Vt@wgx$MN2yQ)JQg0H_s#`X!@bF@UcV<;Pq!v$KJZSyDk->!i4;hT` z7<3j5hI4Co7{yORhVfY50jFZ8=1Hx<0a2 zmDTjc=0uJM<;#baPb=EZhqt_X5k~c=Da@jHUQwgN@d~Cjrl_P}XEz^J{pJ2ydspCl zwz-9#c{P|~`cyx8;=Zh8q4Z#BEyq9CFHYJ{i!9WqsxorpIu_<6aV* zoS#TgJd;aF=xOR^J{`-U?H`a)diymdhnfhGF2*D!wLerLk?Y&y_bqaYIk8RK%Im$B zpGhtUC0gX~YZmf)aQIFVh<=9GSTy4_&jV#SB~-Q7&a7>DxaR(Z^{yT+cK7I&aCkvz*C zX7ns~RT2)eKbL-r>5$aFTAYXbY2=>L$$a%tvfWf%(Vi=FiMLU|jZ~NV{3eQ|0>ihH z1tzP8JH{K7<29$RC<4w@x!v#Y^0=%md4|6hRf;N8Ch9NCS&jPM_M0^To%%d|F+hk1Iv z9Xt)m{?-2|aQc_KB3%w}_y^ZWHP_vv)CMkBJZN{qA~E{wU9YBGzp;x9=iD^EJHKiy zEwAVlSNzU1pAf;Z_ zL_dJGpq{Lnqf@l|UO(MzBWWq8=XHkEErf1|y61kIJW1K;Q2f*C?fZPrxKIv(Z4Ku~44bxpkgFw*MEn z2k+pQRqmi^y(E=w_I~C*&#d=|qrOU~w%~sVd6=}%YpO@OE6s|9AirhN?oyD0*qlC> z^m%E%KAEc;R8RYKOU7u!W>&ZH9^iZ>(k3gJO{K6bVEKpkj^g8QPb$iXM71QR(pr0c z%_ikj)hdiGU21s1HN6O~=-tE}Eo*z;?MJ)$?Dk~{kG=|!@@Dxlz$N~KBFDYBW-F5p z<%3qRXR{xvpmtSa6;;ONjP@a%$B2l$Q#h?5d@xVnH`5YumW zZ0gN+$8Vj|fr@&q!Yxs$cN&PxdCfU&)6`yyLZgKkvw);SDrM+(!7T5#V0iLr@_TG=} zZF-Mn=_N1;GA9y+JGt_IFEJcgZx;0gHL>6~Gi6MB?sG zrl}}sfTDJt`M=WuJ$0{)sRMlIn!=oS7FzKt>eakEG6VNPaRxPoZZ1BYn0EVc37u|? zeE7EJpf~e?gul!7W~5Qh!Vi$KaLi2|4WGxm9T#8|HHd97FDC!JGHe1lXPhsMOdgsr z$B|-`M$D>0`(|&d4c9z1xV+g&ptVBqK<=frk5j72FLUV->1WWYUy%?^UXydOR+FJn z(#IEqgxg1Mw$OIXZs=|f0T(j+Xt}|ot>#zeG+}f+wZA)5Vc0ra43SkUs=LKsg%TY?9VajJkR>#p0Xp6B>wWtlZX}gzHc1>;Ei;d|0)0Z z83`!LE7xOuACUt~Xx)t>w~utENo4h$-_WA$Sz@4+`--3C72G$m66X3R-ZThQ)mrvw zosS5A@-`xfgr=6CljG`x$6t-b)}i(dUTSBfi9B3#nBKg(1jCJQM(@bZ(&w-MH7nBl zX1GPqPd9*P_bt2gN7!ATwWzT?f8oR6(_xy7rE%U?cQ%XS%lR*^BFNyOkxSrJUjMBh zQx9B#aEI`2K;DEqK%1BfqL%kRPFtG%M}d?gBacQ!R*B+8YHqfA1gAZ{oWW!l406@29FS$+eVpP!5Gx%jj2 z$==+heLGdzbN|y4Md^1&W@=n4?N{Urp)(wLHZuBM(CXtEP#Vw~r^)=*tHs?#wY8Lr4rA$<7;=m#%v^OuIHI5Kv6l$tTt&Op4&wNSN*koZtpm3}MLv>xy2BGc}F^ypFl7e6@a zz~c{du>^;M4w35mWIr-ND(HvHXgY?h$3w2re*Gjfos!oye5;*$>IvSp%yMK=2%E?lL zy6M?wN7qs%cULO^-1sox{cpFgUaSZW-SqxF?|SEvp8kQKflC-h@d-3)C%m@JkV4ZXctAu5?PKp{LWN$$usn{k&)Tf7L`>DovCWN5}%rb^m?5duA1-9mxC{+C)D3XfTJ zzfZZobJyShbD2%)ze%@+WPj6Gp+EY6<#rl1eHc)1>>K}yA9fbcFZMPgobJ#KaRvp ztRF7SiNJILLdpZ@BuOnky>LHFMaZsUWndV@6o-I#1~^tyAV*~mLHuVM@@E_2NYgoo z!wQ0q4A`%kcaRbn+C{=+`UqGn!M+EfD?q3YA(esp!uZhYC@=})uEl$&@oA8qGLx;% zpWKbv5g7-Ww6WO&`gHqLccTXXFGD<-joX{>WdKSAepz&wY z61?4rQVO({dMmgK^8#YJ0khI@Y5p6#5J`QobF*IRS2h(E4;HmdNH!p&x({(q#tmFq z){1BiPI*s*Y=UDw2^O}EMkWVBh6+Qdg4I4E8eYHa6?QeyuSy6H&?XVo1!{pT(oa%l z8As29e<5zxc|9w_NnE(uzl7X)))baqKcEeCodTH+h>)%Rk1D>^CZCHz@? zStG{@Z#JNvTdd1w?s$LWtsVJ#`?eRcm-oHFl2M|b#3YrlLxYEvu=Vrbf*REQ zZ#=}J!AQWC@t7vOKgV(yvF4$@AWXmqcDuO4Ysy%)g^A+FRkyY?>BMb_hp!uyqiLMm zi#>=(iAb?m8@?gR<6WLjt{vZOaH%)Ej8b_y3iL9MdUmNyh_1E$(R3+`!E%1zz#1h> zaR_1?fH70_Kwx|SZr`cPrEfRkMp>69sqV-tz>4hT)?ER$OXWXu2d+bRMEeoh?I4p~ zd(vnXMzsOJUu_8`QjHJXr^-|h4g|&fe-ZbVQB}5E|0p0JNG`er7LC#%A)V5o7<5W0 zjWmjMH-Z8J0tyDwCEX=u(bA!GNC?82_fzLR=ffHAhyNI7j6L>v#M3~h2aq$tPR?nz zVUS?lISqGD9o)cNMuR`tG*SsUeTH)6!RT=Wjs3~VNy0Z!Te$f6j)lTiN>&7BKlmnA zfvE=dkirtixu|N83NsAAdk>%a7YKUp4rWb_VV;8U2W;}X;DAnr=P!@_Fz4Sy0;zfT zgAJ3*U)IJe-vTsrn+BEs<-KXc&yz}1YMa3<69_uptYo3u0RK09j2{gt(gCMZKalLw z3s=Ct{K0cQ{rvp{pb3p-e41V$rjwRGg`04l?!jKUziM3;33}yGIV94m1sPMmWH6WW z|De~T3n=+z@f<%v^WaJ~I37g;NEAX=ha=X%f$Ff%JmTJeNi89GWdEaf{-4~`%z{Wp zBs|yj;e>+RqIVE3Ie3;MrVx7A3RjFFDhWz( z&PIr1CsmSEY zCM5H!M-;&<11AVV;76AR;V(PFU9nIAX8@#gm2IFNXdRpP97sS1Q) z;)WtH2w^)T^v9!3HNQavq9=pF=?X2LwxDaIKsEP&sDe* z9-F!ZWpiXU?S-vx*%HUVdbMZLxf>uEjbn#YnkA4<_g~-uMsAiyLjOiH0enN;qIruY zdFz=u)iA}!2C*}s`h?IlZy-okVRzmV=UG`DMNo*8%TI97s)%Z5%fzxrGjlQouKT|y z?d-O?EH0oLI4XB1+pQVPiVCc-w)|W^H{L>w110S!sATCHK`Z7xbF%mA7XmhRgc+6_rKz zh3;*5Sj3D{Pq#hDPhqEX=MUKU*`d1!8nJ2LRbJLjkdj|Z?rcT0641oK>jMrWb4hb~ zY>7;rJA~)ZaB;+hx?^dX16brpsl^`{=x?P`>ZLLSvXFu9s|CNIN#lt7izFXfZysV! zhKi4bWrROxlyD2#P=&J7T@{VyXpIEfs;)rEeJ=9{jq99*KQQ%jX*6>GUo`o~YiOwS z=W4+}HwH`M$iC*$K3lW|wdl8hi1H4ByJHmw#@F6ospRT2-i8h+4bwOok5XnX;Q~@P z2Uu(IrkE$hTlG!?)*5{3wZ5KpHK8tDVxr9>e95Am{`#YPE>~odYNApwZm4R~-H$i9 z)KI+?&uo*>iYYPNp=8g5|711&Ijwwj?8k9nO6}z>eI_3NaDU8i-JE_6B!@dk8 zCc5!s=Z~|-OC>=g`Gv0Ra}g&=r+jHM58R6l@M7dAHgE}4C6#wnu`1P)Rxj4c>=#~# zZ}57#v2%Ch$~~b8cy-ExQ@O6Ua+puMv=NDKzuVSUF2TOZN8Q>%b@{df;4oE~USu9! zQ~M{^vS}{UK2YG0%^OrZ7%s@Y$ZpxgBj9$OI#c*l;#6%X$iT9NA?}J8?M)H!)uw_h z2@iIDRdIF@a^_}YvE`A*G|i|cu$Az5AW0|hX(6;?NtYzx8~O}6IS2X zIRO~fe!B)FxW3#Lcq2DieCarF)<3`^skWn0hiK+mqt5M`;QI# z@Nu=_yRYKeUnizsiHi>Jw81vs%9(s-`|&Ai^C0GeHCqpgc~=V9s9lCE#3)v4*+gk`C0M3iZdV$$7qJdFP6*U*f3=wdu|K|TTxoV- z!c(z{jE?x4tcwyp40#|oD#cX%fq2p8bKbQdq{CkGJ5fbllH2XHVO1?P1FD*0usnv4 zsAux=@xeVN)qFf@F{rRm#6Q}gC zS#%rSB+bG2yXux5XapU{XioqXOz5mo>&u!ZbBPAdHh!B2RM?2ad3AfoH5By$YS^qC zjfGMC+KZMUZPMA0+hEx=@Djg~b~kG{HAM2r17^5`DKE2%)mG4-e5Omhnz;BB1PVu-~(1*fg zmN=*4kkAt6&3W`d?6bMRVp22}FWolRuzJsz&1jg@m^mtqbEgn)r8wdS?!+qT+FipAB3%GoK+VHttb$F7@AH$Q=6w$bd`~-c|wx`r| zti?2vf_MdXr2Unh6^8sAv`*x+xPxBT;h^2T!1)Z6X!)`CHgfoRw=qr z3TEZXKHLL4YSQ_T!pJXk7Umfx2BQQOwB=#_asOfg--FZq^-Ih#hm~;Yaql!j?@sw7 zVmUC>TKw5nk4ru`zgb-kCcaHtj~&BG;4PX$wR5>Tw1r+c>IdWq5#&YmhehEzo&QGV z9+S~=hexO^6P0Mf^8O5FvC=htNB0LbyvP=}wMXs2JD0!XmrktuZ;d9ct@hp^yQvEf zDc5;@j&ZFw?Yo?U-(9;d9xC!6c|C*^H58&1!lNX#LhXxU?FMolDep?J?F8|sSV}BZ z-im{Zc@KU~XpTqa);O`w{gK;BOe6@Fg^VQ)B8&C0W>55&h$OUKH;w$c9PBW{GDkd< zF#K^``vm}k$ZzB?GTY%xx~Ug_)8MOZ-!)shD;$;s9aGJ)gR>bt#OP1E{(-0+J~frE zh-R;%z=S$9k4P%u18Fvm4}3PphMvLy~MMah;@3I;6svF~BUm{S_ffL3v zkt&L6-km1ty5;UWOHEm3Z5&5`oY__^Xy}(ig-2AsJ0R(a#U{0e!heH57$uPG;dvDW zG8khVv+FzW$!BTD=P>}`-5p>9c#DlhK;$WzkxB-N1`rBV+btpAPAhn_@E_q{1jyEh zG) zK796a4Zh8aQRCd8vJ{sZ+?8}v04gZOxrVvSFSKOixZH3Ja9yfdZomERzQCpVBt!MC z-6?_teUxJH&qbcRtLoooGJGE~etZzbd%hqGS6;>PyK7D9QMWdkOoZGQjw)*BiI*y_ zz0Y$!Uz;$Kd=ynhJ?Q={Q`HDv;N8f?Z+J8CI0aul{T(_(UUIiHfoMULJ492ch`=LO zF&I+jo^~zQVA|&_*~$rQ1Zsr53pt^&2Z_tadu-0Nng>gF z<07P(AN6vH!iZ9JO#mr$3pNru6C8)%*AmD`@5DS@A>E+QT?NN@nXvqOm!U33znGot zo1RwCod4*RHwRRsFE(D413QwJ6>FOz_sPdAWZ#vgjQZV}hDHx5X9rDHF3)BvDm|u; zGrgirZKgfV5?aSD;+De3OXL+qrA%J~BHpQe5pSbzU@G1!eZWv7=KoND5@H|9;rDjs z*mt$cc0WYw8T7w|8>+Y4umu{jIy?7U^ep+woA-TEhbipPN$fFugX+Y?3g} z2)b)>J-Lmm{e&>c08vhliIvm z2oxGT5D%eQ_H>dT(RwO?nYih;wmrAUd!xok!7c061XT7pnUJPPZg@QD1+&jBEdO~z zHSR6%*HaxSy2&1|x+NC7lP%uF)sI-fw6G5vgDF=`!$R0Od8FMC*D#FhX^=pyhBLKurZG@C)L5WP;B z_y*h@ko8v^wPSt}Rw#X2*Uv0jJ~kRAR{v6!N%9i?xCxZtX)UDqVJKgK#@ClMqyEMn%#t~gyLg{W&WZVwP|kXU&G;cs$QiLJ_mPsgtBZj z-A1h+^mmXjrdVqfE3?&O&*3~cuie?0tKwh*tzRZ$)Y9F|I=ct^z0_;##SUm|6&kSl zzuAwEvVY!$T-^>Xn+xGF4B2R%vrfL*%;0P7;(gCwD0hs0oWwAa0dN7rylje3#L1~5 zt15t*Ff3-@pUPWvMgR32=llkS$Im%^hrLNtHVdB|TPTEXj5RFANisR^bdSl?LZ5g( z<^X_K*r&jHdp?}$5i_GEkHnmjU^1RJWyU#yg@j6#3UcZ$PUfdmz@>omhs@TRne+F+ zs3L-ApTpht~se|0dFPoa&7LXa^W0?^FzZXAn|`7s!qZ zVNDf}VMX^+B@4dpLk5an$PfAgkV6vAuhR_tP(yR+H=|nG`zPn|#vDM9MJNq8-giI# zan{fn`55v20A)ZLEHn;2yAMiVb7v1~D~ZOl#q)e7NGIC*WVqKc#*l+k#WXsspQgoS zAE3kz?>NDFY@vlo^7qf(ms~bx?@<^6x>CD7$;WoSPxO+{X|QtUt+g}Kxa&L6d_UfM zd7+SXqghW!TKTK4yeofT4o1Vo_Z1Bjy%cxGZ)xF3~iEH;s@_T!}4YBvUh0%86 ziK0?iqg>s~m&3N_iReOf=+nINh0{YcGqriZiKfaZz+usdUk$Y>&6{(!2UHb)YBZVg zxtK2Uq=7M9G}pr1l~{*e{pMMa2zw}W9S_kAaaz^(_S-L^(FfOW?gGG9=C*KMT9>LY z&^cUvhD}BQ%l)=aGn|8ObxgAe^fYwnp?jT52P0N#sSUCqKF?Ge-fh<^TtXVs^_*sT zSAsIz!ABF0)w9CS>NvE?H+DfC01&Gh)&dOTuVNei>`#bVCtkItc@+rx0dIjg@b+NS z)Ysw3EQ02EP7mi}uF?2}nWXbk z-xrQdxS;Pnz`5{ph;T@in_jZUKV77BD&l;sJ{BexF5L!MHO2NOV{+qSs*!FJHj3t| zgpS|b$fI078e{Dvp*MEy!WA#}BE)r3#*%5Yr?+QE*f)<|C*mw+7)RneMZn? zKvJOSKPX}7R|Bpy*JpN=N}i<4z>>Y1Sj>x;0f3WSEIAH_v1*w8I@r%?#-UA7AvMVy zhobe^ZMta@gXh?4M3Stim}5$buCv4_yLjFf$F7((3#6sd_48zJ(dFphdlAkE@-8hU z+h`gqa$DNgJKIGDL8b*`G}`2!3TP5(HpR4mlkV4TwGvE4D@p}q>41{$BW1I2&CfS) zX*Tn4WULgghxBZRD`Z`IF&e>7XyrE$&PQAJu;SNbfUm2nirAbyzIzg8UI;0tTBgjQ zKA|b9hzoP+qYQf0Yi(5Cc%J7YJ2|ug)@>`aX_377<)cF4f}?@F*31L}(`2d1*(_JP z@_(>UDPN_N<((^0%~R;i+(9c2e8xT464Tuxx00WHD9WH(#rgXOJKlQ~JJt3MxJ;hR zSX!mUq_;}TEb3C0Vt>kax>o&>OHEqE@A2nqJrvvTikjmM@}MngTJTx-jD)Oe_4G{%qN4 z>5IGrGLr!}9>QOajy{DXiUxQ#@lrbxf4;i3%8|^Gj^8gNzk^YbnPxr~T{|oc-9l_B z?ay&hC*Wga`oXZLE;IJfqa_AASg7D4scBgcS|NfNQpy7$i!Z`LFlGvxS)hDVej}@J z+quywI)87Y%*z`vN5G1_d9d@tUL0h7i}vrKQ)QkO-IQbUSMBVJQs*3s+u*uc1wxN$ z+j-2ON5^62$eAL-#1MkPcg~k^i$N~MJKYzpz|10b9&!G`AqL}yH4&}J&1%1qX+T>q z!6G&OG_QaTQS;`ht+9rCo4eX@5z{BKS-A!$G`Yg~8_drp|upJdM+>dM#`24IZ_xS(CZ*(nRQJXq_H z(1%EN404R_d%{ z16JG2$KlIo>l5HPL`?vXUkqF8)QW(RfRxd%GC4#pemQAsWXoMVgc}5OpuS>PWPj$L-fU7g8+{-%!bh;i|=5h zMV2fqWUe=j{OfzpG`jH$W8XjUD%fTrApzo7IWPxxpzKrwj4->ZbHwonEU!|~&>@y# zkY9nT5Q&4<`G-MPB4h5jV4es3*Y`1NfcfC}MCfRsH$nIeIU~n<_iX;vRoDhn@I59c zdbvUT^qUZR4^Qv$yu^i971d=cn@E8N#GCagZNnOmK8 z3`3K1R1Xw;!&Cp&`9`=0#p1Aly66NkToJ^+_d$jVh$s_;{uOW}QmDGmG-Ga7Abbbx zzN}YbX?f`Zfzy!iir@sL{mRrPHz6wnu%{`>pgXE;Ep@%09gnQDB zb^rXnWGYyb&YnQPXtZbF_(HMbDPu}D13_rN{xzzzvF{O<{Ci?<2111D-@i!2|K~q% z+rWqU=b*9Fq9KpEet4SsZ(iBeQ+0A7`G5Hr{cnEQ|A{{%A4S%Tu%Uo`A3jVV+znM>p>X_= zwZNt)WAH%X$^Q4(EJvgyW13bu&}OTC({nN?F>irRwNn-AY`Y5y%eJIH_61mwm|pZ9 zG$4Q#Bg!K9Iw662wuAiU0YJdNp;7kv??B;jbDo2B3)}7EhtNn&0u=)ovf2c2wQ1Bb*fC?+5$%>d=ceV5<$> z$E?$yfTt6#`<8x{7@BPm7$Vy$fp|m&Fry{2@U!)0;U<8W)Ve?Y7u?SaF;mHcMX0PoaGQ7HH~&*+g{BgJktMbKr^rk7wpHB!u;kBgP^El?b|0>xe8nKY=V0*% zjtlrLcUwl3Cn5I{+%m3BZXSgG=z@L^B3dErkuN6408%4vV1AC_0b#n#Gsr7Z+6lWv zeC7cN6(aabV$9r!C@C=dz=gBF{bWMnm+Lvbk9}S%df!e8pN|%^L>;FV^h^hwY1Ki3 zpMHs1yLEZZv4;)mJ8kld_%nDFa2A0cqe+KZaq7WmEjEX?xBIXYj)5uwA>H1>jjCr_zIxCD7o&ddHQEUX!Kpd1iEC zY(eYz`sQmgn+}aPL#MJw3*?&>vm6x;W1q-dmqAc~NNR)c!$DADYFXb``UGfcnfx{B zqlIL+d?A8ioAQqFFvJHMG0E7V4hxN{H-N}iYe&|{ipvg*(9Cy0fKvySbpcPO%kqF%~jlu|sUkc9;*c-lv zEWuV9+YtejorCVYNAIjc29Q zQ1X&Llx727%jB7tUKU9JUHv>trFPfJJU5ZXp+)x%svzcWAa|)r> zCQ54{n%fT6`O!UBne1`Miecs`o$Wp7^oRSRoe(KQq9$|WB)syj%}}6O`DJEGa0VB^ z=-c~TQp`82{_?dmpoF`%63vy~<*qwYvx)uQf4$p60`B+ zYu_dRQPWcByC!7a*~8k+P-@d|lQJEC<>tz*p2eIk z$D_S!sXuyU84lsm$2P79t1=L|45%H7^{=mp#weE@>WR0@@-^(;7>sxIRcH@s3A=5(yTF@eIkXUQpG)SuPZNGKL<$ld9-&ID(KY%f+-OMV@PWFdkaf)Iu z;`|iQ`qBrJ2u!Ih$%f__Aib+>M|0>SB9Q64oIQ*AIhU_)O~`Nx{!+)wvWGM0(!YUZ zNi7VUX_(v==j(O1YSPjcRMu}7~M!%eiFc#RP~r5;8;w)8EJM1qUz=_1_1)R%5@b)K2Qm7A8y zS#|NKD>TG8(hCVS8jt`+9~2ho1{f;wzk%fB6cG{}O~|zd2ST#O10G=pmsT`Mi!!#g z_Purv+{V?mXMT@zasKpNJ zRHIo9`Wy{0cBR=vvxF`%doF#_R~rw2Zm{5FVz?Yxfqiz+bXHre{Xha|6naSW6P#;U zcX}nIqbb^WXP5q_XxWpl3*QM}uYJ3w%5Lg4 zn}PG^1FX9Mf|b4FqN=@o}i5l4$aJQTky zRykt>3Q(vV#~hkZhX6VI7`1G40t9aj6v_wm)wGVG%>}$xxAkF1fIjewBD9+AVTt%w zs4_B*KX7eZkm71!@PzmU-KH!Q=`J=RtK^qw^Zx=}_j=K$d&n(%} zRzj9zGE{-0ppwVRP>U!rH`B}C&F4tI6d8E|^g?JzC@V@gD>d6VDoW_=U%8Id&y5Zu2^%VzY4|VR1*Sq@Gh$3FIKK)^<`O*UV{Fspn6p+DyN4- z&#lL#6Mhgr0#O;X<1>r_FJ11+LANfL#U{~oydh~6pBBFwGt|K}{}<0|s~P;_`Ai+T zA?ONhr)KYcd#Cmt%Q3R9qged&p~LWdg8T8|TKAQHx@1A^_wU9{l~xJxZRGE|mCVEb z8HN8A?;NPU}?}@Gp0*oTvvA+wLBkz zX;cGjZi60bUW&FAQRT?ch7OPNMYmmCQU{HyhH}EOyt$Fmxn~qNbq_zNO1?e#i|ysz zFbBmCz1^iSE0HHf^0#oA*sW)(!Yg#TSd`s0wlGJZZy`Hfh6+P2)eL5Bc9v{HRd1pD z8T1&-Z3YHk4qT;s7q847@u@56oQrHxgXD2q3CAa`d`gb3XC_ztHR9;~vly0p?Gqy6 zdf`kORyp+8Ge1Eo0Wnx@)Fr(JTq}G~*uY_IP_nw{0vOeHE@`8|Q1SdFaSdse80FM^^taY+akMy;yj3_gm34-(AB~j zXfH?+`n6N`Re{f=Z{54WIoRQNFG)#r`60%`uR{Axb~fHK^X?VGryfB}Ae17yBd!3k zn~_x^E$TPU=}cM>a?Es@Tj`-!P{ssV=T#DBLuiD7GEOFs5bdJpZ}BD1B6=BmqMnu? zEjVNGTk!K!=L{lB)MOmoL-W`}Oh>(kApy-1-F&DKX!febF%K_{J{)A4MJQw3$cXcc zcQhqlL?M(hPcHue*G*FS`|d=MWo7`PX34(Xij&+BxU5w|mK>*Hv4KZ?A=oK~B1-ka z&1*e$ej$TpSh#&lw?jdMO?e{}w8X8rma-@s@kVT>?R0@d7(dVli$`SXL=My^Tq%Ua zf>zi>kI86+x~pa%3Th6CsdQYsCv>;Y{zkGb3R37><0dgP$QiN3s2Y|V zKRw|>kNda@)6SEgcusIecQB=|iwuVhCbx#x@x_(U!BtYNovr}@viR?_?s}J?#Vp49a9Xle=R9K(b zsBsZ>ZUV)cU>upB@NXW4sYi1Rcvs(n!8-WUv_xU(2fOOSsLX=2!h8lyhyFw^9C->l zK`PPlRQ-tv-p*~n+fki)>MM6d=~MRiU?HkVenp+9VcEfO@y#E`evPOU-Mu!E9cev#Vi#fng6i)4h`ka>Hj% z*m&FyQ&fYV12M_oPzRzsLJ5a?nt0=R?_?~P07>b|#tS(OaCapXMBrw7J zt4D5q|C4jxLpbM8F@JN;p);p==LBacDmB)98{hW-KS}59GSX%MgDf=#f2|%lb9&Sz z`^ky!ZBAz^bhCZ_pgU6I7;&}z#YD_UMj7-yA4smvWE?-|6&HQM6wfm3v#9jU(Jr>c z`3FKihANnQBxzJP&9F7?CzRYA*S1a`RA}H#U|XHzB)9L66{@tV<;NxVt4Z6VCT9C_ zU(Zr7hiUf4hvHXVxz9Ndvd_6*Zy_6f+Sqwfu@!Y@O~IjNb++i!MkJ=Yi9TRcX~?$j zUUX|+xuy4Td+~3!^1l3^PoG>k?XFL@K{1?g)CsP}31XFSU#hVF&E2=$4dtMJ2>#nk5w)6ZFo^e}#rmKIs<&~AvinqS! zxbD_NLZ$n7TU+EUoN13)>J~){*XQw3wgPH|RvQKseHi5Ncm1PycatbU|ig z15@0XnH;jrJ_@$|eEj`Zmw7&OM12~`KqS*giq#_IAGbx}TJbBhV0qQzxEG6<=r#*N zWFvV@zd_+DRJDEIICq#uv?4npK&hno+xxBi8Yx`zomuF|7@`9rfrOWcM_O(%OepQK zDOtjR+%kiDfxT6hdDog&jnviZ9oHp_vnSYeV_4Wvq$M;TVf8VI?4)uL1QHFsM=^=K z>>piqL?yI54DJK*zQWE}zFGaf%pahtVtzdQb>EV+O*^t*mz(QepO*)O_BuAB?cPG# zN)-8fJ2&l*O5Ahd#dyvK#uZu?i^-VdMEa=~1Wlwuvy%`_-U>5fOM#Zm&PhG-9XkIv zd~_5+hQtQ6>rzFsxXbTyn_ug;ULZ6Dl()iM1dCBn<<3L!G~(}s)z@<%2VzJSQs7Fq zqB6D^y#7`mAT4EOw3XExGHJ4dCf2AWdvDt?munrZYW^SGbC^)r+XFN$38dfCw6vAkkwKMKONIoG3F&buCKPa3H`|C#{RY&AaLb!f@SE@u+4nN5v2&lh_!B&<|dl+J7LiE+4>^n^#-Zr6j5eUhKgVAHTU6c)Vi? z3N#Sht0jmdwPdIFGa(VYLM(sm%5BH`IIbo!v~_uircTBQAJV8{EB;4rv$@$z@bRkP zjV=i%|0Im##uN2=5am%GD<$jYD4~fZh!&0!n2B&lnC;M$7P1eCty7=Nv!-Q&*(Zd; z+g9^#0YZ=rz{WG!z@?FNEm#iYV&L6katv(hq2?8LFTWm57MTWY)VdTewU)v}|GlN} zLh9qmqv$$)!=w#0M?>xI-RTA2g>KZP5e=*vXW;>|k(_^s?WF|-lhMd{jvuXL#A2kf zTG`&{mmB|rE8&#h7iO(CtekMg=90P6kz zhBdS?Jm)whTkXaE^!T4pllQit87WDBoJ*jg&NZ!6-7B)93!+feuSUwbcU_~^}L1$?1U)-+IuwM zXZlC||3P`%177Fd3^<+vTi7TUH8aH%m!e`IHUB>3@;cl@zy1PK zjQdh|WRVO9GpoW#&S*|W*m9O%9I?Vjysv?41r-1-PHT`34LK=%*i;YVJd9)5O}MyjN7kou(O~BrFIF$Xt@=xZ*1GJFmDt~t8sonrM{^?>$)mQ z>g1Bk$5Mldhy6F}P3arPL-vAnL&7m~2Ua2|db4jFs{oBTd*@S?>oaCKLL8EOz!QxL z?oIxYa1ptN7zZ<(LF^!9=9Bvd8fc&1yqO*FKJs_gean+KkCxOS$nPI2#4B{|D#XJA zQ%7)(J`~9YC3fY8kjTks<$@5>Wrn|5@RB0^?~r;)?xHM0W>O`_=X*N+9KsMN4?(u9ywDZ_Dt+7zy{EZ`1olSs$GGS!2AR35bS2o|gFC)PcrP zdg;rPyX4_whZ|DC!X{Ptw@l$?=WcU~;xO_r#m!Q&EcI{=3H`MJ%(*+yC2~XQC4M6je~ZAE=R3DHm*0q> zE#;m{G$wz=e_{XV*j}Mc%dI;mq01=VEn500mbZ^(DRy36 zIG*?rG2t!~6M&w$W)x2!$V8en9P6#?k0oTDLOM4W@i}}(EI92YvjTHb;uz=MKL7(p z+|OJx7D9R5yv9whGFYSOfPUVQ+7ekw?7%(uxxutMaB-m^qMWaXUzsQBfrx_G-2F5A zo83?K8r5TWD_W$->A6~4mXf83Il{>aw8MruEvMBmh6l)>MMYc~qzCg`Oy0GVYGvOC zhmu07$>TI#_U_s1&-ANz)&&0Iy2sZ3<+?W%5B2J7TUjhH?2m@CQl!{X(y^p-e#xx9 z^1}?2aoX`SC-aBlSCj(4W~SBJ94(xmhZwr^<`ina>xmv>*G#XGtuv})MBj|Xb|HHK z_x7mFF}-i60M;3t_D|6+2wj4rr=W=J{fDq_pft|Z>ZUgXlxHKLdoGB)*>zIJ702bx zF_Sr3i7X!|KIm%Hfh(cybgG#R10G2I&jp~3=28+G2xaPl?k17mOKAAUw{aJ4WQ)N% z_6s0Xjd0h2irxMDZ*GvqAo1}T7s~`vnKl`KGy8|NVYvnST>pRh=Y&?AEfAI;eN1Ex z2WLVN(TeKFa0avwF#0oC@E*q`d-?^0N4p<|T?fyd zfsqQ_Fji>5>H&M{%aqvV_Ckm;pXKLWomr|lP@$&&A&c3>Zlq(VZ1X`a2De7U=;h-2 zGO$0r7QdW_x>w_o}jB4`zQP>L0!=LRe>x<1eZcSX=PnE(Q_sfp1$GBq3mMxVIWkNZZPJ86fgjNQHF=+jarZ z8)&AX1}bXkEdifr1~L~|rZMs_vz_Rmb{*>wW{kS|I7uWkk0bTw7UU@peX<1}$lsb> zb_&F74xYWau;=_i2j!iFtBs&$1~zXS_{BWn_hgJ=BnAC_cNGK+o--@Z%AhLS1P6E) z7!7th!6tnzL2^+FS~`f40n2X_499?m|Hsf~D&I)Kc(n2&98>@z4}U*M1zEx>5NS{< zqS`lMIE3sz&D&sg3(emE){mjsvf5#6?x@@p964&IkX!tx>J(hh{>qf+#Xu*h3h*K9 zb)BF#^?m^b4hWxh$hcj8y9n}MLggv2n!kPj;U_@4V^xkB|DK9Dhs4}0Ob(`Fc;XEJ zOTh;1r+^+_88H@yK!$H1aqxr&;jWn(q(gtI1@0@l43oEsRY(`Tp4 z|9Y3jjq;nT#L$K~U6|U+A$QhT>cyg)IWA*kP z=VfT~N8uX+mue>;;_rtidupZK-kQG-a3U$-WxY@kKY+2wNoBe#LO@DAumZHN5#sNW^zV!Ob!axI2t7@8j>triX? z5F#s@-{Ac(0#l>Ct50JMcjx%QigMkv)W2K&UZT!ah78JL~46E`JX= zd&tj267y*Q2pq^?hV>BP=8tAWsl}fGnHGCdl8Scw$GSu==GKI8(UiN1&yFyFdXA-^@dx=!V-OmJtgz__Y@b|D*D4 zqeYM{+}auN*>hJxjB#BZP{Zk=Y$pG$mL_P#0Z~tW&=2=#nkr)Ah07+Qp&xE3P?J3B zVNb)p<0i!A?uJOE3WpSTtie&St1z4#DjLp_IE*l9#%^jApE947p`EW`s2J^AX8^N{ zGgAJrB$gI7j+ns*IXgX&_1c4X_&EwPAYz^p@V{{JWq7_zBA7oMHV>*Er_qu?6Nro< zYuiV19zZ@pWcme!$xq-oeXhqZojolf&{l3YL)0R0nuh~w!&`nK9~HdG@bz{6ka(Ms zKa&N6e+ejl09r5jP#ZXz+4L~?50Qg72;Kj{ELpe$o#4(d=;~rNQzyB8d=(Kcrv^1iAf=TMp zZ+N2`@_XUmp8P1t#vL*5sG<7!%xeiIdmZrOGWunJ^b4}cKS~MEE5RM2XuVKoEs<^Nb_=2aP9oPXa2)A*~7SLMVji|l{HW$i`;VGzN;yL&< zW^0v#@%zClQd73vAQ3OD3Du)W`; z8iAbVTPeXsNH{AfW0h{*(7I#d@fFnc4f*GtZh!y)mb*CIyKq?qoYqip5Xo1m7ojg! zq4kD<;J0fKYy~AEru0rbZ>=zlflxypLH)M#wll1jp3CeUD!k5mbpUF)c7Er#&xSP< zaMS1Mxb6lAU+H`2vsT(APU>g{K$PGBr{NLtrRQki|2B3l$)??E)+ry>U=P4mzOxSy zT#&Kr*wI|WFJ-mdCefD2l?*DfYze}NX{;#+75sJx zscRV91ds}}VJnZ}nXZnO&O;S6ID?Z6%SYj=oj`(hI^5YGsy7+IJPRll?7ktdbD_Yo z+=<8Vul(hYEJrDF2Gn6KH;ja70EI#mJ_q3;=dEL85#e?|Tmwr1$t(0Qa*jmM?O76c zftp4{TNW7r)q)6ab-rMwk^vFs%#-g>|Dg2P&V3K5QT)CWEB0Nk^}1fB6!#pY>Ulq^ zKQ}c27J?0oTRpdAce+`zsq5Krt3N=ypda8wdk8dGDTjNsB$mEU4> zsUdf0w7&Z+Ulht^+h_V!8Vkl+d9$29L&QG_M6%$sjp>Ncx1)!XEANlMJ(+5vn(g~Y zm6`-OxXiw)9>$=F#pcL;t{JmKG(Z%n6((WGC(^Pg0{g;lG`{kN0^)ZJlu9ge0|{VH zxZ_WfVp)Kft2~u_UhY9QG(~=iYmt>_N^IO0W&q#uQOI|qk-zg!Kk!I3L$pZiA$q4* z-w$B&l1vz=jhq|45?Z_6g^urO2Nuo+m+PbQV(FIWp;pO@$O;aFo^|{-0t?05LxRm` z_mTh3F9eO13E)$p&hmjKlT_LJ5{PrIyjX=-gPQy!E?8>V$>O)!ExP|yFtZG+_u86? zeuvcjaC2QRYd7_cF;aiYpl!I^nXxruL(KZ%L({g^kYYVs^WIyzen37u2ro?HJrVF`{oKSA}scma*mlG9z825X2yj9=k?7MQfEa}_NzkRDj z_Z^@Pt+lbJTzga$i><=?E@gbkn-^E@NMaTlB1kE3Df))&m2u9=vFtx|)y{dH{Gpei8OudOYk7A#|Maqmeq*}EK^=B#Z#dht6 z7s2TueO}rR_w*E$m5k{=uD0jZK5Ab&$aSOw&zg5YA}9q|D2*a=QgN>s60->JD{@8N ziW7ubC){38HA*c%ag{}H&cQz-X` zJE)T{#tiBEEKEa@0B_JP6gl5U57tT38kbp6J?Gg#rv825N}7lGLu8K_rMz80Xc)98 zaH$^d-F9P$O!0>8RFJqywdx>4B}!+te&i$TL>+FPsfb2y{dev178E>CZs-wSmLVwH zxYS!aCNyi4OKPk6TxJjKz;XE%u^FbB+csEe)+7`4Actg`Hq+%RsHxwli2qLE))Uyy zI`d{Tl^N)Qx`_9E{^B?fx3)R-$A>JS%aOq;qc)Qc&|SVQ;3h1UGlm!EmiDn0UHZ7K zeHoMu_>98(`wb-0{b+eF}p+BMPYwLZf{9C%m3nEehG_QJaE~7}o zjw1t{m0hdHUV`wKDj5;Ez5Wah!W?#TxV-lK_zi+D+=6)8o#X{9rN0}+IR$>}8sOcj z4zu$y*I5jcZM2{f8yrW^j@>wKe8Dg)mZWYPHWpmWc^!o$f^BUa z(QtCnKeW>sq<%CP!g-va_VkFk;g9kO8?%+-|EzVSxuLb^c1oe0mHi@Wo4~Kzrj$H# zIpn#d7w#?EF$bZygk$>7MwkAHTGSPx`A>l68*yiYc^h9w3@^4C0um8)2ja+Ff4to;PHKYdxTajjS>XSkw{eAcNP&&B+bc0)@8lWhf=c}Y zK49MRUox%9B=>KhQ{hlC?&KY0ilMy^#V43<`{c36*sx1^G;PDihgGl|^a{lhbIai3 zuW{hqz>@VjH6OH#?P?af6zbBrY`Z@iL*#JJK!oPxQ-RJPUaxp~PzMQ~&si5ttu&M_ zE4(&Gpz4LJgz-P>chk<5K4!8R)IyDL^7J-4EH~>b+!#^8!j}3;9j)^R^tzGjk>^?K zx2v}(Ohnql+$Uw&XxnN{Slb9gUGC7DCtO%@%uo+^zDJ+WhF3A)$RPWOVJzOX@=g}E z7+&%A*6kLoN}I~~3DmoGIJ9KHnvI0Q&uxY`E?wZHcSy$JdDK$$$~6fu_eJiQIIT`Q z+h2!?^kDY$)wC8NPocR^m?_cgdeH4%y%-jka9;gM$~bJ*$bH7m#;zXrNXs@KQy1$Y zd3j`q;?gjv5k6b|7^z^u=A}x~Q}{GWUnM}`q|Zhj)okN;JPu)3F-4MUy)8dg z7&uBk=v$>GUVVx)5CDL#=qVkXvzS%ZQ@s?s!2l|In;L9g1u8L5Xg+3mM?0^4jNgXfw5v$yz#o{8okD)@qe8b5uI=SHH^tT$xeZFnSYU(2Ruun6dOqoF7oSxw; zZ2ED6?71{4v?w9$R75WzI{S&~oz#h?5RMSL5?Xs%dXoDg(4J0Tzid$CBe zF$U?g&(&>q(6ZElXy9dN=Qc)b(JovR5hLjnnC$+O#RG4xu1zuz9xUM>%ngxGza(=N znmkH4b=%gisy0WgJ&+5t@rA$b1@Fx2%+KrETX(tEq3SE-8p+Yspj#|BtcN~#?LN75 zBdSoo8+W^CJ3&2PVm41t{`OQw!%QdPS=HkVg@(}wO8G*Jypg^-nNjUOkLNPw3U>WB z#~PxaEGBT%5R2gci7-F^oIo&)T&K9b*P=1^+3TDd7MxyD>}y*C+54rx7FJagERO__ zRdufx`!!W?-#hn5B_%48GBiQyr3acPG0qOx|6w()>tzaWk-V0p99h9}WB%8zV7SL- zw}z}A`p`G+Yvf%@_<6}{TOXE>70P_<=Al!o-DYVNLyrqkY^w)*^~@Dbw$1b~PjnyT z{(dKyOZn7b1KH9uWDdPCpPIeXu)#5ZjGh8+hP%pkI!mldkTj-KB{h>BNeInQl#+%c z(M2}#tD|Ke?DMdem8K1(jX9qjiRS%YD1>T$y7TsSEIKlg7!yGqXYPO1obBRjg6U** zbnBp_L`XkQ`6s!qHsPq?0*9oLYcD++iY{P|Baj`eklG1rm)3$WvTZ8e6SO~#B&hn- zp!lhzcxhAUlCT1@!)>?%VtQf@5D)@}=j#+3CPxnLy|V9_ch%?;+(4&%xznLO!QACb z^Q_H6r-ob?i#?=3uXWIN`sMCGbQ1Th4+EV@3BfFVrhCjC>k!SEKqOXA+m((mHePz4 zrsv~KX{!eF8lCZG>bec?*&Pfsa!xmxmGj>P*Ri$K-YdXwIfjz%?hH5?<#oD_>Q2Q= zf(;~>*WZ4*<1Zok&@7XDaYfY^M?Vo=fR=+G?1 z93Nz)BMcO^`f(*m;UAH}z0a>=`!#LynZ-vz2aPR|lytq#(WZc+-_E6>TZ4^7i-LW+ z!d~ydP@R9t#s3+}^57r&4>`icyZB91xtWDpZhfX~b8h6hM2e{5FWM6XlY(E;j@8|Y zp>~2~(LK`XMjSi`_8y1ahoEV{W9*8PB*RrS$cwEDf=KgOO0ijq%+K4{bHw);WCP8w z3B1-mk1rFg6hCA0VU4Tw1&Eh-)vBhC2v6uTC3H+aXGP8oF}E$#Dqm)LJq_Yi6;1L_ zDLUlrPI0-~?QZp42~s)R*dyXbsQEBbAuDEa$v{6OROdVZ3}euu5{y3!ra3mDUMB5y z2>XPXa*Adz-yRI=4S65Q1^v>+;P0T)1DU_&v@8D><0Kz8f#Z@s;nt%ePX1%_I3y6v zjeF(J3xVwqs$65(1$O+QER2zn%Xc-`9mXs0C)SVnn{z_KWDZ@#H}X+y9h|uHT)}*b zXR)?^7oPNa*eZj>Mq&8R?S9|? z8RLv`KAbP-8_#}*%3Ak5=e*`GBP8TTw!YG6QiqS^r*Q8yTUcXNqk6BBH=z}6ehO@R z>Dw!y-tkWBRpd!Ng`!1Mc1sI>?n8O{4rKoVG! zaK$9q(=ksteqb4pMO%Lslykkb)8kpC0U)ZZ+s8u8Pt8y<;-K3Fe)Aj!c0HY*Hxqf| zYtW30RF%e}H<$E25f^zyjw-&oNRlIFNd8VcJgh7%m9dV65`Zg_#!?*#O*K1d`7qUykV;!^afB!AUM&k zws2Lp5w0nT_Gc&roYPiYkC51#_`^$YB5K}FRJ1}Pos=!TD$2s&#d|FH7xj^~4%$&8 zJp;=g7*=Ajie3nX5!|q`NTcjY3Awsc3#@~h6@omt6QtusX+-!u**-!^CATL<;23P# z^!G^)-~n>X`LMSWvLJ0}%G*;~679d#N#+nO1*=*gP0s^tcLN0Q;OlduYIQ^?5hg>e zjn=nLcIQ}Xad={NMBJivZ_sw)xVXZcdXqUu29)IA?EshHjZc5*GOEB_+o=secjMKP z1%O6Yoeogegw=b1ih~7`m<`hjn<=OY*n4&XMsXeh>H%_=-$82E&Kc{6ro_z8DL=8{ zuL?PzChD@5_<=GC6nfb>??IOh(^r^fN-E(Jt)3p3U z-z?5-u8Cen7l@GC2eS#m+pCS5p`(JWsw&MXlg;cp+&3iLqF1=|>VWJqItlm%3`W*# zy(eD6tq-Bz!XtmFLuM)^XCKgBo1SYmzb}3o$N9DV<;!_325CP>M?TdgBcf^xc@xE=BnZ&i!`bzNxt}TfgdFfmKJ=bG%QAb> zAuj-6+OE^s>B|fs6hUoGplZQRK+7?{pJD*DiJaNu@P{9PndRa&gwNSgaZSHCaduA2 zxEO3@RYW^9j+TBXxvtq+OXj6e?BKc-HY6BVkM0|8AUl?lGLezCWvtZ@(cX1<8;UD2 z(^XYZFCn&b;`vSusdHBP1>CYNUu-Bb$u3-^))EUQ4L?i5T z>~*#5_XyxwD9(tJkdFSOYpf2M5ymA=Sz7xmLvLPtT}pMWY-DQ$`Oa9VZZSVt+&4_M zXSx@&Dk+qZci97SrB#Wu*YIgd5y?RbF?s*R;KAwdF*#gU{h_4^ok>C>6g-E7Wtj{R z6Z0piy{>TUQwgL%Iru+l)r7a{r=Vh;um0>dM3<_+=P|9TT6!Pq08~;0o6>Ph^ACn_ z!V0^ziqWAan9@PuvKQuv>o; z9h@c!)KZ|9^&ZOX6>*NWN_Q@7~e5(C9Y6ee!4t1d4(ALx#+x#nfXYnZ+ z{!VR*ER>x*>-yOn7FXk#A_8V2jUc~enhKa@#>u|??>|H z!IAj{pfjLWs&)jR07r?i~)vygQ3<6lPjEP5AF zYe3J~1FN(1H5zzBCeeEd2!j)P!mM`q#q%e`bsB!Z*&2)9_D+Da8mUy=Su29f=5k2h z{@C{wXy`exG+pbyi8y*$OC)x&_rRKA`2==dB#{ev7h-y`6~J8~yMr50QGgE-aM%dA z)&%uEh%Ej9Zz2F%|8ZKZ6icS3AOm{~TqQ_o1Hi4wK;}sdBh5G1ycAK8*-CipGBA$% z)c$4=iv0dBi|CeM)Z05bh15R-zzs%u z16XWGfHQD>HJLDd-Gr2An6)1QV+a%Fn(WKiDRey4C*KAGKIqImX177WI0b5*=Fu`| zaJ~K&AOJ&JdJ4(8f#65PX$qeV*~=l{8!38TWj~TrCioy_9)PFn1$%cj3#=ir&m~{xyg9QL6!%fawGG5*SBnk*&a(|F!^F zy%c}KN#nl1kbD*;=)3(B;&GPuAEY+`_jW%S_}|!m@Ogg)BPJxYs+>|eqH!j7`#xH9 z0<S(?JUf zc1HvvM(lW!&X%xea%iPLg$#M^bS63I1?BmNu$JIVKfLP;V~Xkxv56wcReTR_w>lg- zQyP*wV9XC|z{eyLevZd=P>JRomyQ{{e-#<`!JP0d2^gM(DVKiD4H%!dKp-L&i(>Y; z{VVoQlmq6Cp>@T%arf!D_ntU$4>w`e8${$c@2^4{{vahO#YoQ=Cdp)d(c@$)E9 zx{1Ik0z(XXz^fsoj6hDp##{>r*ghn`ERKR5YvUadbd?Z11nS#p#>>{8y3GK@A<6Xr zP|r_AlTBPxI4Lk3nyG&!0ZviKa)VX+d!?*DMj2R~*aH32YE$5s0UnKQUcf(RqcHkV zJq4x-L_-WC6BLmoLS_)?S4dHUjn?7t zz!;l-g>=Ec|0GMCtSu>n#Pl<<&~^mzCkS$3)~|GbUqj0jr@@7XQZ;{@~<>}T*Q05%_i5;BJne#oaV?|nCFdI&|JwKSrc z9N;x_>P!W5`RVG(w^+X29!nK?LDxEgQ0H`GosQg-1zTm2faxs?KGOxH%KDQhOqn}X_8tBl?1LK zvJE1?v&1m)S^xg5dhY+t&$++in1DC>PjeBfs06uJ|NL!a1^+7={Qt9`{i)b}^B`9Q z=tZ!O3&Z;fcLAdPU!d?t3a4S9lKSu8kN%(Rfv*t5jyzz9T@8M)4sR*kDrVsf3bXKo zSweB0;Dp79~Tmne}nc1Vl{eUu!0~!TOK&_^eXIcRrZ3V z)&Le7(3{0(vL#c1?Moz4yTL|O2{b-@oZhEGbD+tp0nrCy($<9%ooPq7d8J z`(XjjJ;(o&1Q0P&t}KM31>jt<|ABPB!}&D{83RPlKzdAaSW1(yL57*se^CMohGt$f z+p4l-v>b%i@8lh~D4o8me)IzM2q?l_Yg^awjfE%U$uZ&Kp1^g0z9WYJ5=g1`v_U6e zQt4pQsXg;Cb!0m53^B?P#6Ga$MHeBz#BRI?=Oe0J`Kl}Y@sr2H+OkUNY98Hu7T0iU zfm&zwO5(cPN0KT2Aj9r^l+8!-)dbe0pI0tB8r3QCg{0M8pa44W(WHxbj0J&2j9W0h zX*YSJ_~(cEz;VHi^$uO8m7*H1?9qA9HeqhsK70-vXmG=|LPODVyUrqb#_VT|@^x6| zZGg=h6L$R(af9wz9?0vEo$(2nkpHOLtZ&06^&1`%;+k!;7kgC;H%bG3=VpX&vW>kF8a1#>Z+6PO?!}+Z%hQ4F^Dy3d^7t1;PLP@ zUAp`MeoOA3$I6`nf|P0FbMS2e+#w+kYpvp!yIh1091kyKQ}^W;p`3ZQo2SU~1C&<( zl_1y;WHkXTy0GuE;OKb)xAAb-OE%3M-tgs5FtshsERZoC2JDuzyihG89v$RcY7IUxG;pphi>%p|+AN+VzE1_-NU`dCKk zjpmVL)T(X}Xth2Ab{jx$oH@)jEK|3WDx$1p3i1f z$lT)Jvdw*#>dX4GE958b(A4)4TJevbdf&$=oH&RgD106sXdyMQI-~Jeu(|oNO zab`FO?M^esW~Hfd3^;_bzRKkAod;Djc-o5C+JjRvEO9j754kg7ypBI)CyeI2_)kg# zl1={$iB@PT064D($uhb=b#5rn;9_tg=z({>4RgBAa1k~)_~1;xF5Ep@#u$AqwdxsD zA~`RU&c-V5I1XV>+V@)7>1RpRNVnd@kPDR%s&8Z4SDuMU*f!bCOQ}s}9o+)On-+{+ zp=2}7qW34ZA-OO@+64Q0KYyoT`js7z5zdMB5t{2)l7i^v_fHPLp1?3@E&Chh$|?p+ z8^@m1BNkLx5rJr>tyMuj10(F95L*1eER7pVb*YK4|XRi2% zRV>&9KA_EE#lJT|VH5UkF)`lk26X|QK79|U#F+Y#!z2i z50q_JV$Vv4b-A`K)E2%`S(J+hE8ne|%)X!9Q2{@YocOd=R$gPlspaY@u?0$x71(_JzcKE)T@xz&O6Z9K`)HX^Isr+K; zBU$h6_N9*#XD{`C?4YA~)^xvAw5qL@sG8sW%7gk})RRaa6;!D>dSC3#&>@wEUYa*7 zTmD@0aQ1LcEbPZ~gx&#GC7iilgSZr_SEA($&7hzsY+s?>Bk+W%Na*r4f5=mrad{sU zd*pUz(tfU$8QzLhVVjcb<;9oocxLN@Fk(7vy~LjVt}1>&&hUb??A=XBJDBYvFK?c4 zOvc!C#;8eU6bqg9Rcfs|H@^e4l&OQ^$}l8YoptQP&uQ4~<{8xEDImN?-NMI5auT=k zQJSfFFYLV9Zwk8;_(kutyPbW2@nHEXUp_*Kk$VLP7v&noxx2BqE>W@euF2+ngq>9N zXPLbP^^?fs&?gotVplSjAibwd9)Ff{ku6W%3;ndGt>cAkFlZV^p0V%BU+E;@$ZIe7 z3ZloFU*%k+>LecC{)kpvNLUq-gJ}9kXTT)O-&&#Qo~2;wy>I2RYg=hqVuQ-atZx*} z6V1MAaGoKYa=^JN6Xm7OM&A{MzKGg|0P|g#qvN)w-`4Pz7;e){o${Zv-hwwnlRD6) zg5=fBqGQmX#W-QyneXAy#2Q-cXNU8%ifgSmp3vta1H35vHVq+t>_>ckRo- zx2sRIQz^B#Wg~K&C<-2C5w_0V$ zOcG@llUB;e;` zxXP4MTiur-J9m(}w}UY{x)Zp*FU#<{L85+H6lY+MM%sgcEI&*Vj8K$mp$fjAq0KdihgNpZ z?+lZfC-xODE7~7w`@~ZC8twFx$x+|-hZ30NuHhFoZ=FA{ggX-zAHeGAd)N94!HvXT z6jkgSxI0MgOf06)36`WU7eQ9r z^+yA-EnNKATQG4>cp4=C7?va~-`lh%A5<*e@X}CuLT~{GX<318j7iRuD@2JjZ z)BMDGe9>lrdF?Eg;?MU7ZR-?xO5Y(#f<}5}zl@#Wh_btt*&v(-K42d zRU^tfjFCDKDv#N9n(f59J65{#vLXJ9oQLi$fGtsiX1t@fvz%q(2r8EKc2p= z_n}(`UW6!-Yx+j0Rn7%MR~*Vwv!DeD5WseDGMZTV{8{^NCXD& zPUq#HV3A^N&9jGsBf`U*lzOTg!uuwwh#p>0wlmy$G-^U4gr+STN=La8;i%}Y$Wc=F zrc2_>ksCKfaxBYN&QE;vR=h10OTlt!59ZAv`i0!nu{}WAeBI(>WTP}>tZb)Z%#mn0 z%TL;*yUo%~rqZ#MX^ljN+%k?K`M~Ko-S+9#_3%WoT`a7w&fjq4nl1%YqrW^biX&m3 z3We!_Rkkt3Q3g%D%t#yt8k4rD_~N!rQd;3$u*FU3RlEn7g?0#qFD*OvZ9YYG<1ZFq zFEn_DLX%h?E##lK4H((p;h$MzG^=k>#^5)?m*HrXxeuTLLa_o0}wGnE}mXYrGD-! z9-G>TEo4V3Dofp#Pb=K=5(pJj&^cY8%=rRm%Hh>np0L}BCOSmRIPy#*RQ^fsgn5Cv zkpHA1b;53dXcEsU^|BV56vkExX3|GGK`5YGTtYymrXcxc`%$GC%O}!btvKIw3Co-# z!26@QcvGUG@MX*v(hjLd!-uoTmF*;#4LI$F^R&;Wn`#`;6gnsk-m(d!3ay{LAz0;D zq2-qqqIgsi5?)S+|S(1%7 zfXmoS@+Rh@S^~8+rsxOzV;g!{{!McvT27VQR|d{R8MGOkeiJfIHW^V@zodP?xCnvW z*!2@Zu_{wkPx5PqoOV!c7?!w;_tlwvt8{J@GhV^PA{4{)NrMG=WMEgHZ~039ZUSi|uLR>-drk zaexM+nu68lcMQJ}Z!Y!!thMYSRhJ$oRH$?Q z4HvW&P@g(W#uwJrHtLo(Bk;UYmmIuNbK8P#0PMP*&^PZ7)YwRY#zWrl8RqVhkn@f7 z(q_8{@#!T1bu6^yNf=$ww75ZqG$et*t_&De{>8N6hPEFT}*#vPktATH;CU)Kxtcb|6jfg(7%~xulc6aWW zwJ$19F)np|DPc&cYF&aHxCcTj?&P&1&AN&WoSLbDxbFcN;l1bZ*i7CH=R3a8&}-UL zg1a-=V|9T=4F`n!689%WA{fbKf?ci_?`GChauZ9b&Nf&I@{&HYtcEGji4OH(fC=!~ z9j|DtuQdBj>S?DTVo}EY%1$@oX`&ydNtD=K3{oaT62vBbJr*_h(s{^3 z!{~O|8Q(LGER+6bFd1751(WZgHjx&a7gI>R>-qPD(XF2V)8v>}!>M3F)2?2p?R#Cp z?f90Vf0szoXcnN(ZMUsyT8icqHUm2FGck(Os&Rn2km^khxML1P114t zigUpHops@o$Z{_Eur<6=&CV8FRwp%*C$b;q*ZubfA)kE~&C|m_MuYt;obbg#436bk zdlaLc0|Bj>xoZ=_Yx1wXEqFFJIXMCbiulU?a7l#%9IC-zbS+(zw(%B>yYC%i$QsKV z;dQC$T}c>=AVY^u3{?Qw?>ZXZp!9M`LU&U{Y%(K3fQhDofuA8$too92&y4-&%lQEc zcxTLSCupw^RaxYw3hA$CJaBF>-g|RpH%5we9X7UwtEzrD;hW9O5+6a1?}4)m`LJwY zsV@yFSNk;nkhoGne4u(*jW+$UKR&go6JW3?&{I zO8lAg>3JEKTzIzzMJ@&&u0>STn94QM7W7;&rKt$MT{>V_CN2ID}%qgSXJdinYNw zAv60Ko;aVojn?D~Y&}7V)>dC6qp32uDB`6Uy?=DYMH&<0?aI=y~ z&ZhqS%+>hWL+^Vpk*rC

_^l2A%T>D!;3E+RVki%(X9I9sqQX%-gmWgeYP(VGlX zZ++y%Pq{cq(YEJ2;-7}nUL0P$sQQ0iCdG($ zb_yqBNp^aHv1E;x{3~3cZ-Jx>dQ$&%Ae!tX*OHf1DJy0wVxgb5$KMGnD#%xjn$l82 zZRONWMMUaSenHDP`#R2PGKyz5p`W!cVHb{;^DBJ(N8myoXW(!nh^GI0=nWaU^X6QL zs--jlR4pr}|EgMwPoeHj;t5^-Au7P$PV)S40ORj-W}y*({dKZ?Zez09;GWHbSTezx z{4XbMpH#|5Gm4}()D`B17M~_gd0fEYa_R>t9&x+cW}IhZ;nLy_G?O%Z_G|lmfAR)8 z@LT`#7p&8GWDc#BJ6?l5Vq&@(sPCMaUd{M^Eg_oo@y;s(c^f;kn@M7^5oVp7?rNtw zXiI;|)}e_{4?#lzhNQ7u3qku(NxQP!tQSc7`{8zqgznB7)xo~k`ZLF>VwHppK@S^< zyaoN3vV&3jgqHZvaSj!PC!c-^Op|YA%uLHafkuSs{aA0QbDM7s?(i0PI*^=1Y(XX} zmEXl0`rE6|8n+jK7@|DfJ@WD9G+i@Yoin4?>hfBKOp5q8LLXh0C>n>`aB$HQAX!#$ zQ?bWoV_yoOQGM`E{IulHsjYyAdmB4iSvGlcW8s6i;-L)qErsOpme@ z7p1BU8=jN0C6KTfTZeSZrwlKYN`hZ@KE|7K+PEh<#y>>gREb62qd?(uM$jRYgqRWc zl1X3zXIB%sq+09g23C^*27*fL2V}Q2-V1gxPZ(u=lgr)Amw6jb9%3b4kk}U*JI~pj z*DWFb(9DGu)&y<{o6;?3E|Gj#!z8Xx+kV^9I8 zb8?i~^0aP94Rd)*rQ7}zTsTwGl=V4d?$(ZZO;_DiiP$7y>?3N_4SiZnwyHY`m!Euy zsvYE+Re5;z0K0HPdWK;x7!ma#G8)4P;U;7|Rn5|f$9>V)%=R07SEo&Fb*9Va!I1fp zg74mV0l2z)$K6YS zirh0+uDQrrJ8LG#nqpc1&VNXRF9MM_8sAm!k={1S&=9x zTpw;{<1AA&rtArLl1DM#04Om)@2qAWk7s*vzUp+}RF=_*R=iA7(IjdaF;k9-YI}N+ z7-!b1R6@p>#W!O-I}OROlS$U-q=dp1L82;Uy**r8fLM1uTM+DmXQ-<=(H#!qwJ zO^*{1?}=CkAvz z1xv3fX|N|1wJF8AV(vWEVKt3)c}F`qCt!L8_1Io@nC_qqDcb=CjOvlcXNx=F9tGL%bRMp|_|66?wREFg6uFH}^CU77cS zc_jTqulZT-yIal~VpSpi89A6uyDGIio?60EeohB`OW={=ctN}2yi$0k>02p*ubD^r z?ki``eeRvrOTeE!y5vA)G+|YfxP+5!p(q7~G1q`=u;DzP6BdwjA0Ojx;<~( zr+-Y#*I|}R`gl&k|7^mastQJVY=hi;XkUG%K7yS7;-CtWrezOQ zy`TRRSmRJ4_)q^b$$Gf|U;W%=bm}}L1VHm5_>1q(fV3k+f(E#aMIdp&r~Db%%6|X9 zS{k*u`3pB=YN5?i>fv$>XP@Xuo9=mkavEbtIZ(p<<%Lx}&>C1*y8$Du2a(3QB1J$< zE5cuZH9DRQzMVfGf|Wb=lSXiiZNhwpnS#m=jZ(@q2B_^(tYbY2!u z)?DvIPDgTy2s6_(JJ_E1hTM&kWh6#zq1lI(JZW0s0pmhLy4^k%qreR1Y5{aU7 zhZZn|$O-d+lmf7xhSzU^X%P~k^uc2cXzS)X0J*_EPS6on@f+$hpdwwFwFJnAe5+PS z6nXKVlIm`R<-WM;;1~ohLF9?rnCtSu3?zhJ`MvXF^g*L97SPS$9^MDR39ur&pgHO& zDTi6(tLx-_8k2{EaO2Xd-T{XKaBLJPd%1o^9i z3caWXj{*r50bLI;^x#YW2#&r__7KbhCuPZP#8?g5ejo5EI_)PYJKV;y?XeP;P~=@7OCec`}cmt ze&QNJN)$>un2;g^plZFoy+~f{iq8X5`^W>R`5>uoyP{lWEdXRwa2^0xtsqtfXD&>G z>IA^ac0U!efDk@N%&_ob69MTrkZ?NDXa1G0w0?`v##sjq1$=$<#`-V>#8rY#3nYtG zY5QJ4fsQT26ob6$8^E-Pz&gR2CbG)6E4kvawDy1SUhm#e1|(J?r6e=>)+uc zf{n%+)|$`W%$ZNcSZdQ_3uDCdKor!ygrrzIkSCcvo`d<|rFN!F@GQxhSVV{iuTpFu zQr)tWnPRR3i8np`wGok$A7LBG)D!%x)Cncx);txz$SCXW<;ebR7}hA?tY-v)S{5T2G@Q=s9!-)Zm`IKP#~79FHQU>q-<0IC=Kf*km@OjHXs89 zk#E4m%Sm5n19zJbxW=fe0F^P)2hoIZJERWe8&?Z^jQx!ha+=8H!Xk!Y5o#q4fwY!y z7p-F2@9u_uhxL|4vRTpK1xN#TWN&^1e50imyowXAZx+Gc8+Tn9jdUyl)L|WP^82R` z)L@_=O+iA-y0F?2s{@48Jc7iLz!T)1Gy(69HpsDn`m^tapiNz%>R~-zFEafb!h{br z3jj90ZWEA^Qwlt$DJywxIgm=j;RH2^Kn%)3*b8n3E*R0*7Rtme0*@hbFhU<6w(g`#^d;m#00uvU(A5FfY-G4|FM&%Z!3;o zRne2hMI@#Qav#)TxCPAtqH8IUCK#h&S2qR?iRr8p=m>*~t}e~7SRuM`yo#oBwlOn&Ucd zzJPpb(@hm@ga7#(1e(;xC@W^|;~>4{di=kqYeDQ0jw38V$ak8@pg({N>gn{|=l>o) zv&6ByO9e;<53(tHA60S2F{yrS_4rsJ2yKyIfggk<>`x7#qV<8z4z_5hCfSw!lZ@h_ zB>)l$2QW0KKo3P8yyy^x|3$-T1v)Z8!C?H1vkzTRXu_^?O{KsORN}BI7ePtZ3#NI; z-=I1zfj8dFXSTh8?}zKDQ*hHS_yvqg%yI8(?b{9?pX}%U4MPF> z4wJ>xW6)?-MK+0okk2%hRY_}$qczx&2lBhhMTX!VLeeOB-d1J9Quz~yA`r5;id^_@SEZG?>S*lu-y9qvI>34V%G zr)MtqODQZtUsY0J3YRzehNA~3p|1KL%@9}2-{g|^ufL8ORp1VYyur#s zXc##Dj0!^U8HSoV`P5xha6Z=vd1ox2{6DeY zlh4xisc|8lS=7h9hVxPGMLs`8EF7EU95B-qK50`jLpeaY>2c^@ggoJeGD7-Zxb6()EsMuBx*vgH9AhF8CHgma zB%5;TAc@bYo&O$~2$5QoZcBh|!L_goSuOKz@o*xiWqbvBGOU+s1m519!28J%aFa?Uf<%#EE?h};S`G~Yc{sECi$I;9uo1P^6FA+4aSe#bV>%OZzeSDa)x2?4Dj}oSuKE&0OGxH{X1cQRco`pOVy}5ye($m`@ zUHf}%O}l*Q)yQ~$7*zY;#DJ1Jj*KJEFjqC!jE*vr#wi2+!%2*_?~qL`oUM-wvn)1QKhpP#VP-|0T(Lu@+ z_QnFdefFecH&x6Ve^Z?%-GgOP()H(;NbIFIogeg7SKg~5Dtpl6%hgXoT-(c~ zNWgt3-iJ8jQ;}M{ULrEsS<*bxH>EE*&eX%Y3KbiGpUVz;nLAy=QK%4H8K!ouWgzg-M0^uxH0CSX$2-vB=;%#O7fBc9QVrjIKy>=Z`sY2+?{M%Fft)rkBQ7!^u&l?Hx=Pe z!?rl4acuS(WsRQ?UR4{2%7p#w`BL;+;A9Er=}i&7;ITz2p@X<+_9H2%J%r;Qr>Dfk z@fPZ|UpZ`4xUCH<&d8tl%R807fK z4$4Igm^Em^uWdPot0j}d?XV1VT>lf7oQ~ko;~Z5&TaggbuOI!17|~V z0v$4>9!m^>bH!e|Gz3rKvRvStG{m}=tvSNsgWkIOiROLEZKv@;&%w7mvj)DdN?j^- zIC$psWQP7wy`ZXcE#$s}IvEy5QSok^BD+vykb9p0Q*2KAgNqH~SOtyCNadHG%)^wi zEpN3?(69D*Wvs*)TbHvh9y*meQI#ZY1sK!5%;v5wN(qkGsOCFm=sofCa91L3@J!@g z;H#YSzjq%jB}@GwOBX`z9EUr*cE1fT!E3vFWTf)rHym4?B02$8Wb-w#UN}}zy=B>Q z0ANG-qiijNz|6PkfmwK>Au0Z>|ESz+Yb?sU{ipRvOU}_8U`p6`VqV#fGyS_$86$v1J4bnWSpp;W8)Va**as}Th^ufyF}jmp!TE>JFExq!2e8}spPYYG$wzbOeh7a)*eE;%2m83l%pZNL;j z4)Rn#PBlGj;VjKY(UGC-AKdQp7!OA~H9?fXG!Rn*tK=Qn*BroQ zbxS$h5-^Xg+ohHSc2f<&)0Y;(CE;O*s481dGM8VErP-zz<`D8qCW|a{;q?3|26t`I z=jV5+th^pH1JW|)TSYNhc3I_lqs`53Y%C7TjHPJfYgiOP7P#6OEWhBqb514U_TMSS zirp9__=(1@4En$sCN0tSd8F1;VGJ%|B~qY%dnfMx+!m?t7WYdhLca^wWz|%_@X>V1 z@0G~7GX5z_Mf+w;*-CSOVcwbqUAbHh;$egQ8-=NbatzK6Gu5-}d;|@YsC(dN>&aKI zogveEzY=y!o^G3wZR>G1}u>gkeyN@PfcUk(M)-HO`Dw#c1>EI!}2jRi^((ojBzGV-+I#~bI+ zIIUHoKO*O(L>7M^KZ=a@@k9rbznb)rY=|xsb@?i3_lUsLY_Wj{5}yG4$s>!)FX#7q z_>d@tC$8P$;x#NnsR@I|eP$Grw)_jOyk(H%C#QzVhDnlpgUXPA9mv0Ti4dSWoBiyjbbbWP9t2c!s;$QucY*z~2W?nv63QOys zbDo`ZkaMInph%}kVwJoMPnq;p4%JnFFhVsEqmEW-DZ{M)Wk}c18B*D6t?Gdr={wF4 zp^Uv&L<_OXNuTxGcv=|$K3?zK{`h+v*QX3rFTYrGP@Zk`TpP?egN;&C!|q1XHSY6H zdpPyRhKlH>y9*mOiR;9*K?une^Q*VOO9D4EWuulTbW)cWK$!jPHEWtMgyO?6=`gM7_G9@GV@8b2*o}aII%?0Fo z5#W(WaYIUHX44Y3zlkvGL)PJZ0M7A`*`J07#|*xB*4jWVpr@p0*Rs|Mx3LHtPV5DC zCIrQA4G~Ivz76J5H@m&COv_L41ES;$u+v`57x||XBVwV7#{H}VjlaK`3ooAgg|%~} z8Rv~#C3&vp>p0TmdDc-9KY>aAK8Xr+SSN%kD8KFESW-Nc#n{|^1k>)Bvy5K*VG=Is z&s^EH&V1DE#;Zq!`nxxB!Bi2V{Zk|+*1$q9XGfiLr*u36WvNK>YK$TdowZUe*Y6V~ zOb20pbUTmWrk}*c5v$STgvkM%X~N5TMX>Zt$mEx%F0$z;trGs{^ZV6yJ$o~ELym;( z%S)!=mXY2Mw^fT2Bf?zvNvTPY8#?x_2}JRl1`PMJtbr(8>d|ihi&oQ?Oe&IU4Rn?N z3CsDfX^j%7I)e8H#Tt8wbP+d_YPXJ7vR|twLA;vArAu56)%V`^y?7JL>J=$r^xfDG z8_gX#k*B0IiB}S$1bWppK{m?+5!tgb7icUtfol)bL=DkH`%vBB^wm&bj$fGeyR#U~JY9d8vE@37{9 zzxVD4!DYc+6~G6EGn=zK|JGxG12y}t+Dky7hkgg)IB&oW5NgtBZN(KAffleg< zZYOQM4tIW-zV8g*#*7j(N$r7{gT+PZqBnr|v8kb^VUX{Oyx`&WSJB$@ zZs5Ls6XM~$((+6))d!HvimNXsWi6g*s>;{6v4tokkOT$OHx?q^=*oPk7dM?^cG0>% zB*^6h(@3j&-CpAibdb`mzE2L&Nqee3aiO#r?tQXH>j8ylKm7zxhfPfA2b})c8E5+> zVNG__%Lu$RGCn_M^4jUkC0F??fka8a0&8R$JD0ljZodqKK_o%iipmdWxHK64ndP_* zxu0_h=*FI+mPgC9ZpFM*OchE%X$F4>RAF+3X2Z}tM4}detP46>r(X7&KGUWSC8z2M zjhFL^<(2D9lWb<$^7Z^&Vxi)Cry3yowSH89qp^q})}y-nZ}=K;u`G<=B7xxH%7Wwk zQ>37Jl(3KzW8~zRxOJyv{8_f+;<~r_`$zg{>brR+m|;0+wta5sbRT1`69CQ`ImK$u zQwojxMN2MFDhqzWxEnLapF2#?C{4s?u9%coFyR_OzS^6Ia20^K`Ag=l_?`pg*MoQL zhF_BK05+HTv(m1b!pt?Tzc$rNLdOwn%8_>o0rqp!>A z`KeehvAG1VIq+Lnoi)D989gckO2NZ#@M4%HPdd~_k_QUgeiRzIk>Go+Sn(|zL}Y=F zce?=0bvZGsq`R<@hm)3u{0**P<6hdw$Ze)~K4m?*qh=qa#HTMqnAu!CIg%r76Go>P zIVn4ZSo#$OO8CS~i_QGpTv;Y4=CE{wkAWr#)ARo^=ujFTFhiwr|T zDg{-qf!2vUciY%Jze#ba1I?2-y zk5c$GLs4Y+2xW#SX=0Q&KC_D|#GGXdct>)ADZgT!8S@KHl19JKQT=ytcez~K>K@#> zRi-^+0v5-BlaF$OLl;DjzR~zMG-0!4oU$3mNjne$ad|Y84kSkiXuny`OIxQR4egBn8_8gY%nuJUo$^WGR1Q zDVP;{VyL_%F47U8syiQ*A-NRLFdIg_dGup}baL$bjSNjlywwtk%*QFgpdR(|EnJu# za!|-38Hi9YB|N7x`$!>_84yBQ@?MT#;o1~lftQ;GMr;l9yn@04Al5Ve02r71uzNz= z?Aq}z4<6ix{;Z%sdE$nTH2(Bo{Hb$S+{BID2Ph~u1|7cqg^<4wCwDjV@gf)!fE855 zrgXS+j#4bFC-D0)+6lMV)SvwyoLn_2#ys-(e3>dQFoo{`!2?Yj-RB-;cY{dRYtQ9z z1*&7?aMyuugaQ{igKVIX;EMWRaRE9M!YM{JH{vdG-#R6JZ^Q=117J!H0pNffUGiN< zWaaM=zU@jCeQjO;Z|u8bDMuUOG)%ro{t%RZSylsT4ffJO@Q71)JONmnc2lexK)P}u zY5!wIEj7BS8sR^zetbPjN#*i7_%9ZqEfp*4DIj#%}85QDJZnj#i~ zhQF*#?22m2$>}LTFo0Wv2&7E_r|5vAf`b&GaSUFRCjop_-RB#>NNAUU{}G}2fy!33 zg$M*LfWbf@ZUVV+z!ZBN*M0+1wyhiduMN`2I^4!S8+m6EVok1_952 z#ajpvIgm^zU{?6c+LPUVO2y?1uIo*>sa4tzcbd6-x*$Lu0y$bnbMkWv$NxlYusegd9$NwoWxtP)iK|uTLR~?GD>3FoAc09|_=jw*3g{%Z9rW=PwVyYdg4B zjn41BgGZc4)d-)14fiuB8zIs01#m7vjhx0K4&A%QQ@#PL)*aUL`mqnk)@uLCb6Qi_ zB0W`qJv69xwrZ_zxLZ~ZcWDS`Z-6>kSd(ETwAJJA?)Ifz?D(Sjh+_{z6ND{09f1bx z_+k$n>Xg@lcRwExZPhEKJ+tq6Kmj^e8~!h~cL&}0+d6qVzF1DL?_wj=!y~q6#1ir# z31I!RkZ<;(;C9vCY`AuTHw&a8=)M2@a(q&SCwFsuBT%A+Oz$tT*cF+uBd#i+jw|wS zPDN;$0<_T}SIU3>TrT1NAAi;hI>iLHAoyncxAHyWbm#2G!DQ*0xocA zjHh4VSEU2%6Ded4Yv^hBR^Q=?c@;s`43b~~zYW(b*vc|grSu+u)n*6M0O510?y)?g zld&FbqZTkthkF|EENDa9zN@RC3%q&v|E!vD=a_?Z4hc`hs691^sDNPG6obnK!Ok)m z%GKf#0In?q7LL<6JO7#Az${cae5R|*hK?T|gd9G7X%J{%E_CZxh+#QhiG7ASij$Hw zY60#a#VO#hYXFF6n}nP%0}<5My%xxqdn{rZ%ykjiAUaOeervh((Udy zxDUaoewrjvWAO*D*r>03@C)7kw{JqVI%q{+B}+^p*rM-9<@~*`sWo#DG_}%9(ho#g z@yqOIdaZvtCz_tT$XDbIQPv2UB5&W9sdyri{43$iX7h>~_S6Gc%qRUJj{;w~c(CpV zXs(PFo!VP!fU%vS`sEH?-T&EEkyPf=m+7^MskI$sP6s)e z@Xrs81D$OiYQB)1;{|7dsk&beH%MuK6%~x={0tDSL3mgX$=M3R?Cb^ht=p z6xl<8a>5dN4&Y1mf<7kzawk89MBj3+hxw)+1nDq3r2tv~9=3(SBg(rk1$&Uv9xC6) zq@}}gs_>J=Pm|>hWHGMx!4-dsDPX@^s`|fpd&{sY*Y00bx*O?`2I=meq=bNqC`gym zAfR+lkOrkw6jVe?L#Seg)&9 z`Es;tvCQ^DFL(<8`0D0DI%^6*9$XCM0mt>MyMQZT|mK zDZY~ZcPa(+y_rC*gnff58O9x|bqiqf-?)qJ)MU^tQVPTEr0b1v^MS{(R>3E@^E@gY zvLw-Nvv|c1uB!f(OOXhLkxA_SH@|}ams;Xl$R=8tygTt_m9=prD+-KfT@T2 z4N6Ob=3z%90IpPgt)Kj3Re{K~@(lX=#JX1CrJ?y_>u-2cmC=kn1H11ZgT67~UUreY zLFt~_BYH;zVkNG9qi7u!d&h@6`5nem{_O9=)Z1lrk<1j2LOecEe4}u=2vwTZF0-f& z0%$!7Y=~SoJwvDbISOWv|3Jspw9ftLf??adcVlD|e+X3!Br3F_LtGA|ru;8i665z?M1#`XW2_oIz zPaZExm!%J8R(uEy_1Cie!b+7K`@evT#ugJ#YXs!%ij{QJPXjz*iw2T5EAzSPFJHhl ziI#)v6R~sUfQ#aBe3R`ERV6~`Lm|wFLpa=YN+O0AVHL1(d}<0B>z3lC*R)hg8sc0b zs!jMSqGF71AsJiVF96`8b+x0h9rXr;A%rCnohV+8GFLPzapIwR8W`c-exI|QDPUX$ z^ON&rWzn-h{q0~l9`+tj>Jw*3c7=gJa}@?{=s*hkFa#$Tk=3JXDb#-`3EIdyx?q=U zMxrw}E(L7{vtKp(+vDx3H!v8`b*X|f17fi~mAE4kZ5F`p3$6SyNSFZQvl7m?5;7XA zgZMMfHo?ejd?K&}eJoj<4<%z-6v!g?*Qdn0ro6ZppP6|BArMNfN5h?U(!4P;33gv( z@!CZp+4UAtpVXhIxX&pZc(!Olgh+kIH&{h+?( z8LA`UA7D-qA6n}=NHF)o9O7&;pO&n?;G}^;$it2I%Ug?T#*gMDSw=3G1?3VPk@C9+ z-hZs!9ssphtm17W3Bk54PR+8Qj|(YRC9mQA3aiS+dh7l@|0UnGGV)b>!;Ub-I@;&k zgeO2pze>;EdPZQLRq4ONFAATQCp$S1vSu`pD)auV%AzRMwPCB8?P#lfrK*bj-;yt^ zqmn(}mQF%cBCh70n%Yc!J-j^(`*1~1b@fKlHI-k<8YS|Rri9%kU~E+k#k(8hHS9Vh zi?xZ)w!02NgX02BQ8m6Mpx8?yV!O%d*6V31dUk454~jECPMSUhDYTnGhpvt>Bw)(O zh)$y|AW#bgnYZeKT(dl%1h6O-2?So*;QT8LV~qI=I$8LM^B$*{=WE4~N%kC#!VW2p zAG7}iVW<;H_lDj+>!32cc@|%Wx1IQJ2u1_hM$mf9RmxXP9MOFYD3x7?qjcKdeC-R} zSavKlCD1gWIaq#QhZ04rL1f*HL?c8vR~o(qtJ$J(FT#O3ZumHfUc77RGN=KOZnmVN z6|$<54;hZqX2a>YWtoDhT?T|PCi_kin{N%Y{bQd;q-fknse9ZYz5x8+F0v)l&lrA! zT~o%~4%D8Bwqf_5V@sdRJ*te?_c0ug^~P!AUc{hWV`lvI(T2)XVEr)74v$0e2Fsx| zD3^}<{_-dsPar2dbpjW~vMK0I7pMZx%B3)C&G`l%@;zl3Zs#7|PC_&2J1-;lvrZ--WOA4UKsmjbrG?BLyZ!wD*w#~9G` zNdI8R{S3@+4)g*kZ*chcTRG!#xy3nc!p)GxqKM3%a%o8?ycpR{^IPSXe?m+x;oW9{ zuJVv zb6EqO8X0Wzn0mOR`qf4Z$mcsM=`d&J?~a%2c*SB1V2A}!5lJO#rF|a zdmTs307v&F+Yz$}E-fK3y_;hKcku38c}xz!Fe9eGB5&ZWC+QShFu5caTZTc~S8pX3 zS$O#=fo6Q9LKr~_ZRYFQIMAAHu?T~l)E>uvDRXv5{#x#F>#K!qc1<_oZ$2%y@Rt}Tu zsV01ntNX9oDB%i90qL|ZUzL?7x2Jx&8{x5Pl;jd0V0c-k>qDeX`sSh86wuejz4CtZ zEr8$(IbYo+Lw-hy+5O1Q(BRPgcA$=t9G(TO(sEYIMQQ43>)A@8U){9chR#t>2wSQ- z99C}LTp-D}E}!+VeXue=_GdKZ^BN22+;drM$v(#b*^+J{nOC6V0)LYoKE!@l z|0<}v>n>S{_YDFT<8KR4MXBH@kqYuf)u{ji&RvNdKD-TMlET+oQs~$&?4Z zJ6P7x+bHeF*{^ogJv{6;e3Ty%cYA$(^5I4mfcd?I^<5>L9)RoGS#e2ci$)wxRKvM&A$%ExjS%| zb(-a`xQ~U;Hi855Eyw7)nLl@{Sf8fy0OwtHS1g{Mx<(C-xD-44UyC`cK53au5iFRk z%V26(64Y~jWL3lm$(w$ePtpquRx}I4e`kGgT-BJq^xQD!ndPY0(36|m1_U~`?vrf- zbAvaHfRT@|s%Z-1vcC6(CLyfqK97#<&2PwdZ1VC&a;c6=uIH22Xn3LIiM@)ZUyJ0VyuC8T;kN<#`GA_FV*R=bE7>lRv`@j-mX6)th zUR<|fC>)TR-Ljyp`mVUG<%}R-(b|>7eyq6gh|hb6@Tmn~;;W{?*n1_#!vWV=t%-zt zRi>RrN(jQmWu|BD5VU4}ko4Te)-DP-8^b-;jYFosfAyE$1irdBpZUIv$nckBAzS_z zeD6|KN#3v!xC&ep?03 zMP43%vGhHdaX?CGV7K=C_<*DlR?U{Vau815@jtA*E8yPB&!sY(sFCw#7cmU5_K zLs9(9SWjB>gRxzzq7UkjDtg<35&ASh`zMon^7Xf#$+}X%;Tv({oTQpde6Sss!mJT` zqc$5;PTRw$(*CDz7mOi9qdE7CR_mhIxWBtySN|OUSI!7i6yUsYv=uWU_zrM=J7c=Uv20V!dU?Qk_* zGlz+_pE=5Ij~PnCwv`WW_D)pr{c*7)kzFae>?nPkxC_^Z^1EcFx1NqZrx?M$BpQXy z46NhJ?i|kHlpF`N<~tw&iS%FA!TWl>M?m2C?o;kFJggHK1)y~W4Ln%ltqcr;acxcf z1+E5%-52eYbkHQQ92lwlu8r~0F-dyV)Ax8j7LZ_hL)$nSu_cJMp^(EbCL&?_t$?%4 z)J}%KxdbHz$Nd&(5edplzd_3fM(eoN6Ue$wH;1hdoGnRDb}0MR(*^6Vgr8sL=ndZ= z2k?1Aa9pgoD$YG2=9KW5R*!kt3AvLkG z5?Tvny`S6T4Dr}eI5lWfFo|@yLf1iX_4n$Mtd>0QzTmQWG)iDsV5km&rReZ_q&QZAdrY%&JXzfx+DU-K%-b| zuHeJJ878tmD_57rxA^CA-x0ml8)a9btYZ7G43lQJZ*ti*adH%W+r{V;%u^jdYdCe# zU`~n4F?98dk9(la*C&2ot(IW?5X$^c=hxm>AGM?o)m{Sfo7Me-Sh6&N2aayBq%o6F zIeIH$3&TKE?`&KVgYMXcgGO5b?MurlYJ+V}7Z8-89WF9!RUP{^yR)^LoRv*;g-eId z9y;MuaP0UX8Q8gImQT_hj&R!F_$Io7_y7}~M?pMBEs_lu_%vPq%kRVXBa|LY;d#$| z3<^lSqS{Ot6+5A!*2^uU5x_^it*xiPV|&*TW_^=xgCQ8WToSuQ()0DGUF>s>T+ejX zjH14_+s#My{hBiM5$1fQc9s+u?&$G`3>B(I##j{hkKeY`RQsTse*4q)WOgfCgD)&2 zPqjYG1;w$!NK#a@TV~upIa$}vsnkNF=E*{O?q6vr=VSrP7!DdKb-!>U!?<)m-+Q{*V85* z&(9tXnb!Ho3kz$bqZSqx*Dd}iZQizkP+1*4(&I0pxe*vVNV$!N!|_+?=$_{+2naZI z=aJBs=d?w{J{l^wT&cIHS~ZA$G2*o@W(0E=DHD*`&&JSBIlM8b_PrH@iq#s<`1($W zq*aatRlm*MRu_9Ct6m~sW;w)CpV)WU>M8=F>B%+j22(zpOBCr^$2d?KX2iEjoR=CH z2})#SDDjx^??4;EyDx}riY;K`GjhV^xqu3%2b;Yo5_j8(lI})QyOZEq$-`z@> zf1C0=H1B(4H7uMnv6tJ^%C?;s1Av0*3*)HS52S3{<%iAta;iBu%u6FH!e6kB@-qvL}jf6A!{E>~R2F1pQJ)#gEM)s`ji2hNQ6?6!7%XRX0n3kz|-5wJ1 zNyxb|?;ZD!a`li;QPWK05=yU4;dtS$s#DlOso8p(D`!^ThYhUln`aV`BHzH5$XVyS zv=FuO9BC(3J6~OzhDLmF}*#TECEG&-N?c687bIZEP zubf0W6C9{JU&Yb<9Tm;toJPzwIUUb2ZP71akS(Xk7Q|p29^N@psV-}8ust%i9h7Mj z|Gfx!M%Spw7)auIkSKLTY4|fkh40h3^@R5aG%HPApFU1xi|l16R)G`LYB}tfe2e)V z|NJeQ7Nq+5pIm>nwkrJC+SRmZU*u?4sNp_$$6BF_yU;9~Y-)Z7rMWV*O(b?Sa?;N;x77@e!{K2HplBj)@W*IM zLt_4hIeH4iJWfAFmy0G*4eKELv_t^Q9@Hf{T-Pfvx>#ZO3yCx*XRO?2;SwkXP9`e9 zQS^UJs6gp4IGu0x+?&qdOiHFw5XK6>|ANUS)$7 zA-C#Hm`Cil_=g{j_dt4dX?ne67jdJz=9NsHuknU5@$_HQZgYwQ;{?4#Un3UO#w2;N zFOq1?XlFWFnhTcQD8JrFH#P?VC!py1?lfE4{WD!cyBc8IdhvDJUO{UE-;wpY1LqAG zqT`FsCrW7gf(VPEMWL;hNlrZ*FFFf#*SYwCwoM|B?cS?8`FrG||IPwbP9j<^9nt%- zl|or8Iys`xCP%WsD|4@hg?7Q$sd!0V+64rfIeFCK?~+K)ixU~o?8~=>Vi*}%o;Z@t%voxFh8cOKZS>UABJ)4Eonknu`THG!$ZM4>$ zx!*nz=jPAlv_>cBucwD&^$cl@HXYWb0!6@BhZ4UQ}nx@ zrQOFV`IHv^4z@Nnsk(M#?|A7+>0WRPB)4`tDjTRST$pce{B)pNv;Tl()|2*$hS0(Bg$tt!=kfQW&KgNR6kBX(q%HsNM;Fq zVH5qP@~YTbbOU3D3sJAt=|z5Patz?A)~rfGsMfcCbZ zCjbeIP3&e>x2rjF;aB8gkAIQ13A43SiA)LWn^iis%<_~wyrfIgrPv=3;714M;}YU$ ztF%{3v8{{m(Jow8-}t>=6yW)o!pFO&=VOYJbIb3|gL9e;}%D zHIdMZVVmlMBnD_($=Ak&`vDyUZR($&6VfKbk>s&>avKnLf=SExnU84?#w!`;e(=|7 z$VFn+uR}y0`19)3$O}Xi#2WuV+1mZQm;!&(gviwpdOp}5K3H{x=l_s+B9U@&{}awY zgQn5{Ln->d{bveMxEwtH`Jg%^5Z%Yl4O8{RfAIgaShp>me+9s7h-#Jpk3Y}p;%tGP z2b>k?Ko)@SGWbloDE{FBuu*b((zXCj9lVs~2>M&c9dY;-z-hog-vyOCNPhw02L2>4 zJ*xnaf!4Ze{vUEfB>C{A4a@_mG`>R%DZklh0_rtzF{lIqEk)iLU?lL8+C$FK8e%BJ zpd%1-OiM!vOh4R~N7gAT{bY5T_{>r83ZG!5wfX z&&;IL`j-gv*1#Ow1NaSf?%y}Q?M&JxSv2}nv>w7D@hAI0iD3=_9-FwHR%-S4%qu=@ zoLB45`2icpK^~q(nPs;{($_Px9t(spqr=Btn0ODI!C(kzVAjxz-w_r5XE`n0;u6Bl zoLq+@IM7!KB)g#yA*4 z!4Jo(OC2m?3OQA=rTR^loC3cOmvWf@^KwJs<$~P{noi_22ah6P|NG)Xy)Z$6QV;|E zI^trD+YJ!21C!5n58z4;m`YpcC%P@41DDorFh4H=4bZ{Tg4cil-wnWtwNQ;uqlQ*x zYGR@DozCAt#>^&E4PX}qLgF8YoUQ~$bX;RW(B2g6-NR1+T&s+xbmn8InU^ll8 zY^m`#mfvA{0N?=6rS;$KokEO!bRio4O9(P51j7ngD?1wn63&$J1h3EuPzDM^z&56K zfv@oCiQx3JE3|Fz!N!1aX9l)B;Pe?_1_T=P8z8HWHA3S-2LD4bb4^=-&k1xui2eWq zN=iD6*n)t1U~6kjfM2r%1i(#TR|3yC_W%ujyHv+~6zy2Bc5W#kPW&G?DteAED6htymtkG+W zU6jfKBXDqR&OBX0aOt0B2?ZVgbB-v`E8Co4?rO%!?r8BhSTmzQ1mL zvIBzO%e)Bf4TcYE*}XotM%|Lq|E3n1Y68hS3G;qa!r!p3 zeE$u3HP#=q?L(hF{tWa?v{ArmaLIkq?l&OXj{apyl+l!onkeXKJy}Y(vejq$jsawO zI(Sb4R|{Jrguv?MnY1URH0=I+0EV4DSQ%n#w1o_j$Zrw$9&oZ81sW(|gZsg%_n_i3 zCu6-Owkx8Pm_cF%Ev%`y&jJNIY>EC4F~bx*0_`Onlo${9-T=)5IIBX&CED;RfKed> z6Q~+i`7@waHvf2X+n+^$KkCag9P;23;M103^VRqa)$^ z+Tkc>p7_i?1w5gzr(lZlEJTjin6#Y;Re#6!7U{&Vu@_YDAA%_c1&=}GoX^>^^7-ML zkkdz_aQgtWuV7l)vj!1bFL`zgJ;1Fbi@ydIbp@5ohO+<46( zRYH}jJz6w*ufc<8u?B$grDeFC+AKZE9`hQt1=3_J{bGJD9Kd#d7HQBDdl>{#oOf5m zSb!{wP8b#wqeD|Sla!8lXZ?66)OqT?e&}lA?@$pHg6;w|0ixd*a2tDMikUmPF;B-eU=O0X7!TPy-m<0J|FqklvtET@Qi7%hLe-4QE);F={sNDa%TvIo6e7 zW5bOYWx%9?cH;?RIdI;Suh{R{2$_$fRpXI_mZAcQ-)aY`f>fS!Kp&cnCHE<>Y9`o) z3(a(fBO&V)aX1^eqmNCtA2n+LOgvaI^Yz}JSb(bx6cbb?su(U&sk2MIxO%y~RZYy4 zyUcT_L9Z9 z$jpAixTQVApLrL}L}ibAJ3iCNq(>ZKQ+hhY$?MBd^5+KXsVdV39Qe>T!rEXiC0DXn zp0^Ja0v9C`lGwTw@UdZ{X%(Yl55jt&ZZ*0co$p5#FfaU{& zLr*ROe978NR>{NIp3GLlYRc4iAaf8sYoq2fo?8q6dtMoqj0d1!ef>>uB55aSLkwO3 zl$(3p2JGM*1{w{F>39kMZa&uBT`7^C_uzAj@f2rv6v#*cL3{9Enn7R-S41%Uq=(Ig znpc?}10kYq%CMt%1AQiM+ovj-Za?&HLZ?&t=fPFp%WQ3%mA6Q~FKF8B`LkX$4aNYr zy%gk5knP>1Urrv~57o|`l*5HW2@!o>!f*mOXO*L9yNu!@cYrcV%bfy?6}80iJT9bzv9Gi1$AjHw-s2Y^ zLQS}~iO@6|hSFf*V%YDG7T5Di_DFFDfL)LpA{h{h@gj9R7oTgq((^_h`2WrVY!cBi zCcqoA=@0rUi1|3$+Ta1O*a!@(R4cZQGqcQyN2xJA1-*$+#q-|(Ig7mDX~61cz_|v= zi~GiIPR{Q65f`%$O_Hp+UyKgS4BX>z;Z2SW&Mb(;l zsbBjL!!_G3^bnq#Y-TuvoDZ)XhfDqt?ShfUFZr+|Ds-4R?JkZ_xi4=UyPX6{2hNpvGTYxa!(a_ChNRK7IYPu+9Gyaqnj@x( zD}~Vw*`BclE~5ddN?2s=;;01Kcb3h;iTwK8-%-|`kTCovsW@DuJ64rKu?1hmSFq`F zj3_U=8Ms<;9sPv+eArXaf%F~#yGEqtie2LJy#sBcCFuK&gS_uT3*?Z*%eJi&OR-gpTDL_)#b+zxseplP z-f2*cH~i>NXd}I>7v~T0yJNh|&3_*0>_h0xrTg#@BUyZBrc!0j2=2wRC_;o~HF{kx z!@3l?VC3W^@*&1`hk=t+sUU-ezLAtg!6~HLRZFN%z9Zp9Io#{umKNhMQjHqNZ+lFm z_JiF(ul|eYgiK4c=E_oEMiLPSK|il+?QLD%5#VTh;l|Xl=LLQz7h1U@xxR6Ppm`(6 z!-CpZ5JNU>dnu-eCm}2k42@cUlll_@U+#n7OXUIF*L}D}R*hM?8v6#X+9h>R@7Hf< z^l(bhsUdL(Yg(AuJe||bpTV$fuhJ?C_xYP0=RFL98+-~u68?5<_I))vHxHWjXKP|8 zxHVW))p@F5jbgmH%%POzO>!>wv~r57G7eG$+fWeQDj7Wtm$k7cF>C!6T}Z+%`@>k5 zIwj0U=P9)}FZU>6N)SX}9E~VC(oN^J9<EDH)#>RL5vN?()r+tV@knZOfKReYa z*^IKF3BoZ&B1qaVe;a_-S2HH@5NW1Ko7GdQf|Y+oJaA-GRLqk$Q&Mx7&4?~o#FOsN za15_HG4meUS`nCo;_B(!L|zvjcI9yIAzFhvbZ7t%3D*HGUwLuwDCrha%v!VGZ zAtm5;dPN-@gqKjm8h-#$`MC zF)Q$A^Lqh9tMF$jytz$ICHJ$AvZ-PXqHf9Idi{;Q^5ntf77cpFdfp7~Xsb4&$UtD% zY9Gzu)6c~%9av3SEjrY5oh;N_ON$$cWU0TPeEXDgs*B5djp9+vQa1`!kE6}sR@Xi; zS6{^V@>2i2d8CfmgS|8!xB*Zh*UG-NOR=-fwSwTZNC*Z38q7v>_0O)pj2v6x+?E6O zudC7KRH8OIQym`nbcS6GvdhF+mh2@Y{FCDcP86~<31#L>Yjxh|B~K2 znBo3~mU}2M5L|B`cme|v16ekgGib9iUE9ok4X`sedg5t$eOd5!@&z$`v9a#3bX$<| z-7L!LJCr^S^=$nc~_vt}E49|FSq#Y-bsl|9Ff1cuYK3tbh!AQInTliClJ zv{li^SQ)oUvZAQRYC|gSZf^2>$q~j`I$6=nAr=K3O+$}|FRy=S1?c3@1G|0$Ihqe? zi4=o4whf*cb8qP-oX9^bo$+|YjWRt*%I|)=4VTVzjR$h*vwF#(Njp^-h7Q?k5Obcc z^l4GpQRgqp7#}s2cXQ%7?RyvJsgcU9ces$^f0bJvYPJEx+oA`ey4K%EX;C#kl+u}% zGQwb8ud#^NNBK_OYdKZxd}_bR#F>)S`9%znfUC9M%o-y>Vu zbjMbpz2kXq3KeYE{a1FCM(a`&k|^>AExIj>o$ull6CmGvu0DlJoM)8j9P-6vH#$lo z1(@oyn7A*~JHI7r_a`zAaQx3^*fqX+ChCz*5+B?zR`F9YqeSMypS}y4vQ`hsbJ$;f zKBp95Z}g*tiMfJvG3EkV*gzrvA-#$s2A7(TSxl6lk}Xw_EVMzBZ1jiC&*^sPAQLkY z8vWo-x;}i7LtlSKVmiJf8=zsJwKfuG}Or~gIBOD+YS;v}nuGDQEZlSu%Y-pEX@VuD9fwvza zua7pgoDq(bp1f-vZFN1+%*ow=bEthtxagv*;_px)yLCPnHeF#;5nkM{>3kJ z`w~VG9@35cCgx3K2dO5Tl11R4Oo=EJxPWzaoU_@8#SXXlN9>guKZ`>hHC6?su&zr$ z!f&ojYx3r)F1Dge<-{{y3;~NaNbwl|)QZb4vNFIXDQ_&P8V+HFPD` zQ4RZJD0FP}T<$yy$5iEUPl@iLhoIe4!!XSGkoIIQ?~TDc3ZrV+Nc=GO97X~6cus$t zipzJYM3gGp3JzU5wQMJSqGa3*6;9-bgk_={hS8h@7l zYyQN0st}lgrjV`)sA|W3I#L^bZ+1Dm$CGGD;6&*mUY^}6o|<@Nlzz{d(Y8O^rP34L21T>3D%lv zQh#nfy*-#xyG>Ph%AEb0c2mm0e?mey5a3*`pPzS+nb;=&GxTKBiYImwlJm5Lc&mRP zYRih3yqXT!*OG}-agN{e!>Qzkh3p;c^s=sqYTm z$+K%RGGk0?`(pFGL1K=)$aStz^xF#y%QYB1EyhT>9C=WdysO%AahAnCf^cs^_wQz18(P!wYB){D?MG(2_Wy=4q)0xg!r_QD-nL7Udf~wKjpVKI{u0Uc` zKf&_xSR>IrPkHUa4vtVhyCaMvCPJP`(bktT4MPZ{W*F0UJM3&YUQjDWC5pOve#%9& z9Mms+(`@z= z;wEKc?MaMDrg4Qf{O6^%NfbK=hp?JHf}{~x(vRlRicsQrXB9pXRb-Wdbi-8<+j=O; z5^*loXmaDiq`KLAYKED7;GEdL)b;bcha*X*;yeIE*&P9P1MDgj3Q!E+sc|4N#CRB6 z%mN7=QK94a&98*}b9{|{SSmVaB~=_n$c3rih&+0RF$ObPaG6L%8m6|_6u7AV1^`S; z!36A`t#a^R2Q|0gt)(kNWL;WC5{KuVOc_U*U%Y~8F7$ere?V?5a3R9;h*akCS`(3q zk0rr#;_`6+J&hc+tCW5HD~)rpRem|yxaM9s@k1p3{^h)2FJKXA zt{z5ccJevw;xbp~F~psxtfv^>!d!?`LIdkv$Ng`STxj_f=)=C9Spuac^}$#1DD@g9 z5vvQo2z6M?MOIs#X7>VQRjMUi6qIVM6<*$OkKvW*4ytKyp3=I;=a2NScG^qltY-S* z&K}uQ^MFO0p7qHgDNan4>mvE7;IQdJLE^Cnd#)LqT}WW45yG8({Y~r3Nl&fYd&dGY z?%T7rPBN)du_zP_@Jb`Z^xMiY^Hr?;OzICUHV*UEG(=*$$>#t~L`A@VI1a^xRCiX~{$#L2^ zY=P#W!ZU}s2yG*!B(W)wMyZ}MXwn_Ukv0}z?PwxElbAZVk~&&{$2}Q<(RWv#%bh`POa<`v zAeJX2oWOcJmzT@E5V5$m&U3p=xBR+-zJfjAUTBS1r6iw-H{%ELMz95Y36oy{#*)9K@<6|AL{0li9>WNxFk zeZdZ>uMu9hTZH~7gp^qQ!MawcQ8UJODQeS4C1>kAjw|(zR~WG)>e;o9$|>wyc1si2 zi+yPBK2gRg=esOwWsGrerX?aN0#)7jEyYm0{x}4c&i%=feHpdlfG@Ce#A-3ppyjeB$o0D^=nmrwSU;+_9>S* zP*HLkxiRl=+A@tKP52PUk16phbsVaBiZsh|O>%LDYE*L*5Li)gkK_ABlJ^oQ9;>2s{!-oBP#;73O?kdQzrvkD%-=oDD8aml0P4(_&e;4uy1nS zqXul@ZA1jz5apK)$l$4AoDLM zdGG0M1}{ek&vkTt4XPrd+1Z@wM%jK36OQA!C^$fWADXN`SN;!J&EERH7=H`IsxZzT zK=qlz0y)K%|FNJ6=i@*4q-hvGWeXI_xzg@;zV>J3$yCjDe!$Z>1@DY=IIRsPliR=* z+&RSS2q@Md&sGl~gkuYUr&r#cfV$ZN=Kb$uMV9Ez;D}^)_O~6-vfOxe!xgNYi>T;{|7@Z*0ApyGf1<}JW8;a8`Dg#OL*CV?}&X7KL-aMO>y z2w*z2eqd??ztcO_#!}q~79&|(oxTmfmKEr^Md=SLd63TEUoP060 z{LgDlg{e*p6fx)+fl2Le#j$|k%9NJ$BrE-)(r*d?ht4XHaC?U_OGrU07;Kb;4*)|>g0lQ(OB0Q?RvYjfU5@G}^s-5LQ92P@xa;MNT* z31LmS$|coj2~HlLG~q=WWja*+2CoGRfZe-;#1A0|y547%@)&B!RsFb74`8pugHiHb zzq!Ye^!_3E_ygAg-m-7{FW@N{y=3aQ93IrqIn%0O1qv7(Hm%hi!QQWds{kPq9|7qq z^Eo?*<$4I0S{~3y#=qD@$_j)jc>D{&o0BXY(3ikJ!_R&NejZDXdNIqwdYN<|?&>2= zj32+}($6Uk@?{Sl&H`@H1R8zw+c4iO06Z`v{{(zzy%u}s>x;&qC;;9gzH4kI+7y{_ z7R3ra)7N-g?eJ$|Z>}S<)p#64WMp+l@fKDgrjJpWaFg6_0rI4M{K8N0u zKj7lZ1T!B-2uV@ReRKOAz*JuK1P9;oSJB;7=LWJQDM;D;@7a6=yOG_;o1>xIpN2R1Y|L5@4m6Jl4t zD4xc%j@G(tPv~nTJ@*iTtM}zdo^$@)&!20XESE#A2++_!0I%TwG7meB$xl=Cyg=bW zs!D|-HxS8ALcmjq$15@LcJhX6^JOdwX1`Yg>G^plUEW3ktG0qgh^cZcL18kz)Eq{nh`FOnX z0sdlegKb<6%r5}G$#HH+OF;H+gXA6kDUP7`2BgYR+ZQDHXx|lBhRo6pedm0}CHTtB zIb54Q_k?*5fe7&3AC-WHnH&|NsGG5Z=1ARAhXK#&8bPIfQ-e zz85X9lV+>!iL4!q>zA%Hb06rnJn`6oa|phMB@n_qs;QF!mImLXOTd``=XX*}f9SD5 zev^81@t=42O3nPa3J^Sw0T@)Eqa;})!oAkta>bg-Cah_-FBDFxEbj#-xMl-?@#kKDMvE2h8+MNvd($6m$H?ZwHA;#Qw_y4L%jpF6{rnpH*L?0}?3M-^1?7f(NGQ z^9c}icEul`WPu2SzFszFvf%6fthj<1{0jCXkhVmDs_8ojZZc>?8laN_IxjoH^f%pv z|1Ao6!1?~|s}kfIudD}f*Z|@M>cVuX0j!93_I)(&7GNk90=E?yU!hrrm198DN&>97 z_bK=m&whr;V0g*4nU=vJ!RT-wT+&foJ3pNzio z0BjeCD|rBu#|>|g`Idr0W%lj8i~$}Vuh6ufOFNK%vocyb2&mJQqT#sux6S6f{5&jH zL+3Ee)O{k*8|8GrwhOFVJ!AziKE)_X9FUJ^pQli-qp5mm>Q>@FD3vTwIOjb{xBa^; z38CpP)QEC300RlZQ{ub`DxRGjlW;{oK4vz=f&-Xf!(9L-{6+wJLUGXGF|WomyaSI1 z?JzW}cvAy5TZ@7hr6MSxZvx_mCTfxo9_FNafPTr=boSR7(`kXLX5Rza8oEtcTx%6jC{)9uh^tn?EoUufn4d*FPnb}XrQb}QXTES2RmQaJ=Aj}4V zZFde_$}JH%0t?}=IH+XTWz$2e{_-=(@rDGzm8Tchs@!(ya~1Ru)TTwo3FN}#Sjw=v zX5r$2VGwt$j0Yu-)^DJup?Qqxnji0PQVK+FyWd&J#h%$3Wba4Yhm_lC@-Ak{{)WO) zgMjKAc$$&5R>0%(2lfx_5V91RC(_KqXNdFDlOzL?-`q? zC;j4sGAW^x9PHAdd$GC4f1|u?-T37-vcV5i|IPwl@Hel-2<@tduM7@5YG1h@6*l%VT z4N${N2c^c;4s<3|iY>fs29NIBkUr(y_N6Mcn(MaKOAgD+6fkFKK87$I#00B=X(HpF z&V%;40B{-Xf4{Is+3Cx*K=8%LIL7VC3YTQ3)Z?fIag30LAt)n@$|t3$?!C}j%VtS+ zy_@SnAN^Jb`%l#FWpIL~>;opp{}TODMt^)p6UeG6~&f+ua9t+KxiSR+t(>N)D5hDFcXAsdl%H!olA zeq0;DO}^MTpw{_2j@)|SOwEn<@&NEAb`bpiM5Lsi^yELYQljY5)Qs9#Lv#X<{L@#T^oPjFwmbf>&8tZD*m96y0 z!j}2*!;~%tOn$l|VQ%ScBxGdigR#41GA6fbova(itwlPO;=t<_o3D|t>BDaSfjh^|5GO{5B3(DYD2KgTR6yY`@TTg+-q1a zEi}M82}5WtbJyZ86^N4}Io**t!;)(93SYA6YvhawH_;zWcu(0^On({Hfvu!l9RaOj zme?~uWdPLRF1O357}zPi}+^RQgxx8oe!E_VLOO$a_ezDtfCI}ATty1s0Mm8-0P7^97|hohL6 zL@4qmVNfae-sjP{64k+-lP+uS2biJqEyr?l)_P+cFTeC_sgM{e$#6Ky!pQ*-ei8^r z%k_zLw>Hs!no-8ondD^y;2k`d-ioq{zQ2(%OR(GrB$&uGls14?-um@`u!%ex4rLw0~)ah!t@VajAtX$!rx!{UhZ*DB>zwU|WH-(c}( zl@mNuem8VGOg+Wmi7O{po=j6Iy)#_^w1+5*iuM+RqZKfX$xu$Y1>T_Rm;{+u$BWLO z4MW=|q04%mLzw9BoJ!8)KlCssxOr{8+p)KDxt>eUXqs=R)hp_?2c$V>(bnQeh#;Y2 z8l^CO?DUqOBBU%aRbt6rW7Lshva5s4-veM%Fm2W?Pu)nrdXb&*nQr9@3~9Qair!zl zrskT+UnJX-8;+Bdx(dwng)CeZ!j36?Wt(hU^--yncLbWl7H9_z7=>_MDZ5c~foz^z zp5RFlMp*0i5)Z~S__xVaBp6&9bx8FY`i0Srsbb|33!08>4kH{*`xMcRlHNVwxq2pp#Ld=rsdoFAwmWz!TRn91-hT?Hf zUhw<9sKXe-A*q0`prvrHgKS{^9zu9TXg80&hCs>~F~e~&fhd2i-F2Rm!HE%;iNr^D zT>B5Bdf6PyOFV7)`HoOd$aFB9j-s0mPp7lESPiEP1A>9rkXC`^FfXYFQZ60Z$bdF% z7LhK1Q=MK-#=zY@3xX1tu~(*p>#B;W(&sm;&QoYEAAud&ArueW7~51=BA!HJ|2Tk2 zLz?R~x#Jg3ZpmwvJPW4mH+5f$#L3-kS7Grk$;wLl_|_eW6~EK}pQ5fin(F`mo7sEs z?M5=Xh-_VDk0kLSGgM?|Z;DGol)Xi%WL0L?HL^#A$hc-Gl&o-n&s(3*_xDfda$&HZ2BLJ*@WrBGpd7Gz9E;7_Bp765& zO#Qa@sPB+ZLimL3T3{*SwN$5cG)YbP7Uw0-9V2i|CmoH#73U~l@fMi0cBruFs>#kS zfRTV_Of&PWJFm64wY*lG=TaEjw$2j9c7nS9n_cZ`n5B7c_@=qwu`!Rxjf&4aXV18k z@xO^Wjg7l+1wNR1qUUTA&}5%F_fIjiuQX2;g*4IUcJZ69g4htEUHs$p(Sr@?MD6hxtP@SIQ*y@+<8UPOjhyqZhJuJro|S!)M@$p74GrIU zy7VM>@C$%n0>#U&+jr<3Z66yim`Fv7_u1vkmc#fdE{?MT344tM0!|vK> zX(H~$m**W&PrR3pJBvA&`c&%s*Z~I4-~pq5dTZ3HBRZ?&rAI{#az^Z*sp{r#D)mR% zZ_ysjsb^gDQKlouaRs<~X=x4hNh62yrUSaKv@6{n;!4(ElY2|w_oF~y>oU-qW9t}X z8`Dj05pomkp=%OtCTs7gr_G%=*V}9qMNyQkJsEvJvpJ!2L^8NibB-ft%2h@lMx8jlMc3K?EC!^|dU*T|ZXj5WP zd1j<*BrdGqz-S)1y22?6o!_1)q>$K&oSIY+BjUZkmB0_dx`+$N9oed$Mn(N)^zeF4 z1!o0rfxxj?Zl-C-X-N42*AdkxI;P^n=VxU^4hx_*7QKyT)iP%)sEWyuAmye?n$X6A z@x?CxXEk(;fyIEmc>#E<`67tS}=yCVM5?P<2a)8pIGWZzCb0LKf*uvTPc zG&hf*mjtO4u0-7=W17W$Fc zKxP!pls9C@sPRkENB5T}6+2RP&$gUw`*bqnIkQ!sVcxzUtt=gR!MU7BMC8&<*;P0f z5}xlh9_6V2y3`{v&Dxz_ucKTQTRVO6 zel<+E3`n%&Z#8bY05e0-6YU>4+F{E?YFv2nzHgdh&xD5j@u~Za8&8V9XE77%r2C4L zlYAV0Z2;XR%;0tzyM74G>tU%MXHxQZCkaKz!}L75AH7tw}M#UOtclnA6wV;|LdFYz(%YcqK6TRd)+ln6WF@o|9a0VyBx1bmdsJ0h7k zXv{M&9>mhKrI@U4gDu-kLk^`IFgsE~isMJW3Ir{l+BQ=l-Q5QqxnB&@cM)pDk}Q)D z(hbns3w+=}@Hck)+WIXaTgNP8f@9Z>L)m+TJ6VfGQHzvmYCGIKXZ>3F7ean(WB4dW z%Q27g&(*)NlNLPI3E@%j2>&-!;ht1jCOA>RnxVkB#>zG3S?UA4%u5Ik{27k2)(Ps$*UFo@_xWixjaR^q(B}++^{^F{;a&W#{1H-F{WcJo?Vt) z@E!Q8JqKoKEr1tqwfO(gg(4_=!6U_HM2N-ob0vtS?ZBIh>?HCh+wSce#Aa}F_0pA1frbXjd%(eTm+PLPyW;Oat*E% zu4ScPBl#M{4fJewjLJ&eZ^0=cC*??biyi*+zQ%}T6@rtwxQ5v+8itJ>GIN=*;mnNxuz+`+;bEG>^i$63fR;V`qLVX> z9Jy|r1%Z47rLp@9XE|sU#)h-8)ot{zUEZ~SCw&`ZF?FBhYi)nb%|7xv^` zD3|X33b$>TFDoEu+K48%dk^3f`2TKbZj(P)t9KyKyL>zrKD#NDcU5h>xb~=9n$8vw zIqor=c<*ExFH9_ha<$LeQVwgO`ubcz>!bmqdwd3({~>dMAL@RCU&m>v4XKfYYGu>q z)=Dqj3MrLKl`HNaIJu!go{sFajbS(o(88mf?^-9id7B75zu49l7A^Zcv{x&kP*Ku2 zeG!BF-)b(ftjyoEpKa)3<;3e4@@cHs;HdVDwWZ2<623m>Lr!KlklBs6ZPMG!3`OzTPZAbyKLT>M|3D{{)%!SuDNg%1Z z8EzIS8}!MK6D-rsDJj!33*AlRpCiQ7lZTW}33VB_0o&MR5H7yNAYaP=5G^B^1UcJi z35;i7MJ%sF*6%a!DWdG!D9(Q>nz1fIZ>MHKAoMIBI7=+Xvn?trITCbUr5Kmay?0Hw zvTRt9&EYQ2sI_tMZQdIg>f@7y0R|aorRs%v>J9)}Fv>9Hk-cmgxr@qW&ZQmbm|=cl zH$;&13G&4>-&MuXP6WT8QX_x)9NN)6r6JJ;J0Q-KX)lYou{61{y`aU{vr7_FlC|#A zkJ*_O7U2_jowBO%)6pRgy;hbk)y;C+q1F%gO2?W)tnvAr7UizdwWeDETDlNK&Vl=} zJjgfDx~IG;hjlHY;8YWQ6k$%>Fe)*7p?b4iMEk8Co5ky!W<+uT(^nF)k1_M2tkC{G z_m>$j$~|&ZHTn3$0=c0D9!yu~`Kz&V!`7CB_ceC{5V;6P@S(^WcZLduKMUf_Gded# z_3|Roo_M5GPa_ets&WLRKCrkL)~pJ@-C?<=evZT8>gKb&XDK-#ukJLPEUc_Ppe|J|-MMWwjqe-35rE^TaE zQi(HA^+@_V2|R7dBPxG+-Ntk~qQwF1p-rz0EL|>-o2Oapp2l1ae{Xm=sxSMi4WHp~ zj@j;-EbZpiIprT=I;yh-Y}AewyC22%DwOU_^%RygiuARnKB~J|<(T$z=Qe`98}WhU zo*w|@=b4rKG&Jsx-VVthztMSI-P0~GKpLq&TYpSPL`QxnVUD4q^*8+syVMW){pSJLb$(t-t-T) zjN{qtS@aVQ6JMuQMU&8;ShrC&W11ZUWp3de6iVXZ$74)J#96B0cw70~H?nESZLO}5 zd*#J5wrMR-=8=SMJ=hzca5_@|J=(zhk~HMO?gN@b!4G6Nr##I$V-I3pVqWVqhKNb# z=(VVkjwZBXDFn!!uNESIg*LUTli5cxs_rQ-5qfK7UTQYK8-2qZt82_-JsJH>aK(XG z_exjJCbx&$E=if1}B%u#=| zXl8RQB5iY(t*@)ygb5fKx=-cVvj*R*qF4GQ^zQcgy!#p02=!5^>Q9->w3FCxkf)t689gW2N()6; zOa@QfRXL{O*2au4rAB;6($DJ>0O3RGfc+c1IUXumTIo#rbni%)cLBrM>M3XGgzu_7 z+^h;vIjW*Rn1N*fKa}CM{waV`9DOV9xcsXc5WFT(GuhHnSAxDu(AC+=cq`#xIbA!+ zQEhLQ8246+8Za)}rh!!`6P%~i0HmC1@RJ&z1)J`{#YZN&V8*XW;p#s{e<(||2dFA4 zu<13P&xfDGV324o#5!J-@}Ndhz~>w5RyL#pMd z@;a?5;qN%s@&deQ;_>IR^cq^qY2s#a#^2yhLZ1M-TLFN-rv@9@j^$Wna?Vq!&y&aa zfC_U0@Sx8~?(IO(!QwzpR<~P=zdJZS&P&}i*n10eutCWoX7lcFbFMv0?v~R`)ap!g zGixj3zl^KDh&`dJ+U^1%vfRtAyzJ?OrtO=d{TuRLd_n*~`}iRX{R4rLs6gV9sOX)5 z-9XS^0Q07VwzO}ehhk8u!elBx~}(o3`pY44Nrq?g)N|w zV2lm*naSl8z5gUk*u+@UO@N!eHzT#;Qs0`dnp9|i8CqtffA{(@e&7A+{@Ro|PPIv@ z4|5u@1|*-~j^+@4It^hW4w4U`zmGo9;=%{&f``{htxaLPw-f$oR{%a6@#!12@Wh~< zPu;8byM7~V+Cyd%ll^nC*ZCDWhygg1d?hBNhaHODp#)9Bw5pDYG9rhMSTp{ONwJI7 zBvH_cyUbEX+if~qE^W%sY4!ShH%CfluA1Lr6*@>D_yCyoz4b;AIlAI;!_Fy(7>ee8}uKU;nJ3A^k8j#=3rHO zGH7q~eG1~+7N&f71k}%RYPeso!WjM${Rym{2kO#%SuokvKMZiJqqa|!5d+n1$nRjG zx0uj&^X7YD#e>!bmbie?+y-!Tu3(z@*@g=tHK`e*8bXd2j=J%VNpfbm&`$`QgPax^ z8Q`-%?unmW4sN^x+uw`z!0rDIZV}%eYsUfZquHaX6wh+%F~W$q_;p4&Po$DnV-P;t z{6AqDO4IYn45y@V?d?{kEY0VoGz!vf|8c=w!SLs0iAwK!M>AAfl3ZX>zyAKk6JU913D zBnkn;Eaz}}*@D+!o?trT*td*9|6DdQ%J66&M6G~~>bI77-N{jms0Nhk;6vvH-Mzi+RwFGpJfXq9s`Jt$OdVHoFT&@__Sn zwR0lSc7M3|WNG~M#(at{q*ieF(J7k_h37WfIR||ERAw^=UAuO?M(px{zoe;1$2IEl~M`v>;_3Dn3La8@QH3U@N8 zkCkqjHGMxmhr`|dWE~{Mc&v5As~r5b+vZ)Kgl?APt25Ttc{>@I zyd1f6m+Far%4ds8QQzh7xb81Eyd|0Y(#|;CW3~wmQ=LTwnRYn>y;uA{wE+fUE*vg( zO?mmqqTaC#tA&#)&OURUvTHwaIw9fCy9}RWtk&{Mr5+&@h%#RO`)k^i*=mVyI6lDc z7f@)R=J5gm6T!Pw6eWf^pV23#z5?Y8nw`fma;)Ztl+-l;u$1hNOwb@+J&wr*t~@Jrv|8Y;JG=6&foVLH?V zQ&+^VR?Ol>xzg{G=HjmH>*sdxhFN70yz}V%{qvC2`S?}k;ZMc&5<#(3)1EwXay#+f z$LQjE$;_S8Xo=#0{g9;3NTF9PhgaeIL1RA8_(ja9rI+sunl=J?E~)uH|DQLp)?n z?666q%xE83HIQq9?+B0%S(r0XSe+ycpMHHFjx>CY4u1d@i}(Tty)CWs^$q2s34FGbttR<7-D*@QdEAn zRZPV+UuTMfwmcOCVs`MlYnft$0Z4D@le!oSEfbUp{n3ukFF&(SE z+yW(4z3;c1tBfka?KEeTXaoJB&p-u&?VZ7U0oEQxALDK_XvLZ`miGlxFiDUR82|_q zX#CpyDAl#Ij%s~%(M}9uLViPmoY?%qx!^+SL0{eDuD%Iu?Fny29=}H>ETOYY-9rmUB`g zGBkv>wMxQ*<99Fi>hHjvHVj`l0Rhcp9hAo9I`4rG7Hn@^>9`5s4IoL+VSMbf`CwE| zAxohieB}p7Cs4^g_+7saI)g^Yq5?r^DX8foFtnmHUojclLLRI?<|klJDj)=clB38P zpR@r~VTPkgdaO~`qs(se9lQ_~Btb$GP+cqGa`x{}m#~8V5}$Y~^a#?+@dNV;nkI1I z*MWqwJRxY~4q;suwATri!qw8@bPUsEHR_?{{b_?c7=wyVe`#XS4CEt;Nf+w9O&v#q z#$-9aAHa+iOibGYW&u_$*zMc7iwhwe@69GdZF`#}0S)jASn zn@IezVB}kVMt9^?__v<<3pAWt*|3U%4bPhj|n=w~Azud9QKomL#SRHxz2S7tQE$1Gy|4{Lr}) zK>*cO!}t2<5N_+_^=i;L344#F1za&Z0q&ouE_rk(3B=-q@C(_+df+rx`c7h7*x)O- zdCDhc{KAr(8z{bI?HN^E*(i-*W}8a5^6dC*ST2+MiS;)Dq*DXoQcG<%6Yf`#evwmW zaD(9K8oC|(X(J_e--~hB;i7P$i>0zI9N)1~Z|&iLQl{Ngav=aI`S$*)fizh4`*|NB zVh%RPF3`&4k2w=rXgTwmvF0OQx1jhyRgp5Cjmw*^6UPYPWMpLKg11iUYxg)8 zTj9_}i}*}GE4LO3ok|d_t)&RL)O9 zk??*ANJ;gYeq}Uhb;s*<@H3WJPg_>tCH%C>DtRdV6MZaN_D-NPfSUg~O#1@)7W*)| zdkIW5j{whwyt_l;&o*me0IY08009xWGE^X&PX7#?^G4IfF3*ddW}N=mEVPyB3p&sn zRw^L1-hze!v@K_3mc z!983L4+~)Me4TguOI%3YgyX|Qc#6yTLR0LLdQ1}kZ#WI`HUI(XH@Cm^SRcX(C;vDL zA`M}cL*gR7G+zqU@de2lpvAjRgIV1GVMBTz6u|{h$0!Oi{M-n`agmX2jpJq043P-Z z#6F|U&^v(MZCNc<^K}`INGmOMLc~@dRN`VnBP!Y{g(+^9(8f?U{FQh z660y9Xg6+{bI#h&+L393_#wCtKo^%{yV73q13x~d4DL3B-KErmiNqbq?%@y^0*Q0@ z!BZ%ZnB4HgPDk>f5r^*ylVk6u%EhaBe@T%9=17(a1>W-*ZBbh#$8LI}jwLs{?sOTU zo6FEAI^lrUrm$f8to6iWL%2KH@fbvw^U}CiuFyBCQ(l<#Vn3%cf~j z!48lEFisZj)A^^`euyA(h^5Qu@UeGr014cML%izHutsD3v~~S9s4}`ryvljgw?W6K zE@o9V(_F`COwLS7%E-sbekxfPCMMPg#ANh5iA(WmQ6v=zLX>kW`viZAlu+8mgxvOv zXiU3luJ}kquU|6NOl`wwp`In5GZ9?UfdRJlL|urlq75p=$McAjlmhYIPAn+ zU7hfPcWb+$m_i<=yZZbILK=#L1lbb}K$9p$yG5D6@y)I7^zuh+%fv9WRC&|OAm@B# zTtLJt*eHKoIt9~W+~GZ_Ln!?RFuq4L-;@1wBE~N0r`vd2L-7oUS}cK`mu*#HC>_ta z%i!;8L#C;)rS!eH!bN-K2%mpJk)_?N6aSv=fJ%y`K!-1q1givu`aQVgMEdLz zd-yr=uo%Y5%&6)#rnr7@0;nKiR`%igU_wu6_&i=SUGYytp-7RJ>S&Nb{5 zkH6kk!||yF1RQ0^v_ClvkX#&G+YEbylQ?23Ke#iMHXJp9^$jT7%ZDQS5GvytCbG_N z$wC)tf5u4-B8^@Twm!2aDq(UJDWI29`%Q2E=+!0@1Ddx&`JpMq zCF&=+@1Ok{=u{*9kc>vzp_5wP!-HQZww6DjZJR+@ym0fzvlkY7e$V>Cws9Y+kGf95 z8TJB#&jyjXr>*#v{h{`NddU3J3$Nro4Gj3XUph@ntntv+9>IQhR2%))4i67@RGz^3 z69{FH$egbZ!=e#?8oF2o2BW-|R@$+(P{M+%>z7(h$gJI*1GA_^+KjmGo*y}W<3;3s z^lJ=swi673xpfmA=p;B~lwl%V>rx>}FrflFxOq8X04&I}+S6w6$Ab0%*mZ~$QI|IX z2||&>-c`Lfou_2RY<2HUm3C!yj z_&RXsH9}cxOU54#`+G6E3o`N`pz|C~Wa1C>7-`qI>0;X!)m;IF1~|!8RAno2)^5Gk znwpNMje!$(t`}yinnUvvdtmQY7z#oy|2t`X4UDn@>an*7+nV96P=8{233H$Uxqyqp zyKNQ@2wqtiV<^H|j`1c06e>R=`NGm4#$r{Ym|>J2s5G4Sw*3tvT}yjw9xcV?F4F+zm14-F{E+A zQZ^ik&IXuTA^ChC-uN=W@)IBSVdN`_z_)Nhg#Sh?lXu)1vj)_(toUSSx3@SO!V4UB zS4;FWz_6xZ-U7Iu4^%BFsZ5IRtH=j`{=<+`XFTghx9V6=Gg+--$4y$V*+Jy$ljtpF5 zJ5{C7gNVw@vwr1zL?_o>>Wmzh?H&6C@YFa!_x8aX?{s9!E$(fWt`fLhNyB)+sOX|nWbF1 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..51751500fbf964f94e18c546ce8fc13eb1d7f943 GIT binary patch literal 120519 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̳NOyP;#q^ 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

ry<=B85{)xkI24^7r?Tuf6Z#HouIp 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 bD^Zc2De~~w8>8&QPmoj{dyr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2b90fdd974fe07eb6ae7cbe0fbd147985ed6e866 GIT binary patch literal 108872 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�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�?9lQNL;Z@(oih3l*-e8XX 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ɀmSdhQ!rQOtx-PKWFZ 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%0DaC4)SLS4I8@8 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ïu-_7FhS3R;{M9>wo!h^8}9l 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%0AeHr}OxvAv! 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 R3V(v3uB@e0u3#DZ-vFX^pO63m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9e0d8eec061ed93b812eb1de793899ccf0223c9d GIT binary patch literal 65354 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 Pf*j%L>gTe~DWM4fDYJNu literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..779e8ea7b4b43060e963bff5ddf2274edf98519d GIT binary patch literal 41477 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
%cvEGd=-LZ)~m|4sp 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d0215948e9e71668a9111ca71080ed6be49ba0a6 GIT binary patch literal 115110 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