diff --git a/README.md b/README.md index 5b62f029..944d0dc4 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접 ### 한맥가족 User Import Email 정책 - 전체 시스템에서 `users.email`은 unique입니다. +- `active`, `temporary_leave`, `suspended`, `preboarding`, `baron_guest`, `extended_leave`, `archived` 등 모든 사용자 상태가 unique 검사 대상입니다. 특히 `preboarding`, `baron_guest`, `archived` 사용자는 email/local-part 선점 대상입니다. - 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다. - 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다. - `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다. @@ -171,6 +172,21 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접 - 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다. - bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다. +### User Status 정책 +| 상태 | 표시명 | Baron 사용 | Works 처리 | 일반 조직도 | +| --- | --- | --- | --- | --- | +| `active` | 재직 | 가능 | 생성/갱신 | 노출 | +| `temporary_leave` | 단기휴무 | 가능 | 계정 유지 | 노출 | +| `suspended` | 정지 | 불가 | suspend | 노출 | +| `preboarding` | 입사대기 | 불가 | 생성 안 함 | 비노출 | +| `baron_guest` | Baron 게스트 | 가능 | 생성 금지, 기존 계정 delete/deprovision | 비노출 | +| `extended_leave` | 장기휴직 | 불가 | delete/deprovision | 비노출 | +| `archived` | 보관 | 불가 | delete/deprovision | 비노출 | + +- 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다. +- 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다. +- `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다. + ### 4. 주요 시나리오 (Core Scenarios) 1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인). diff --git a/adminfront/i18n-scan-output.txt b/adminfront/i18n-scan-output.txt deleted file mode 100644 index d89397ed..00000000 --- a/adminfront/i18n-scan-output.txt +++ /dev/null @@ -1,474 +0,0 @@ - -> adminfront@0.0.0 i18n-scan -> cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js - - -ko.toml에 없는 키 -- ui.admin.users.list.table.msg.admin.users.detail.history_desc -- ui.admin.users.list.table.msg.admin.users.detail.no_history -- ui.admin.users.list.table.msg.admin.users.detail.no_tenants -- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc -- ui.admin.users.list.table.msg.admin.users.detail.security_desc -- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help -- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc -- ui.admin.users.list.table.msg.common.copied -- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked -- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms -- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms -- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id -- ui.admin.users.list.table.ui.admin.users.detail.form.email -- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id -- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin -- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug -- ui.admin.users.list.table.ui.admin.users.detail.generate_button -- ui.admin.users.list.table.ui.admin.users.detail.history_title -- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm -- ui.admin.users.list.table.ui.admin.users.detail.manual_password -- ui.admin.users.list.table.ui.admin.users.detail.password_done -- ui.admin.users.list.table.ui.admin.users.detail.reset_auto -- ui.admin.users.list.table.ui.admin.users.detail.reset_execute -- ui.admin.users.list.table.ui.admin.users.detail.reset_manual -- ui.admin.users.list.table.ui.admin.users.detail.save_tenants -- ui.admin.users.list.table.ui.admin.users.detail.tabs.info -- ui.admin.users.list.table.ui.admin.users.detail.tabs.security -- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants -- ui.admin.users.list.table.ui.admin.users.detail.updated_at -- ui.admin.users.list.table.ui.common.generate -- ui.admin.users.list.table.ui.common.status.blocked -- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri -- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle -- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled -- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled -- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline -- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder -- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg -- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder -- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache -- ui.admin.users.list.table.ui.dev.clients.general.public_key.source -- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri -- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable -- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help -- ui.admin.users.list.table.ui.dev.clients.help.docs_body -- ui.admin.users.list.table.ui.dev.clients.help.subtitle -- ui.admin.users.list.table.ui.dev.clients.registry.description -- ui.admin.users.list.table.ui.dev.clients.scopes.email -- ui.admin.users.list.table.ui.dev.clients.scopes.openid -- ui.admin.users.list.table.ui.dev.clients.scopes.profile -- ui.admin.users.list.table.ui.dev.session.refresh -- ui.admin.users.list.table.ui.dev.session.refreshing - -en.toml에 없는 키 -- ui.admin.users.list.table.msg.admin.users.detail.history_desc -- ui.admin.users.list.table.msg.admin.users.detail.no_history -- ui.admin.users.list.table.msg.admin.users.detail.no_tenants -- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc -- ui.admin.users.list.table.msg.admin.users.detail.security_desc -- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help -- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc -- ui.admin.users.list.table.msg.common.copied -- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked -- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms -- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms -- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id -- ui.admin.users.list.table.ui.admin.users.detail.form.email -- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id -- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin -- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug -- ui.admin.users.list.table.ui.admin.users.detail.generate_button -- ui.admin.users.list.table.ui.admin.users.detail.history_title -- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm -- ui.admin.users.list.table.ui.admin.users.detail.manual_password -- ui.admin.users.list.table.ui.admin.users.detail.password_done -- ui.admin.users.list.table.ui.admin.users.detail.reset_auto -- ui.admin.users.list.table.ui.admin.users.detail.reset_execute -- ui.admin.users.list.table.ui.admin.users.detail.reset_manual -- ui.admin.users.list.table.ui.admin.users.detail.save_tenants -- ui.admin.users.list.table.ui.admin.users.detail.tabs.info -- ui.admin.users.list.table.ui.admin.users.detail.tabs.security -- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants -- ui.admin.users.list.table.ui.admin.users.detail.updated_at -- ui.admin.users.list.table.ui.common.generate -- ui.admin.users.list.table.ui.common.status.blocked -- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri -- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle -- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled -- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled -- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline -- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder -- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg -- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder -- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache -- ui.admin.users.list.table.ui.dev.clients.general.public_key.source -- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri -- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable -- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help -- ui.admin.users.list.table.ui.dev.clients.help.docs_body -- ui.admin.users.list.table.ui.dev.clients.help.subtitle -- ui.admin.users.list.table.ui.dev.clients.registry.description -- ui.admin.users.list.table.ui.dev.clients.scopes.email -- ui.admin.users.list.table.ui.dev.clients.scopes.openid -- ui.admin.users.list.table.ui.dev.clients.scopes.profile -- ui.admin.users.list.table.ui.dev.session.refresh -- ui.admin.users.list.table.ui.dev.session.refreshing - -template.toml에 없는 코드 사용 키 -- msg.admin.users.detail.history_desc -- msg.admin.users.detail.no_history -- msg.admin.users.detail.no_tenants -- msg.admin.users.detail.reset_auto_desc -- msg.admin.users.detail.security_desc -- msg.admin.users.detail.tenant_slug_help -- msg.admin.users.detail.tenants_desc -- msg.common.copied -- msg.dev.clients.general.public_key.allowed_algorithms_tooltip -- msg.dev.clients.general.public_key.cache.missing_algorithm_badge -- msg.dev.clients.general.public_key.cache.missing_algorithm_reason -- msg.dev.clients.general.public_key.cache.missing_algorithms_help -- msg.dev.clients.general.public_key.cache.missing_algorithms_title -- msg.dev.clients.general.public_key.cache.parsed_keys_empty -- msg.dev.clients.general.public_key.cache.parsed_keys_help -- msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason -- msg.dev.clients.general.public_key.cache.unsupported_algorithms_help -- msg.dev.clients.general.public_key.cache.unsupported_algorithms_title -- msg.dev.clients.general.public_key.cache_empty -- msg.dev.clients.general.public_key.cache_help -- msg.dev.clients.general.public_key.cache_refresh_failed -- msg.dev.clients.general.public_key.cache_refreshed -- msg.dev.clients.general.public_key.cache_revoke_confirm -- msg.dev.clients.general.public_key.cache_revoke_failed -- msg.dev.clients.general.public_key.cache_revoked -- msg.dev.clients.general.public_key.validation.missing_parsed_algorithms -- msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms -- ui.admin.users.create.form.is_login_id -- ui.admin.users.detail.form.email -- ui.admin.users.detail.form.is_login_id -- ui.admin.users.detail.form.role_rp_admin -- ui.admin.users.detail.form.tenant_slug -- ui.admin.users.detail.generate_button -- ui.admin.users.detail.history_title -- ui.admin.users.detail.manual_confirm -- ui.admin.users.detail.manual_password -- ui.admin.users.detail.password_done -- ui.admin.users.detail.reset_auto -- ui.admin.users.detail.reset_execute -- ui.admin.users.detail.reset_manual -- ui.admin.users.detail.save_tenants -- ui.admin.users.detail.tabs.info -- ui.admin.users.detail.tabs.security -- ui.admin.users.detail.tabs.tenants -- ui.admin.users.detail.updated_at -- ui.dev.clients.general.public_key.allowed_algorithms_info -- ui.dev.clients.general.public_key.cache.cached_at -- ui.dev.clients.general.public_key.cache.error -- ui.dev.clients.general.public_key.cache.expires_at -- ui.dev.clients.general.public_key.cache.failures -- ui.dev.clients.general.public_key.cache.kids -- ui.dev.clients.general.public_key.cache.last_checked_at -- ui.dev.clients.general.public_key.cache.last_success -- ui.dev.clients.general.public_key.cache.parsed_key_n -- ui.dev.clients.general.public_key.cache.parsed_keys -- ui.dev.clients.general.public_key.cache.status -- ui.dev.clients.general.public_key.cache.title -- ui.dev.clients.general.public_key.cache.uri -- ui.dev.clients.general.public_key.revoke_cache - -코드에서 사용되지 않는 키 -- err.backend.authorization_pending -- err.backend.bad_request -- err.backend.conflict -- err.backend.expired_token -- err.backend.forbidden -- err.backend.internal_error -- err.backend.invalid_code -- err.backend.invalid_or_expired_code -- err.backend.invalid_session -- err.backend.invalid_session_reference -- err.backend.not_found -- err.backend.not_supported -- err.backend.password_or_email_mismatch -- err.backend.rate_limited -- err.backend.service_unavailable -- err.backend.slow_down -- msg.admin.groups.create.description -- msg.admin.groups.create.title -- msg.admin.groups.list.import_error -- msg.admin.groups.list.import_success -- msg.admin.header.subtitle -- msg.admin.idp_env_prod -- msg.admin.notice.idp_policy -- msg.admin.notice.scope -- msg.admin.overview.idp_fallback -- msg.admin.overview.idp_primary -- msg.admin.overview.playbook.description -- msg.admin.overview.playbook.idp_body -- msg.admin.overview.playbook.idp_title -- msg.admin.overview.playbook.tenant_body -- msg.admin.overview.playbook.tenant_title -- msg.admin.overview.quick_links.description -- msg.admin.overview.summary.audit_events_24h -- msg.admin.overview.summary.oidc_clients -- msg.admin.overview.summary.policy_gate -- msg.admin.overview.summary.total_tenants -- msg.admin.scope_admin -- msg.admin.session_ttl -- msg.admin.tenant_headers -- msg.admin.users.create.form.login_id_help -- msg.admin.users.detail.delete_error -- msg.admin.users.detail.password_generated_help -- msg.admin.users.detail.reset_password_confirm -- msg.admin.users.detail.security.password_hint -- msg.admin.users.detail.update_success -- msg.common.copied_to_clipboard -- msg.dev.audit.forbidden -- msg.dev.clients.general.public_key.auth_method_client_secret_basic_help -- msg.dev.clients.general.public_key.auth_method_none_help -- msg.dev.clients.general.public_key.auth_method_private_key_jwt_help -- msg.dev.clients.general.public_key.guide_example -- msg.dev.clients.general.public_key.guide_intro -- msg.dev.clients.general.public_key.guide_step_1 -- msg.dev.clients.general.public_key.guide_step_2 -- msg.dev.clients.general.public_key.guide_step_3 -- msg.dev.clients.general.public_key.jwks_inline_help -- msg.dev.clients.general.public_key.request_object_alg_help -- msg.dev.clients.general.public_key.source_help -- msg.dev.clients.general.public_key.validation.headless_requires_alg -- msg.dev.clients.general.public_key.validation.headless_requires_private_key_jwt -- msg.dev.clients.general.public_key.validation.headless_requires_public_key -- msg.dev.clients.general.public_key.validation.invalid_jwks_inline -- msg.dev.clients.general.public_key.validation.missing_jwks_inline -- msg.dev.clients.general.public_key.validation.private_key_jwt_requires_public_key -- msg.userfront.signup.privacy_full -- msg.userfront.signup.tos_full -- non.existent.key -- test.key -- ui.admin.api_keys.list.breadcrumb.list -- ui.admin.api_keys.list.breadcrumb.section -- ui.admin.audit.breadcrumb.logs -- ui.admin.audit.breadcrumb.section -- ui.admin.groups.import_csv -- ui.admin.overview.kicker -- ui.admin.overview.playbook.title -- ui.admin.overview.quick_links.add_tenant -- ui.admin.overview.quick_links.api_key_management -- ui.admin.overview.quick_links.user_management -- ui.admin.overview.quick_links.view_audit_logs -- ui.admin.tenants.breadcrumb.list -- ui.admin.tenants.breadcrumb.section -- ui.admin.tenants.create.breadcrumb.action -- ui.admin.tenants.create.breadcrumb.section -- ui.admin.tenants.detail.breadcrumb_list -- ui.admin.tenants.detail.title -- ui.admin.users.create.breadcrumb.new -- ui.admin.users.create.breadcrumb.section -- ui.admin.users.create.form.login_id -- ui.admin.users.create.form.login_id_placeholder -- ui.admin.users.detail.breadcrumb.section -- ui.admin.users.detail.contact_title -- ui.admin.users.detail.form.department_placeholder -- ui.admin.users.detail.form.job_title_placeholder -- ui.admin.users.detail.form.login_id -- ui.admin.users.detail.form.login_id_placeholder -- ui.admin.users.detail.form.name_placeholder -- ui.admin.users.detail.form.phone_placeholder -- ui.admin.users.detail.form.position_placeholder -- ui.admin.users.detail.form.status_active -- ui.admin.users.detail.form.status_inactive -- ui.admin.users.detail.generate_password -- ui.admin.users.detail.password_mode_generated -- ui.admin.users.detail.password_mode_manual -- ui.admin.users.detail.password_result_title -- ui.admin.users.detail.reset_password_apply -- ui.admin.users.detail.security.password -- ui.admin.users.detail.security.password_placeholder -- ui.admin.users.detail.security.title -- ui.admin.users.detail.status_title -- ui.admin.users.detail.tenants_section.additional -- ui.admin.users.detail.tenants_section.primary -- ui.admin.users.detail.tenants_section.title -- ui.admin.users.detail.title -- ui.admin.users.detail.toggle_password_visibility -- ui.admin.users.list.breadcrumb.list -- ui.admin.users.list.breadcrumb.section -- ui.admin.users.list.empty -- ui.admin.users.list.fetch_error -- ui.admin.users.list.registry.count -- ui.admin.users.list.subtitle -- ui.admin.users.list.table.login_id -- ui.admin.users.list.table.msg.admin.users.detail.history_desc -- ui.admin.users.list.table.msg.admin.users.detail.no_history -- ui.admin.users.list.table.msg.admin.users.detail.no_tenants -- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc -- ui.admin.users.list.table.msg.admin.users.detail.security_desc -- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help -- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc -- ui.admin.users.list.table.msg.common.copied -- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed -- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked -- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms -- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms -- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id -- ui.admin.users.list.table.ui.admin.users.detail.form.email -- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id -- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin -- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug -- ui.admin.users.list.table.ui.admin.users.detail.generate_button -- ui.admin.users.list.table.ui.admin.users.detail.history_title -- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm -- ui.admin.users.list.table.ui.admin.users.detail.manual_password -- ui.admin.users.list.table.ui.admin.users.detail.password_done -- ui.admin.users.list.table.ui.admin.users.detail.reset_auto -- ui.admin.users.list.table.ui.admin.users.detail.reset_execute -- ui.admin.users.list.table.ui.admin.users.detail.reset_manual -- ui.admin.users.list.table.ui.admin.users.detail.save_tenants -- ui.admin.users.list.table.ui.admin.users.detail.tabs.info -- ui.admin.users.list.table.ui.admin.users.detail.tabs.security -- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants -- ui.admin.users.list.table.ui.admin.users.detail.updated_at -- ui.admin.users.list.table.ui.common.generate -- ui.admin.users.list.table.ui.common.status.blocked -- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none -- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title -- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri -- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle -- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled -- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled -- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline -- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder -- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg -- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder -- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache -- ui.admin.users.list.table.ui.dev.clients.general.public_key.source -- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri -- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable -- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help -- ui.admin.users.list.table.ui.dev.clients.help.docs_body -- ui.admin.users.list.table.ui.dev.clients.help.subtitle -- ui.admin.users.list.table.ui.dev.clients.registry.description -- ui.admin.users.list.table.ui.dev.clients.scopes.email -- ui.admin.users.list.table.ui.dev.clients.scopes.openid -- ui.admin.users.list.table.ui.dev.clients.scopes.profile -- ui.admin.users.list.table.ui.dev.session.refresh -- ui.admin.users.list.table.ui.dev.session.refreshing -- ui.common.generate -- ui.common.status.blocked -- ui.dev.clients.general.public_key.auth_method -- ui.dev.clients.general.public_key.auth_method_client_secret_basic -- ui.dev.clients.general.public_key.auth_method_none -- ui.dev.clients.general.public_key.auth_method_private_key_jwt -- ui.dev.clients.general.public_key.guide_toggle -- ui.dev.clients.general.public_key.headless_disabled -- ui.dev.clients.general.public_key.headless_enabled -- ui.dev.clients.general.public_key.jwks_inline -- ui.dev.clients.general.public_key.jwks_inline_placeholder -- ui.dev.clients.general.public_key.request_object_alg -- ui.dev.clients.general.public_key.request_object_alg_placeholder -- ui.dev.clients.general.public_key.source -- ui.dev.clients.general.public_key.source_uri -- ui.dev.clients.general.security.trusted_rp_enable -- ui.dev.clients.general.security.trusted_rp_enable_help -- ui.dev.clients.help.docs_body -- ui.dev.clients.help.subtitle -- ui.dev.clients.registry.description -- ui.dev.clients.scopes.email -- ui.dev.clients.scopes.openid -- ui.dev.clients.scopes.profile -- ui.dev.session.refresh -- ui.dev.session.refreshing - -요약 -- [Sync Error] ko.toml 누락 키 84개 -- [Sync Error] en.toml 누락 키 84개 -- [Missing Key] template.toml 누락 키 59개 diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index e373cfca..89f2d981 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -44,6 +44,13 @@ import { } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../components/ui/select"; import { Switch } from "../../components/ui/switch"; import { Tabs, @@ -78,6 +85,11 @@ import { parseOrgChartTenantSelection, } from "./orgChartPicker"; import type { UserSchemaField } from "./userSchemaFields"; +import { + normalizeUserStatusValue, + userStatusLabel, + userStatusValues, +} from "./userStatus"; import { resolvePersonalTenant } from "./utils/personalTenant"; type UserFormValues = Omit & { @@ -603,7 +615,7 @@ function UserDetailPage() { name: user.name, phone: user.phone || "", role: user.role, - status: user.status, + status: normalizeUserStatusValue(user.status), tenantSlug: user.tenantSlug || user.joinedTenants?.find( @@ -1044,21 +1056,25 @@ function UserDetailPage() { > {t("ui.admin.users.detail.form.status", "상태")} -
- - setValue("status", checked ? "active" : "inactive") - } - /> - - {t( - `ui.common.status.${watchedStatus}`, - watchedStatus || "inactive", - )} - -
+ diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index d0b34ee8..6142e93c 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -67,7 +67,6 @@ import { SelectTrigger, SelectValue, } from "../../components/ui/select"; -import { Switch } from "../../components/ui/switch"; import { Table, TableBody, @@ -93,6 +92,7 @@ import { t } from "../../lib/i18n"; import { isSuperAdminRole } from "../../lib/roles"; import { UserBulkUploadModal } from "./components/UserBulkUploadModal"; import { + normalizeUserStatusValue, type UserStatusValue, userStatusLabel, userStatusValues, @@ -776,30 +776,37 @@ function UserListPage() { {user.id} -
- - statusMutation.mutate({ - userId: user.id, - status: checked ? "active" : "inactive", - }) - } - disabled={ - statusMutation.isPending || - user.id === profile?.id - } + {canPromoteSuperAdmin && ( diff --git a/adminfront/src/features/users/userStatus.test.ts b/adminfront/src/features/users/userStatus.test.ts new file mode 100644 index 00000000..22a0b3cb --- /dev/null +++ b/adminfront/src/features/users/userStatus.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { + normalizeUserStatusValue, + userStatusLabel, + userStatusValues, +} from "./userStatus"; + +vi.mock("../../lib/i18n", () => ({ + t: (key: string, fallback?: string) => fallback ?? key, +})); + +describe("userStatus", () => { + it("exposes canonical user status values", () => { + expect(userStatusValues).toEqual([ + "active", + "temporary_leave", + "suspended", + "preboarding", + "baron_guest", + "extended_leave", + "archived", + ]); + }); + + it("normalizes legacy status values", () => { + expect(normalizeUserStatusValue("inactive")).toBe("preboarding"); + expect(normalizeUserStatusValue("leave_of_absence")).toBe( + "temporary_leave", + ); + expect(normalizeUserStatusValue("baron_only")).toBe("baron_guest"); + }); + + it("uses canonical labels for legacy status values", () => { + expect(userStatusLabel("baron_only")).toBe("baron_guest"); + }); +}); diff --git a/adminfront/src/features/users/userStatus.ts b/adminfront/src/features/users/userStatus.ts index 647c44f0..7774994e 100644 --- a/adminfront/src/features/users/userStatus.ts +++ b/adminfront/src/features/users/userStatus.ts @@ -2,13 +2,42 @@ import { t } from "../../lib/i18n"; export const userStatusValues = [ "active", - "inactive", + "temporary_leave", "suspended", - "leave_of_absence", + "preboarding", + "baron_guest", + "extended_leave", + "archived", ] as const; export type UserStatusValue = (typeof userStatusValues)[number]; -export function userStatusLabel(status: string) { - return t(`ui.common.status.${status}`, status); +export function normalizeUserStatusValue(status: string): UserStatusValue { + switch (status.trim().toLowerCase()) { + case "active": + return "active"; + case "temporary_leave": + case "leave_of_absence": + return "temporary_leave"; + case "suspended": + case "blocked": + return "suspended"; + case "preboarding": + case "inactive": + return "preboarding"; + case "baron_guest": + case "baron_only": + return "baron_guest"; + case "extended_leave": + return "extended_leave"; + case "archived": + return "archived"; + default: + return "preboarding"; + } +} + +export function userStatusLabel(status: string) { + const normalized = normalizeUserStatusValue(status); + return t(`ui.common.status.${normalized}`, normalized); } diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index f3613258..5e8ec076 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -1281,6 +1281,7 @@ title = "Affiliation & Organization Info" add = "Add User" add_to_tenant = "Add to Tenant" bulk_import = "Bulk Import" +change_status = "Change {{name}} status" empty = "No users found." fetch_error = "Failed to fetch user list." search_label = "Search Users" @@ -1360,15 +1361,20 @@ user = "User" [ui.common.status] active = "Active" +archived = "Archived" +baron_guest = "Baron Guest" blocked = "Blocked" +extended_leave = "Extended Leave" failure = "Failure" inactive = "Inactive" leave_of_absence = "Leave of absence" ok = "Ok" pending = "Pending" +preboarding = "Preboarding" status = "Status" success = "Success" suspended = "Suspended" +temporary_leave = "Temporary Leave" [test] key = "Test" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 279b2106..12882b97 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -1283,6 +1283,7 @@ title = "소속 및 조직 정보" add = "사용자 추가" add_to_tenant = "테넌트에 추가" bulk_import = "일괄 임포트" +change_status = "{{name}} 상태 변경" empty = "검색 결과가 없습니다." fetch_error = "사용자 목록 조회에 실패했습니다." search_label = "사용자 검색" @@ -1362,15 +1363,20 @@ user = "User" [ui.common.status] active = "활성" +archived = "보관됨" +baron_guest = "Baron 게스트" blocked = "차단됨" +extended_leave = "장기휴직" failure = "실패" inactive = "비활성" leave_of_absence = "휴직" ok = "정상" pending = "준비 중" +preboarding = "입사대기" status = "상태" success = "성공" suspended = "정지" +temporary_leave = "단기휴무" [test] key = "테스트" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index a0cf383a..63d010f1 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1295,6 +1295,7 @@ title = "" [ui.admin.users.list] add = "" bulk_import = "" +change_status = "" empty = "" fetch_error = "" search_placeholder = "" @@ -1340,15 +1341,20 @@ user = "" [ui.common.status] active = "" +archived = "" +baron_guest = "" blocked = "" +extended_leave = "" failure = "" inactive = "" leave_of_absence = "" ok = "" pending = "" +preboarding = "" status = "" success = "" suspended = "" +temporary_leave = "" [test] key = "" diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index fe27de89..6b91cd55 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -431,7 +431,7 @@ test.describe("User Management", () => { expect(exportUrl).toContain("includeIds=false"); }); - test("should show contact info in one row, hide roles, and toggle user status", async ({ + test("should show contact info in one row, hide roles, and change user status", async ({ page, }) => { let updatePayload: Record | undefined; @@ -446,7 +446,7 @@ test.describe("User Management", () => { email: "john@test.com", phone: "010-1111-2222", loginId: "johndoe", - status: "inactive", + status: "preboarding", createdAt: "2026-04-01T00:00:00Z", }, }); @@ -460,10 +460,11 @@ test.describe("User Management", () => { table.getByRole("columnheader", { name: /ROLE|역할/i }), ).toBeVisible(); - await page.getByTestId("user-status-toggle-u-1").click(); + await page.getByTestId("user-status-select-u-1").click(); + await page.getByRole("option", { name: /입사대기|Preboarding/ }).click(); await expect .poll(() => updatePayload) - .toMatchObject({ status: "inactive" }); + .toMatchObject({ status: "preboarding" }); }); test("should expose internal user uuid in the users table", async ({ diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 6088c5d8..01981475 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -24,8 +24,70 @@ const ( UserStatusInactive = "inactive" UserStatusSuspended = "suspended" UserStatusLeaveOfAbsence = "leave_of_absence" + UserStatusTemporaryLeave = "temporary_leave" + UserStatusPreboarding = "preboarding" + UserStatusBaronGuest = "baron_guest" + UserStatusExtendedLeave = "extended_leave" + UserStatusArchived = "archived" ) +func NormalizeUserStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "", UserStatusActive: + return UserStatusActive + case "blocked", UserStatusSuspended: + return UserStatusSuspended + case UserStatusInactive, UserStatusPreboarding: + return UserStatusPreboarding + case UserStatusLeaveOfAbsence, UserStatusTemporaryLeave: + return UserStatusTemporaryLeave + case "baron_only", UserStatusBaronGuest: + return UserStatusBaronGuest + case UserStatusExtendedLeave: + return UserStatusExtendedLeave + case UserStatusArchived: + return UserStatusArchived + default: + return strings.ToLower(strings.TrimSpace(status)) + } +} + +func IsBaronActivityAllowedStatus(status string) bool { + switch NormalizeUserStatus(status) { + case UserStatusActive, UserStatusTemporaryLeave, UserStatusBaronGuest: + return true + default: + return false + } +} + +func IsOrgVisibleUserStatus(status string) bool { + switch NormalizeUserStatus(status) { + case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended: + return true + default: + return false + } +} + +func IsWorksProvisionedUserStatus(status string) bool { + switch NormalizeUserStatus(status) { + case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended: + return true + default: + return false + } +} + +func IsWorksDeprovisionUserStatus(status string) bool { + switch NormalizeUserStatus(status) { + case UserStatusBaronGuest, UserStatusExtendedLeave, UserStatusArchived: + return true + default: + return false + } +} + // NormalizeRole maps legacy/synonym role values to canonical role keys. func NormalizeRole(role string) string { if normalized, ok := NormalizeRoleAlias(role); ok { diff --git a/backend/internal/domain/user_test.go b/backend/internal/domain/user_test.go index 18c6e836..54fb6e9b 100644 --- a/backend/internal/domain/user_test.go +++ b/backend/internal/domain/user_test.go @@ -29,3 +29,45 @@ func TestNormalizeRole(t *testing.T) { }) } } + +func TestUserStatusPolicy(t *testing.T) { + tests := []struct { + status string + normalized string + baronAllowed bool + orgVisible bool + worksProvisioned bool + worksDeprovisioned bool + }{ + {status: UserStatusActive, normalized: UserStatusActive, baronAllowed: true, orgVisible: true, worksProvisioned: true}, + {status: UserStatusTemporaryLeave, normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true}, + {status: UserStatusSuspended, normalized: UserStatusSuspended, orgVisible: true, worksProvisioned: true}, + {status: UserStatusPreboarding, normalized: UserStatusPreboarding}, + {status: UserStatusBaronGuest, normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true}, + {status: UserStatusExtendedLeave, normalized: UserStatusExtendedLeave, worksDeprovisioned: true}, + {status: UserStatusArchived, normalized: UserStatusArchived, worksDeprovisioned: true}, + {status: UserStatusInactive, normalized: UserStatusPreboarding}, + {status: UserStatusLeaveOfAbsence, normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true}, + {status: "BARON_ONLY", normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true}, + } + + for _, tc := range tests { + t.Run(tc.status, func(t *testing.T) { + if got := NormalizeUserStatus(tc.status); got != tc.normalized { + t.Fatalf("NormalizeUserStatus(%q)=%q, want %q", tc.status, got, tc.normalized) + } + if got := IsBaronActivityAllowedStatus(tc.status); got != tc.baronAllowed { + t.Fatalf("IsBaronActivityAllowedStatus(%q)=%v, want %v", tc.status, got, tc.baronAllowed) + } + if got := IsOrgVisibleUserStatus(tc.status); got != tc.orgVisible { + t.Fatalf("IsOrgVisibleUserStatus(%q)=%v, want %v", tc.status, got, tc.orgVisible) + } + if got := IsWorksProvisionedUserStatus(tc.status); got != tc.worksProvisioned { + t.Fatalf("IsWorksProvisionedUserStatus(%q)=%v, want %v", tc.status, got, tc.worksProvisioned) + } + if got := IsWorksDeprovisionUserStatus(tc.status); got != tc.worksDeprovisioned { + t.Fatalf("IsWorksDeprovisionUserStatus(%q)=%v, want %v", tc.status, got, tc.worksDeprovisioned) + } + }) + } +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index b3e79bae..f35d5c24 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -2580,6 +2580,9 @@ func (h *AuthHandler) authenticatePasswordLogin(ctx context.Context, loginID, pa slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr) return nil, fmt.Errorf("failed to resolve user identity") } + if err := h.ensureUserActivityAllowed(ctx, subject); err != nil { + return nil, err + } authInfo.Subject = subject return authInfo, nil @@ -2598,9 +2601,30 @@ func passwordLoginErrorSpec(err error) (int, string, string) { if strings.Contains(err.Error(), "failed to resolve user identity") { return fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity" } + if strings.Contains(err.Error(), "cannot perform Baron activity") { + return fiber.StatusForbidden, "user_status_forbidden", "This user status cannot sign in" + } return fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials" } +func (h *AuthHandler) ensureUserActivityAllowed(ctx context.Context, userID string) error { + if h == nil || h.UserRepo == nil || strings.TrimSpace(userID) == "" { + return nil + } + user, err := h.UserRepo.FindByID(ctx, userID) + if err != nil || user == nil { + return nil + } + if !domain.IsBaronActivityAllowedStatus(user.Status) { + return fmt.Errorf("user status %s cannot perform Baron activity", domain.NormalizeUserStatus(user.Status)) + } + return nil +} + +func isUserActivityForbiddenError(err error) bool { + return err != nil && strings.Contains(err.Error(), "cannot perform Baron activity") +} + func headlessAssertionAudiences(c *fiber.Ctx) []string { if c == nil { return nil @@ -4522,6 +4546,9 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string { func (h *AuthHandler) GetMe(c *fiber.Ctx) error { profile, err := h.resolveCurrentProfile(c) if err != nil { + if isUserActivityForbiddenError(err) { + return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity") + } return errorJSON(c, fiber.StatusUnauthorized, err.Error()) } return c.JSON(profile) @@ -6198,6 +6225,9 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { if err != nil || subject == "" { return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") } + if err := h.ensureUserActivityAllowed(c.Context(), subject); err != nil { + return fiber.NewError(fiber.StatusForbidden, "This user status cannot sign in") + } c.Locals("user_id", subject) approvedSessionID := strings.TrimSpace(req.ApprovedSessionID) if approvedSessionID == "" { @@ -7472,6 +7502,9 @@ func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domai slog.Warn("Hydra token session validation failed", "error", err) return nil, err } + if err := h.ensureUserActivityAllowed(ctx, intro.Subject); err != nil { + return nil, err + } slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID) @@ -7655,6 +7688,9 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile if err != nil { return nil, err } + if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil { + return nil, err + } return h.applySessionInfoFromWhoami( h.mapKratosIdentityToProfile(identityID, traits), authenticatedAt, @@ -7667,6 +7703,9 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro if err != nil { return nil, err } + if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil { + return nil, err + } return h.applySessionInfoFromWhoami( h.mapKratosIdentityToProfile(identityID, traits), authenticatedAt, @@ -7699,6 +7738,9 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } + if err := h.ensureUserActivityAllowed(c.Context(), identityID); err != nil { + return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity") + } currentPhone, _ := traits["phone_number"].(string) newPhoneStorage := h.formatPhoneForStorage(req.Phone) diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index defd071d..de7b21d7 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -31,6 +31,7 @@ import ( josejwt "github.com/go-jose/go-jose/v4/jwt" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) // --- Mocks --- @@ -159,6 +160,61 @@ func newHeadlessPasswordLoginTestApp(h *AuthHandler) *fiber.App { return app } +type passwordLoginUserRepo struct { + usersByID map[string]domain.User +} + +func (r *passwordLoginUserRepo) Create(ctx context.Context, user *domain.User) error { return nil } +func (r *passwordLoginUserRepo) Update(ctx context.Context, user *domain.User) error { return nil } +func (r *passwordLoginUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + return nil, errors.New("not found") +} +func (r *passwordLoginUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) { + if r != nil { + if user, ok := r.usersByID[id]; ok { + return &user, nil + } + } + return nil, errors.New("not found") +} +func (r *passwordLoginUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { + return nil, nil +} +func (r *passwordLoginUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { + return nil, nil +} +func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { + return nil, 0, nil +} +func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + return 0, nil +} +func (r *passwordLoginUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + return nil, nil +} +func (r *passwordLoginUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + return nil, nil +} +func (r *passwordLoginUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) { + return nil, nil +} +func (r *passwordLoginUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) { + return nil, nil +} +func (r *passwordLoginUserRepo) Delete(ctx context.Context, id string) error { return nil } +func (r *passwordLoginUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { + return nil +} +func (r *passwordLoginUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) { + return nil, nil +} +func (r *passwordLoginUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) { + return false, nil +} +func (r *passwordLoginUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) { + return "", nil +} + func mustHeadlessRSAJWK(t *testing.T) (*rsa.PrivateKey, map[string]any) { t.Helper() @@ -1947,6 +2003,88 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) { } } +func TestPasswordLogin_ArchivedUserRejected(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "archived@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "archived-jwt"}, + Subject: "archived-user-id", + }, nil) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "archived@example.com").Return("archived-user-id", nil) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + Hydra: service.NewHydraAdminService(), + UserRepo: &passwordLoginUserRepo{usersByID: map[string]domain.User{ + "archived-user-id": { + ID: "archived-user-id", + Email: "archived@example.com", + Name: "Archived User", + Status: domain.UserStatusArchived, + }, + }}, + } + + app := newAuthLoginTestApp(h) + body, _ := json.Marshal(map[string]string{ + "loginId": "archived@example.com", + "password": "password", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } +} + +func TestEnsureUserActivityAllowedByStatus(t *testing.T) { + tests := []struct { + name string + status string + wantErr bool + }{ + {name: "active allowed", status: domain.UserStatusActive}, + {name: "temporary leave allowed", status: domain.UserStatusTemporaryLeave}, + {name: "baron guest allowed", status: domain.UserStatusBaronGuest}, + {name: "suspended rejected", status: domain.UserStatusSuspended, wantErr: true}, + {name: "preboarding rejected", status: domain.UserStatusPreboarding, wantErr: true}, + {name: "extended leave rejected", status: domain.UserStatusExtendedLeave, wantErr: true}, + {name: "archived rejected", status: domain.UserStatusArchived, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := &AuthHandler{ + UserRepo: &passwordLoginUserRepo{usersByID: map[string]domain.User{ + "user-id": { + ID: "user-id", + Email: "user@example.com", + Name: "User", + Status: tc.status, + }, + }}, + } + + err := h.ensureUserActivityAllowed(context.Background(), "user-id") + + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다")) diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index ba446949..b4da8e1c 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "io" + "sort" "strings" "time" @@ -415,6 +416,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusServiceUnavailable, err.Error()) } tenants := filterTenantCSVDescendants(allTenants, parentID) + sortTenantsByInputOrder(tenants) var buf bytes.Buffer writer := csv.NewWriter(&buf) @@ -483,6 +485,15 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { return c.Send(buf.Bytes()) } +func sortTenantsByInputOrder(tenants []domain.Tenant) { + sort.SliceStable(tenants, func(i, j int) bool { + if tenants[i].CreatedAt.Equal(tenants[j].CreatedAt) { + return tenants[i].ID < tenants[j].ID + } + return tenants[i].CreatedAt.Before(tenants[j].CreatedAt) + }) +} + func filterTenantCSVDescendants(tenants []domain.Tenant, parentID string) []domain.Tenant { parentID = strings.TrimSpace(parentID) if parentID == "" { @@ -2231,7 +2242,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te users := append(usersByID, usersBySlug...) users = append(users, usersByAppointment...) for _, user := range users { - if seen[user.ID] || user.Status != domain.UserStatusActive { + if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) { continue } assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs) diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 00ec15b6..fb66affd 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -616,6 +616,66 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi CreatedAt: now, UpdatedAt: now, }, + { + ID: "user-archived", + Email: "archived@example.com", + Name: "보관 사용자", + Status: domain.UserStatusArchived, + TenantID: parent("dept-platform"), + CompanyCode: "platform", + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: "user-suspended", + Email: "suspended@example.com", + Name: "정지 사용자", + Status: domain.UserStatusSuspended, + TenantID: parent("dept-platform"), + CompanyCode: "platform", + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: "user-temporary-leave", + Email: "temporary-leave@example.com", + Name: "단기휴무 사용자", + Status: domain.UserStatusTemporaryLeave, + TenantID: parent("dept-platform"), + CompanyCode: "platform", + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: "user-preboarding", + Email: "preboarding@example.com", + Name: "입사대기 사용자", + Status: domain.UserStatusPreboarding, + TenantID: parent("dept-platform"), + CompanyCode: "platform", + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: "user-baron-guest", + Email: "baron-guest@example.com", + Name: "Baron Guest", + Status: domain.UserStatusBaronGuest, + TenantID: parent("dept-platform"), + CompanyCode: "platform", + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: "user-extended-leave", + Email: "extended-leave@example.com", + Name: "장기휴직 사용자", + Status: domain.UserStatusExtendedLeave, + TenantID: parent("dept-platform"), + CompanyCode: "platform", + CreatedAt: now, + UpdatedAt: now, + }, } usersBySlug := []domain.User{ {ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now}, @@ -668,7 +728,7 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi require.NotContains(t, got, "users") deptPlatform := tenantsPayload[2].(map[string]any) platformMembers := deptPlatform["members"].([]any) - require.Len(t, platformMembers, 1) + require.Len(t, platformMembers, 3) firstUser := platformMembers[0].(map[string]any) require.NotContains(t, firstUser, "id") require.NotContains(t, firstUser, "phone") @@ -703,6 +763,12 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi require.NotContains(t, toJSONString(t, got), "directUserIds") require.NotContains(t, toJSONString(t, got), "private-team") require.NotContains(t, toJSONString(t, got), "root-other") + require.NotContains(t, toJSONString(t, got), "archived@example.com") + require.Contains(t, toJSONString(t, got), "suspended@example.com") + require.Contains(t, toJSONString(t, got), "temporary-leave@example.com") + require.NotContains(t, toJSONString(t, got), "preboarding@example.com") + require.NotContains(t, toJSONString(t, got), "baron-guest@example.com") + require.NotContains(t, toJSONString(t, got), "extended-leave@example.com") } func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) { @@ -963,6 +1029,37 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) mockSvc.AssertExpectations(t) } +func TestTenantHandler_ExportTenantsCSV_OrdersByInputOrder(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + h := &TenantHandler{Service: mockSvc} + + app.Get("/tenants/export", h.ExportTenantsCSV) + + oldest := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC) + middle := oldest.Add(time.Hour) + newest := oldest.Add(2 * time.Hour) + tenants := []domain.Tenant{ + {ID: "newest", Name: "Newest Tenant", Type: domain.TenantTypeCompany, Slug: "newest", CreatedAt: newest}, + {ID: "middle", Name: "Middle Tenant", Type: domain.TenantTypeCompany, Slug: "middle", CreatedAt: middle}, + {ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest}, + } + + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) + + req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) + resp, _ := app.Test(req) + body, _ := io.ReadAll(resp.Body) + lines := strings.Split(strings.TrimSpace(string(body)), "\n") + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, lines, 4) + assert.Contains(t, lines[1], "oldest,Oldest Tenant") + assert.Contains(t, lines[2], "middle,Middle Tenant") + assert.Contains(t, lines[3], "newest,Newest Tenant") + mockSvc.AssertExpectations(t) +} + func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 5d49d3be..cff3584a 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -1644,10 +1644,9 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { state := identity.State if req.Status != nil { - if *req.Status == "active" { - state = "active" - } else { - state = "inactive" + state = normalizeKratosState(req.Status) + if state == "" { + state = identity.State } } @@ -1667,7 +1666,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { localUser.Role = *req.Role } if req.Status != nil { - localUser.Status = *req.Status + localUser.Status = normalizeStatus(*req.Status) } if req.Department != nil { localUser.Department = *req.Department @@ -2610,20 +2609,7 @@ func formatTime(value time.Time) string { } func normalizeStatus(state string) string { - state = strings.ToLower(strings.TrimSpace(state)) - if state == "blocked" { - return domain.UserStatusInactive - } - if state == domain.UserStatusInactive || - state == domain.UserStatusSuspended || - state == domain.UserStatusLeaveOfAbsence || - state == domain.UserStatusActive { - return state - } - if state == "" { - return domain.UserStatusActive - } - return state + return domain.NormalizeUserStatus(state) } func normalizeKratosState(status *string) string { @@ -2637,9 +2623,13 @@ func normalizeKratosState(status *string) string { if value == domain.UserStatusActive { return domain.UserStatusActive } - if value == domain.UserStatusInactive || - value == domain.UserStatusSuspended || - value == domain.UserStatusLeaveOfAbsence { + normalized := domain.NormalizeUserStatus(value) + if normalized == domain.UserStatusPreboarding || + normalized == domain.UserStatusSuspended || + normalized == domain.UserStatusTemporaryLeave || + normalized == domain.UserStatusBaronGuest || + normalized == domain.UserStatusExtendedLeave || + normalized == domain.UserStatusArchived { return domain.UserStatusInactive } return "" diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 4d93fd1a..1b38eb7d 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -1017,7 +1017,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) { assert.True(t, results[0].(map[string]interface{})["success"].(bool)) assert.Len(t, worksmobile.upserts, 1) assert.Equal(t, "u-1", worksmobile.upserts[0].ID) - assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status) + assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status) }) t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) { diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 9f7eef48..42828c0c 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -3,6 +3,7 @@ package repository import ( "baron-sso-backend/internal/domain" "context" + "fmt" "strings" "gorm.io/gorm" @@ -45,24 +46,21 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error { func (r *userRepository) Update(ctx context.Context, user *domain.User) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 1. Resolve email conflicts: If another user in the local DB has this email but a different ID, - // we must remove the old local record because Kratos is the source of truth for ID <-> Email mapping. var existing domain.User if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil { if existing.ID != user.ID { - // Delete associated login IDs first to prevent FK constraint violation + if strings.EqualFold(strings.TrimSpace(existing.Status), domain.UserStatusArchived) { + return fmt.Errorf("email is reserved by archived user: %s", user.Email) + } if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil { return err } - // Different ID holds this email locally. Hard delete the old record to avoid constraint violation. if err := tx.Unscoped().Delete(&domain.User{}, "id = ?", existing.ID).Error; err != nil { return err } } } - // 2. Perform Upsert based on ID. - // In GORM v2, true upsert requires Create() with OnConflict on the primary key. return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, UpdateAll: true, diff --git a/backend/internal/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go index 2454f3b4..49ec166a 100644 --- a/backend/internal/repository/user_repository_test.go +++ b/backend/internal/repository/user_repository_test.go @@ -53,6 +53,36 @@ func TestUserRepository(t *testing.T) { assert.Equal(t, "010-1234-5678", found.Phone) }) + t.Run("Update preserves archived email reservation", func(t *testing.T) { + testDB.Exec("DELETE FROM user_login_ids") + testDB.Exec("DELETE FROM users") + + archived := &domain.User{ + ID: "00000000-0000-0000-0000-00000000a001", + Email: "reserved@example.com", + Name: "Archived User", + Role: domain.RoleUser, + Status: domain.UserStatusArchived, + } + replacement := &domain.User{ + ID: "00000000-0000-0000-0000-00000000a002", + Email: "reserved@example.com", + Name: "Replacement User", + Role: domain.RoleUser, + Status: domain.UserStatusActive, + } + require.NoError(t, repo.Create(ctx, archived)) + + err := repo.Update(ctx, replacement) + + require.Error(t, err) + require.Contains(t, err.Error(), "archived user") + found, err := repo.FindByEmail(ctx, archived.Email) + require.NoError(t, err) + require.Equal(t, archived.ID, found.ID) + require.Equal(t, domain.UserStatusArchived, found.Status) + }) + t.Run("List Users with Search", func(t *testing.T) { // Add some users _ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"}) diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index e195cd4d..8ab02a89 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -384,14 +384,7 @@ func userGroupTraitStringArray(traits map[string]interface{}, key string) []stri } func userGroupIdentityStatus(state string) string { - switch state { - case "", "active": - return domain.UserStatusActive - case "inactive": - return domain.UserStatusInactive - default: - return state - } + return domain.NormalizeUserStatus(state) } func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error { diff --git a/backend/internal/service/user_projection_sync_service.go b/backend/internal/service/user_projection_sync_service.go index 14487998..d6f16b6d 100644 --- a/backend/internal/service/user_projection_sync_service.go +++ b/backend/internal/service/user_projection_sync_service.go @@ -145,14 +145,9 @@ func kratosProjectionTraitStringArray(traits map[string]interface{}, key string) } func normalizeProjectionStatus(state string) string { - switch strings.ToLower(strings.TrimSpace(state)) { - case "blocked", domain.UserStatusInactive: - return domain.UserStatusInactive - case domain.UserStatusSuspended: - return domain.UserStatusSuspended - case domain.UserStatusLeaveOfAbsence: - return domain.UserStatusLeaveOfAbsence - default: + normalized := domain.NormalizeUserStatus(state) + if normalized == "" { return domain.UserStatusActive } + return normalized } diff --git a/backend/internal/service/user_projection_sync_service_test.go b/backend/internal/service/user_projection_sync_service_test.go index cb64c9aa..932a938d 100644 --- a/backend/internal/service/user_projection_sync_service_test.go +++ b/backend/internal/service/user_projection_sync_service_test.go @@ -96,3 +96,16 @@ func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testin assert.Empty(t, repo.replacedUsers) kratos.AssertExpectations(t) } + +func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) { + user := MapKratosIdentityToLocalUser(KratosIdentity{ + ID: "00000000-0000-0000-0000-000000000201", + State: domain.UserStatusArchived, + Traits: map[string]interface{}{ + "email": "archived@example.com", + "name": "Archived User", + }, + }) + + assert.Equal(t, domain.UserStatusArchived, user.Status) +} diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go index e7f0a195..6d6c0bf6 100644 --- a/backend/internal/service/worksmobile_client_test.go +++ b/backend/internal/service/worksmobile_client_test.go @@ -924,6 +924,7 @@ type fakeWorksmobileDirectoryClient struct { deletedUsers []string activeUsers []string suspendedUsers []string + users []WorksmobileRemoteUser orgUnitMatchKeys []string groups []WorksmobileRemoteGroup } @@ -1029,7 +1030,7 @@ func (f *fakeWorksmobileDirectoryClient) SetUserActive(ctx context.Context, user } func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) { - return nil, nil + return f.users, nil } func (f *fakeWorksmobileDirectoryClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) { diff --git a/backend/internal/service/worksmobile_mapper.go b/backend/internal/service/worksmobile_mapper.go index 23120fee..f3eccd0c 100644 --- a/backend/internal/service/worksmobile_mapper.go +++ b/backend/internal/service/worksmobile_mapper.go @@ -402,8 +402,12 @@ func shuffleBytes(values []byte) { } func WorksmobileUserStatusAction(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case domain.UserStatusInactive, domain.UserStatusSuspended, domain.UserStatusLeaveOfAbsence: + normalized := domain.NormalizeUserStatus(status) + if domain.IsWorksDeprovisionUserStatus(normalized) { + return domain.WorksmobileActionDelete + } + switch normalized { + case domain.UserStatusSuspended: return WorksmobileUserActionSuspend default: return WorksmobileUserActionUpsert diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go index 73ba88c3..c7d4f3e2 100644 --- a/backend/internal/service/worksmobile_mapper_test.go +++ b/backend/internal/service/worksmobile_mapper_test.go @@ -371,9 +371,13 @@ func containsAny(value string, candidates string) bool { func TestWorksmobileUserStatusAction(t *testing.T) { require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusActive)) - require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusInactive)) + require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusTemporaryLeave)) require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusSuspended)) - require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence)) + require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusExtendedLeave)) + require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusBaronGuest)) + require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusArchived)) + require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence)) + require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction("baron_only")) } func TestValidateWorksmobileExternalKeyRejectsUnsupportedCharacters(t *testing.T) { diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 66b374e4..0b25ef91 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -215,6 +215,7 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena if err != nil { return WorksmobileBackfillDryRun{}, err } + users = worksmobileSyncScopeUsers(users) _ = s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceOrgUnit, ResourceID: root.ID, @@ -366,6 +367,12 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, if err != nil { return nil, err } + if domain.IsWorksDeprovisionUserStatus(user.Status) { + return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID) + } + if !domain.IsWorksProvisionedUserStatus(user.Status) { + return nil, errors.New("target user status is excluded from Worksmobile sync") + } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) payload, err := BuildWorksmobileUserPayloadForDomainTenants( *user, @@ -510,6 +517,13 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context, if err != nil { return err } + if domain.IsWorksDeprovisionUserStatus(user.Status) { + _, err := s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, root.ID) + return err + } + if !domain.IsWorksProvisionedUserStatus(user.Status) { + return nil + } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) payload, err := BuildWorksmobileUserPayloadForDomainTenants( user, @@ -545,16 +559,32 @@ func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context, if err != nil || !ok { return err } - return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{ + _, err = s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, "") + return err +} + +func (s *worksmobileSyncService) enqueueUserDelete(ctx context.Context, user domain.User, dedupeKey string, rootID string) (*domain.WorksmobileOutbox, error) { + payload := domain.JSONMap{ + "userExternalKey": user.ID, + "loginEmail": user.Email, + } + if rootID != "" { + payload["tenantRootId"] = rootID + } + if status := domain.NormalizeUserStatus(user.Status); status != "" { + payload["baronStatus"] = status + } + item := &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceUser, ResourceID: user.ID, Action: domain.WorksmobileActionDelete, - DedupeKey: "user:delete:" + user.ID, - Payload: domain.JSONMap{ - "userExternalKey": user.ID, - "loginEmail": user.Email, - }, - }) + DedupeKey: dedupeKey, + Payload: payload, + } + if err := s.outboxRepo.Create(ctx, item); err != nil { + return nil, err + } + return item, nil } func (s *worksmobileSyncService) hanmacRoot(ctx context.Context, tenantID string) (*domain.Tenant, error) { @@ -803,8 +833,18 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile } localByID := map[string]domain.User{} matchedRemoteIDs := map[string]bool{} + excludedLocalIDs := map[string]bool{} result := make([]WorksmobileComparisonItem, 0) for _, user := range localUsers { + if !domain.IsWorksProvisionedUserStatus(user.Status) { + excludedLocalIDs[user.ID] = true + if remote, ok := remoteByExternalID[user.ID]; ok { + matchedRemoteIDs[remote.ID] = true + } else if remote, ok := remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]; ok { + matchedRemoteIDs[remote.ID] = true + } + continue + } localByID[user.ID] = user remote, matched := remoteByExternalID[user.ID] if !matched { @@ -848,6 +888,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile if matchedRemoteIDs[remote.ID] { continue } + if excludedLocalIDs[remote.ExternalID] { + continue + } if remote.ExternalID == "" { result = append(result, WorksmobileComparisonItem{ ResourceType: "USER", @@ -1094,3 +1137,17 @@ func worksmobileTenantParentSlug(tenant domain.Tenant, tenantByID map[string]dom } return strings.TrimSpace(tenantByID[parentID].Slug) } + +func worksmobileSyncScopeUsers(users []domain.User) []domain.User { + if len(users) == 0 { + return users + } + filtered := make([]domain.User, 0, len(users)) + for _, user := range users { + if !domain.IsWorksProvisionedUserStatus(user.Status) { + continue + } + filtered = append(filtered, user) + } + return filtered +} diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index a6d60d1c..b2c9594f 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -101,6 +101,51 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"]) } +func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + rootID := "root-tenant" + tenantID := "saman-tenant" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "Hanmac Family", + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "saman", + Name: "Saman", + Type: domain.TenantTypeCompany, + ParentID: &rootID, + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + target := domain.User{ + ID: "archived-user", + Email: "archived@samaneng.com", + Name: "Archived", + Status: domain.UserStatusArchived, + TenantID: &tenantID, + } + outboxRepo := &fakeWorksmobileOutboxRepo{} + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, + &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}}, + outboxRepo, + nil, + ) + + item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID) + + require.NoError(t, err) + require.NotNil(t, item) + require.Equal(t, domain.WorksmobileActionDelete, item.Action) + require.Len(t, outboxRepo.created, 1) + + err = service.EnqueueUserUpsertIfInScope(context.Background(), target) + require.NoError(t, err) + require.Len(t, outboxRepo.created, 2) + require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[1].Action) +} + func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) { t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1") root := domain.Tenant{ @@ -759,6 +804,88 @@ func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T) require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs) } +func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) { + rootID := "root-tenant" + companyID := "company-tenant" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "한맥가족", + } + company := domain.Tenant{ + ID: companyID, + Name: "계열사", + Type: domain.TenantTypeCompany, + ParentID: &rootID, + } + archived := domain.User{ + ID: "archived-user", + Email: "archived@samaneng.com", + Name: "Archived", + TenantID: &companyID, + Status: domain.UserStatusArchived, + } + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}}, + &fakeWorksmobileUserRepo{byTenant: []domain.User{archived}}, + &fakeWorksmobileOutboxRepo{}, + &fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{ + ID: "works-archived", + ExternalID: archived.ID, + Email: archived.Email, + }}}, + ) + + comparison, err := service.GetComparison(context.Background(), rootID, true) + + require.NoError(t, err) + require.Empty(t, comparison.Users) +} + +func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) { + rootID := "root-tenant" + companyID := "company-tenant" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "한맥가족", + } + company := domain.Tenant{ + ID: companyID, + Name: "계열사", + Type: domain.TenantTypeCompany, + ParentID: &rootID, + } + active := domain.User{ + ID: "active-user", + Email: "active@samaneng.com", + Name: "Active", + TenantID: &companyID, + Status: domain.UserStatusActive, + } + archived := domain.User{ + ID: "archived-user", + Email: "archived@samaneng.com", + Name: "Archived", + TenantID: &companyID, + Status: domain.UserStatusArchived, + } + outboxRepo := &fakeWorksmobileOutboxRepo{} + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}}, + &fakeWorksmobileUserRepo{byTenant: []domain.User{active, archived}}, + outboxRepo, + nil, + ) + + dryRun, err := service.EnqueueBackfillDryRun(context.Background(), rootID) + + require.NoError(t, err) + require.Equal(t, 1, dryRun.UserCount) + require.Len(t, outboxRepo.created, 1) + require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"]) +} + type fakeWorksmobileTenantService struct { tenants map[string]domain.Tenant list []domain.Tenant diff --git a/docs/integrations-org-context-json-api.md b/docs/integrations-org-context-json-api.md index 3047d831..3b049939 100644 --- a/docs/integrations-org-context-json-api.md +++ b/docs/integrations-org-context-json-api.md @@ -156,6 +156,7 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac& - 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다. - `tree`는 같은 tenant 집합을 계층 구조로 제공하고, `tenants`는 slug/id lookup용 flat array로 제공한다. - 사용자 목록은 top-level `users`가 아니라 각 tenant의 `members`에 직접 소속 사용자 배열로 제공한다. +- `members`에는 `active`, `temporary_leave`, `suspended` 사용자만 포함한다. `preboarding`, `baron_guest`, `extended_leave`, `archived` 사용자는 email/local-part 선점 대상일 수 있지만 일반 조직도와 외부 연동 조직 조회에는 노출하지 않는다. - tenant 세부 분류는 `type`과 `orgUnitType`으로 구분한다. `orgUnitType`은 tenant `config.orgUnitType` 값이 있을 때만 포함한다. - 기본 사용자 응답은 로그인 claim 수준의 표시 정보만 제공한다. UUID, role/status, metadata, 생성/수정 시각은 기본 응답에 포함하지 않는다. - 사용자 UUID와 전화번호가 필요한 연동은 `includeUserIds=true`를 사용한다. 이때 각 tenant `members[].id`와 `members[].phone`만 추가된다. diff --git a/docs/worksmobile-directory-sync-technical-review.md b/docs/worksmobile-directory-sync-technical-review.md index 76849f3d..37aa875a 100644 --- a/docs/worksmobile-directory-sync-technical-review.md +++ b/docs/worksmobile-directory-sync-technical-review.md @@ -28,8 +28,11 @@ Baron SSO는 Ory Stack을 SoT로 두고, PostgreSQL은 read-model 및 비즈니 한맥가족 이메일 정책은 이미 #668에서 다음 방향으로 구현되어 있습니다. - `hanmac-family` root tenant와 descendant subtree에서 email local-part를 unique로 강제합니다. +- local-part unique 검사는 `archived`를 포함한 모든 사용자 상태를 대상으로 합니다. - 단건 생성은 중복 시 `409 Conflict`로 차단합니다. - bulk import는 `@domain` 입력 시 이름 기반 local-part를 제안하고, 생성 직전 재검증합니다. +- `preboarding`, `baron_guest`, `extended_leave`, `archived` 사용자는 Worksmobile 구성원 생성/갱신/backfill 대상에서 제외합니다. +- `baron_guest`, `extended_leave`, `archived` 상태로 전환된 사용자는 기존 Worksmobile 계정 delete/deprovision 대상입니다. ## 웍스모바일 Directory API 확인 사항 @@ -335,9 +338,12 @@ Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant deta - 후보 위치: `UserHandler.UpdateUser`에서 Kratos update와 local DB sync 후 - `email`, `name`, `phone`, `companyCode`, `tenant_id`, `metadata.additionalAppointments` 변경 시 `USER UPSERT` enqueue -- `inactive`는 Worksmobile suspend로 동기화합니다. +- `suspended`는 Worksmobile suspend로 동기화합니다. +- `temporary_leave`는 Worksmobile 계정을 유지합니다. +- `preboarding`은 Worksmobile 계정을 생성하지 않습니다. +- `baron_guest`, `extended_leave`, `archived`는 Worksmobile delete/deprovision으로 동기화합니다. - Baron user delete는 Worksmobile delete로 동기화합니다. -- `leave-of-absence`는 필요하지만 orgfront/Baron user status model 확장이 선행되어야 하므로 별도 scope로 분리합니다. +- 기존 `inactive` 입력은 `preboarding`, `leave_of_absence` 입력은 `temporary_leave`, `baron_only` 입력은 `baron_guest`로 호환 처리합니다. ## 테스트 전략 @@ -410,9 +416,11 @@ Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant deta - Developer Console External Key Mapping이 비어 있는 기존 조직/구성원은 CSV mapping 또는 API external-key update가 선행되어야 합니다. 6. 상태 정책 - - Baron `inactive`는 Worksmobile suspend로 동기화합니다. + - Baron `active`, `temporary_leave`, `suspended`는 Worksmobile 구성원 비교 및 backfill scope에 포함합니다. + - Baron `suspended`는 Worksmobile suspend로 동기화합니다. + - Baron `preboarding`은 Worksmobile 계정을 생성하지 않습니다. + - Baron `baron_guest`, `extended_leave`, `archived`는 Worksmobile delete/deprovision으로 동기화합니다. - Baron delete는 Worksmobile delete로 동기화합니다. - - leave-of-absence는 별도 user 상태 확장 이슈로 분리합니다. - 직급/직책/사용자 유형 External Key sync는 이번 scope에서 제외합니다. 7. adminfront 권한 경계 diff --git a/locales/en.toml b/locales/en.toml index a5f1e833..c3cc2776 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -1733,6 +1733,7 @@ title = "Affiliation & Organization Info" [ui.admin.users.list] add = "User Add" bulk_import = "Bulk Import" +change_status = "Change {{name}} status" empty = "No users found." fetch_error = "Failed to load the user list." search_placeholder = "Search Placeholder" @@ -1977,12 +1978,19 @@ system = "System" [ui.common.status] active = "Active" +archived = "Archived" +baron_guest = "Baron Guest" blocked = "ui.common.status.blocked" +extended_leave = "Extended Leave" failure = "Failure" inactive = "Inactive" +leave_of_absence = "Leave of absence" ok = "Ok" pending = "Pending" +preboarding = "Preboarding" success = "Success" +suspended = "Suspended" +temporary_leave = "Temporary Leave" [ui.dev] brand = "Brand" diff --git a/locales/ko.toml b/locales/ko.toml index 97fc5aba..ba90aa78 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -2197,6 +2197,7 @@ title = "소속 및 조직 정보" [ui.admin.users.list] add = "사용자 추가" bulk_import = "일괄 임포트" +change_status = "{{name}} 상태 변경" empty = "검색 결과가 없습니다." fetch_error = "사용자 목록 조회에 실패했습니다." search_placeholder = "이름 또는 이메일 검색..." @@ -2441,12 +2442,19 @@ system = "System" [ui.common.status] active = "활성" +archived = "보관됨" +baron_guest = "Baron 게스트" blocked = "ui.common.status.blocked" +extended_leave = "장기휴직" failure = "실패" inactive = "비활성" +leave_of_absence = "휴직" ok = "정상" pending = "준비 중" +preboarding = "입사대기" success = "성공" +suspended = "정지" +temporary_leave = "단기휴무" [ui.dev] brand = "Baron 로그인" diff --git a/locales/template.toml b/locales/template.toml index c61b80e8..d19cba8b 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -2076,6 +2076,7 @@ title = "" [ui.admin.users.list] add = "" bulk_import = "" +change_status = "" empty = "" fetch_error = "" search_placeholder = "" @@ -2320,12 +2321,19 @@ system = "" [ui.common.status] active = "" +archived = "" +baron_guest = "" blocked = "" +extended_leave = "" failure = "" inactive = "" +leave_of_absence = "" ok = "" pending = "" +preboarding = "" success = "" +suspended = "" +temporary_leave = "" [ui.dev] brand = ""