1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/userfront-magic-link-ux-fix

This commit is contained in:
2026-05-20 10:52:43 +09:00
33 changed files with 876 additions and 590 deletions

View File

@@ -155,6 +155,7 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
### 한맥가족 User Import Email 정책 ### 한맥가족 User Import Email 정책
- 전체 시스템에서 `users.email`은 unique입니다. - 전체 시스템에서 `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 해야 합니다. - 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다.
- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다. - 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다.
- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다. - `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다.
@@ -171,6 +172,21 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다. - 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다.
- bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다. - 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) ### 4. 주요 시나리오 (Core Scenarios)
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인). 1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).

View File

@@ -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개

View File

@@ -44,6 +44,13 @@ import {
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label"; import { Label } from "../../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { Switch } from "../../components/ui/switch"; import { Switch } from "../../components/ui/switch";
import { import {
Tabs, Tabs,
@@ -78,6 +85,11 @@ import {
parseOrgChartTenantSelection, parseOrgChartTenantSelection,
} from "./orgChartPicker"; } from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields"; import type { UserSchemaField } from "./userSchemaFields";
import {
normalizeUserStatusValue,
userStatusLabel,
userStatusValues,
} from "./userStatus";
import { resolvePersonalTenant } from "./utils/personalTenant"; import { resolvePersonalTenant } from "./utils/personalTenant";
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & { type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
@@ -603,7 +615,7 @@ function UserDetailPage() {
name: user.name, name: user.name,
phone: user.phone || "", phone: user.phone || "",
role: user.role, role: user.role,
status: user.status, status: normalizeUserStatusValue(user.status),
tenantSlug: tenantSlug:
user.tenantSlug || user.tenantSlug ||
user.joinedTenants?.find( user.joinedTenants?.find(
@@ -1044,21 +1056,25 @@ function UserDetailPage() {
> >
{t("ui.admin.users.detail.form.status", "상태")} {t("ui.admin.users.detail.form.status", "상태")}
</Label> </Label>
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3"> <Select
<Switch value={normalizeUserStatusValue(watchedStatus || "")}
id="status" onValueChange={(value) =>
checked={watchedStatus === "active"} setValue("status", normalizeUserStatusValue(value), {
onCheckedChange={(checked) => shouldDirty: true,
setValue("status", checked ? "active" : "inactive") })
} }
/> >
<span className="text-sm text-muted-foreground"> <SelectTrigger id="status" className="h-11 shadow-sm">
{t( <SelectValue />
`ui.common.status.${watchedStatus}`, </SelectTrigger>
watchedStatus || "inactive", <SelectContent>
)} {userStatusValues.map((status) => (
</span> <SelectItem key={status} value={status}>
</div> {userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>

View File

@@ -67,7 +67,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "../../components/ui/select"; } from "../../components/ui/select";
import { Switch } from "../../components/ui/switch";
import { import {
Table, Table,
TableBody, TableBody,
@@ -93,6 +92,7 @@ import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles"; import { isSuperAdminRole } from "../../lib/roles";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal"; import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import { import {
normalizeUserStatusValue,
type UserStatusValue, type UserStatusValue,
userStatusLabel, userStatusLabel,
userStatusValues, userStatusValues,
@@ -776,30 +776,37 @@ function UserListPage() {
{user.id} {user.id}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <Select
<Switch value={normalizeUserStatusValue(user.status)}
checked={user.status === "active"} onValueChange={(status) =>
onCheckedChange={(checked) => statusMutation.mutate({
statusMutation.mutate({ userId: user.id,
userId: user.id, status,
status: checked ? "active" : "inactive", })
}) }
} disabled={
disabled={ statusMutation.isPending || user.id === profile?.id
statusMutation.isPending || }
user.id === profile?.id >
} <SelectTrigger
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
aria-label={t( aria-label={t(
"ui.admin.users.list.toggle_status", "ui.admin.users.list.change_status",
"{{name}} 활성 상태", "{{name}} 상태 변경",
{ name: user.name }, { name: user.name },
)} )}
data-testid={`user-status-toggle-${user.id}`} data-testid={`user-status-select-${user.id}`}
/> >
<span className="text-sm text-muted-foreground"> <SelectValue />
{t(`ui.common.status.${user.status}`, user.status)} </SelectTrigger>
</span> <SelectContent>
</div> {userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Select <Select
@@ -894,13 +901,11 @@ function UserListPage() {
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{userStatusValues {userStatusValues.map((status) => (
.filter((s) => s === "active" || s === "inactive") <SelectItem key={status} value={status}>
.map((status) => ( {userStatusLabel(status)}
<SelectItem key={status} value={status}> </SelectItem>
{userStatusLabel(status)} ))}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
{canPromoteSuperAdmin && ( {canPromoteSuperAdmin && (

View File

@@ -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");
});
});

View File

@@ -2,13 +2,42 @@ import { t } from "../../lib/i18n";
export const userStatusValues = [ export const userStatusValues = [
"active", "active",
"inactive", "temporary_leave",
"suspended", "suspended",
"leave_of_absence", "preboarding",
"baron_guest",
"extended_leave",
"archived",
] as const; ] as const;
export type UserStatusValue = (typeof userStatusValues)[number]; export type UserStatusValue = (typeof userStatusValues)[number];
export function userStatusLabel(status: string) { export function normalizeUserStatusValue(status: string): UserStatusValue {
return t(`ui.common.status.${status}`, status); 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);
} }

View File

@@ -1281,6 +1281,7 @@ title = "Affiliation & Organization Info"
add = "Add User" add = "Add User"
add_to_tenant = "Add to Tenant" add_to_tenant = "Add to Tenant"
bulk_import = "Bulk Import" bulk_import = "Bulk Import"
change_status = "Change {{name}} status"
empty = "No users found." empty = "No users found."
fetch_error = "Failed to fetch user list." fetch_error = "Failed to fetch user list."
search_label = "Search Users" search_label = "Search Users"
@@ -1360,15 +1361,20 @@ user = "User"
[ui.common.status] [ui.common.status]
active = "Active" active = "Active"
archived = "Archived"
baron_guest = "Baron Guest"
blocked = "Blocked" blocked = "Blocked"
extended_leave = "Extended Leave"
failure = "Failure" failure = "Failure"
inactive = "Inactive" inactive = "Inactive"
leave_of_absence = "Leave of absence" leave_of_absence = "Leave of absence"
ok = "Ok" ok = "Ok"
pending = "Pending" pending = "Pending"
preboarding = "Preboarding"
status = "Status" status = "Status"
success = "Success" success = "Success"
suspended = "Suspended" suspended = "Suspended"
temporary_leave = "Temporary Leave"
[test] [test]
key = "Test" key = "Test"

View File

@@ -1283,6 +1283,7 @@ title = "소속 및 조직 정보"
add = "사용자 추가" add = "사용자 추가"
add_to_tenant = "테넌트에 추가" add_to_tenant = "테넌트에 추가"
bulk_import = "일괄 임포트" bulk_import = "일괄 임포트"
change_status = "{{name}} 상태 변경"
empty = "검색 결과가 없습니다." empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다." fetch_error = "사용자 목록 조회에 실패했습니다."
search_label = "사용자 검색" search_label = "사용자 검색"
@@ -1362,15 +1363,20 @@ user = "User"
[ui.common.status] [ui.common.status]
active = "활성" active = "활성"
archived = "보관됨"
baron_guest = "Baron 게스트"
blocked = "차단됨" blocked = "차단됨"
extended_leave = "장기휴직"
failure = "실패" failure = "실패"
inactive = "비활성" inactive = "비활성"
leave_of_absence = "휴직" leave_of_absence = "휴직"
ok = "정상" ok = "정상"
pending = "준비 중" pending = "준비 중"
preboarding = "입사대기"
status = "상태" status = "상태"
success = "성공" success = "성공"
suspended = "정지" suspended = "정지"
temporary_leave = "단기휴무"
[test] [test]
key = "테스트" key = "테스트"

View File

@@ -1295,6 +1295,7 @@ title = ""
[ui.admin.users.list] [ui.admin.users.list]
add = "" add = ""
bulk_import = "" bulk_import = ""
change_status = ""
empty = "" empty = ""
fetch_error = "" fetch_error = ""
search_placeholder = "" search_placeholder = ""
@@ -1340,15 +1341,20 @@ user = ""
[ui.common.status] [ui.common.status]
active = "" active = ""
archived = ""
baron_guest = ""
blocked = "" blocked = ""
extended_leave = ""
failure = "" failure = ""
inactive = "" inactive = ""
leave_of_absence = "" leave_of_absence = ""
ok = "" ok = ""
pending = "" pending = ""
preboarding = ""
status = "" status = ""
success = "" success = ""
suspended = "" suspended = ""
temporary_leave = ""
[test] [test]
key = "" key = ""

View File

@@ -431,7 +431,7 @@ test.describe("User Management", () => {
expect(exportUrl).toContain("includeIds=false"); 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, page,
}) => { }) => {
let updatePayload: Record<string, unknown> | undefined; let updatePayload: Record<string, unknown> | undefined;
@@ -446,7 +446,7 @@ test.describe("User Management", () => {
email: "john@test.com", email: "john@test.com",
phone: "010-1111-2222", phone: "010-1111-2222",
loginId: "johndoe", loginId: "johndoe",
status: "inactive", status: "preboarding",
createdAt: "2026-04-01T00:00:00Z", createdAt: "2026-04-01T00:00:00Z",
}, },
}); });
@@ -460,10 +460,11 @@ test.describe("User Management", () => {
table.getByRole("columnheader", { name: /ROLE|역할/i }), table.getByRole("columnheader", { name: /ROLE|역할/i }),
).toBeVisible(); ).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 await expect
.poll(() => updatePayload) .poll(() => updatePayload)
.toMatchObject({ status: "inactive" }); .toMatchObject({ status: "preboarding" });
}); });
test("should expose internal user uuid in the users table", async ({ test("should expose internal user uuid in the users table", async ({

View File

@@ -24,8 +24,70 @@ const (
UserStatusInactive = "inactive" UserStatusInactive = "inactive"
UserStatusSuspended = "suspended" UserStatusSuspended = "suspended"
UserStatusLeaveOfAbsence = "leave_of_absence" 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. // NormalizeRole maps legacy/synonym role values to canonical role keys.
func NormalizeRole(role string) string { func NormalizeRole(role string) string {
if normalized, ok := NormalizeRoleAlias(role); ok { if normalized, ok := NormalizeRoleAlias(role); ok {

View File

@@ -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)
}
})
}
}

View File

@@ -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) slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr)
return nil, fmt.Errorf("failed to resolve user identity") return nil, fmt.Errorf("failed to resolve user identity")
} }
if err := h.ensureUserActivityAllowed(ctx, subject); err != nil {
return nil, err
}
authInfo.Subject = subject authInfo.Subject = subject
return authInfo, nil return authInfo, nil
@@ -2598,9 +2601,30 @@ func passwordLoginErrorSpec(err error) (int, string, string) {
if strings.Contains(err.Error(), "failed to resolve user identity") { if strings.Contains(err.Error(), "failed to resolve user identity") {
return fiber.StatusInternalServerError, "internal_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" 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 { func headlessAssertionAudiences(c *fiber.Ctx) []string {
if c == nil { if c == nil {
return nil return nil
@@ -4522,6 +4546,9 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
func (h *AuthHandler) GetMe(c *fiber.Ctx) error { func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
profile, err := h.resolveCurrentProfile(c) profile, err := h.resolveCurrentProfile(c)
if err != nil { 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 errorJSON(c, fiber.StatusUnauthorized, err.Error())
} }
return c.JSON(profile) return c.JSON(profile)
@@ -6198,6 +6225,9 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
if err != nil || subject == "" { if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") 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) c.Locals("user_id", subject)
approvedSessionID := strings.TrimSpace(req.ApprovedSessionID) approvedSessionID := strings.TrimSpace(req.ApprovedSessionID)
if 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) slog.Warn("Hydra token session validation failed", "error", err)
return nil, 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) 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 { if err != nil {
return nil, err return nil, err
} }
if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil {
return nil, err
}
return h.applySessionInfoFromWhoami( return h.applySessionInfoFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits), h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt, authenticatedAt,
@@ -7667,6 +7703,9 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil {
return nil, err
}
return h.applySessionInfoFromWhoami( return h.applySessionInfoFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits), h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt, authenticatedAt,
@@ -7699,6 +7738,9 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") 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) currentPhone, _ := traits["phone_number"].(string)
newPhoneStorage := h.formatPhoneForStorage(req.Phone) newPhoneStorage := h.formatPhoneForStorage(req.Phone)

View File

@@ -31,6 +31,7 @@ import (
josejwt "github.com/go-jose/go-jose/v4/jwt" josejwt "github.com/go-jose/go-jose/v4/jwt"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
) )
// --- Mocks --- // --- Mocks ---
@@ -159,6 +160,61 @@ func newHeadlessPasswordLoginTestApp(h *AuthHandler) *fiber.App {
return 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) { func mustHeadlessRSAJWK(t *testing.T) (*rsa.PrivateKey, map[string]any) {
t.Helper() 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) { func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) {
mockIdp := new(MockIdentityProvider) mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다")) mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다"))

View File

@@ -13,6 +13,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"sort"
"strings" "strings"
"time" "time"
@@ -415,6 +416,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error()) return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
} }
tenants := filterTenantCSVDescendants(allTenants, parentID) tenants := filterTenantCSVDescendants(allTenants, parentID)
sortTenantsByInputOrder(tenants)
var buf bytes.Buffer var buf bytes.Buffer
writer := csv.NewWriter(&buf) writer := csv.NewWriter(&buf)
@@ -483,6 +485,15 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
return c.Send(buf.Bytes()) 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 { func filterTenantCSVDescendants(tenants []domain.Tenant, parentID string) []domain.Tenant {
parentID = strings.TrimSpace(parentID) parentID = strings.TrimSpace(parentID)
if parentID == "" { if parentID == "" {
@@ -2231,7 +2242,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
users := append(usersByID, usersBySlug...) users := append(usersByID, usersBySlug...)
users = append(users, usersByAppointment...) users = append(users, usersByAppointment...)
for _, user := range users { for _, user := range users {
if seen[user.ID] || user.Status != domain.UserStatusActive { if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) {
continue continue
} }
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs) assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)

View File

@@ -616,6 +616,66 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
CreatedAt: now, CreatedAt: now,
UpdatedAt: 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{ usersBySlug := []domain.User{
{ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now}, {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") require.NotContains(t, got, "users")
deptPlatform := tenantsPayload[2].(map[string]any) deptPlatform := tenantsPayload[2].(map[string]any)
platformMembers := deptPlatform["members"].([]any) platformMembers := deptPlatform["members"].([]any)
require.Len(t, platformMembers, 1) require.Len(t, platformMembers, 3)
firstUser := platformMembers[0].(map[string]any) firstUser := platformMembers[0].(map[string]any)
require.NotContains(t, firstUser, "id") require.NotContains(t, firstUser, "id")
require.NotContains(t, firstUser, "phone") 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), "directUserIds")
require.NotContains(t, toJSONString(t, got), "private-team") require.NotContains(t, toJSONString(t, got), "private-team")
require.NotContains(t, toJSONString(t, got), "root-other") 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) { func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
@@ -963,6 +1029,37 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T)
mockSvc.AssertExpectations(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) { func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)

View File

@@ -1644,10 +1644,9 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
state := identity.State state := identity.State
if req.Status != nil { if req.Status != nil {
if *req.Status == "active" { state = normalizeKratosState(req.Status)
state = "active" if state == "" {
} else { state = identity.State
state = "inactive"
} }
} }
@@ -1667,7 +1666,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
localUser.Role = *req.Role localUser.Role = *req.Role
} }
if req.Status != nil { if req.Status != nil {
localUser.Status = *req.Status localUser.Status = normalizeStatus(*req.Status)
} }
if req.Department != nil { if req.Department != nil {
localUser.Department = *req.Department localUser.Department = *req.Department
@@ -2610,20 +2609,7 @@ func formatTime(value time.Time) string {
} }
func normalizeStatus(state string) string { func normalizeStatus(state string) string {
state = strings.ToLower(strings.TrimSpace(state)) return domain.NormalizeUserStatus(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
} }
func normalizeKratosState(status *string) string { func normalizeKratosState(status *string) string {
@@ -2637,9 +2623,13 @@ func normalizeKratosState(status *string) string {
if value == domain.UserStatusActive { if value == domain.UserStatusActive {
return domain.UserStatusActive return domain.UserStatusActive
} }
if value == domain.UserStatusInactive || normalized := domain.NormalizeUserStatus(value)
value == domain.UserStatusSuspended || if normalized == domain.UserStatusPreboarding ||
value == domain.UserStatusLeaveOfAbsence { normalized == domain.UserStatusSuspended ||
normalized == domain.UserStatusTemporaryLeave ||
normalized == domain.UserStatusBaronGuest ||
normalized == domain.UserStatusExtendedLeave ||
normalized == domain.UserStatusArchived {
return domain.UserStatusInactive return domain.UserStatusInactive
} }
return "" return ""

View File

@@ -1017,7 +1017,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
assert.True(t, results[0].(map[string]interface{})["success"].(bool)) assert.True(t, results[0].(map[string]interface{})["success"].(bool))
assert.Len(t, worksmobile.upserts, 1) assert.Len(t, worksmobile.upserts, 1)
assert.Equal(t, "u-1", worksmobile.upserts[0].ID) 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) { t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {

View File

@@ -3,6 +3,7 @@ package repository
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"context" "context"
"fmt"
"strings" "strings"
"gorm.io/gorm" "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 { func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) 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 var existing domain.User
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil { if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
if existing.ID != user.ID { 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 { if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
return err 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 { if err := tx.Unscoped().Delete(&domain.User{}, "id = ?", existing.ID).Error; err != nil {
return err 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{ return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}}, Columns: []clause.Column{{Name: "id"}},
UpdateAll: true, UpdateAll: true,

View File

@@ -53,6 +53,36 @@ func TestUserRepository(t *testing.T) {
assert.Equal(t, "010-1234-5678", found.Phone) 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) { t.Run("List Users with Search", func(t *testing.T) {
// Add some users // Add some users
_ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"}) _ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"})

View File

@@ -384,14 +384,7 @@ func userGroupTraitStringArray(traits map[string]interface{}, key string) []stri
} }
func userGroupIdentityStatus(state string) string { func userGroupIdentityStatus(state string) string {
switch state { return domain.NormalizeUserStatus(state)
case "", "active":
return domain.UserStatusActive
case "inactive":
return domain.UserStatusInactive
default:
return state
}
} }
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error { func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {

View File

@@ -145,14 +145,9 @@ func kratosProjectionTraitStringArray(traits map[string]interface{}, key string)
} }
func normalizeProjectionStatus(state string) string { func normalizeProjectionStatus(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) { normalized := domain.NormalizeUserStatus(state)
case "blocked", domain.UserStatusInactive: if normalized == "" {
return domain.UserStatusInactive
case domain.UserStatusSuspended:
return domain.UserStatusSuspended
case domain.UserStatusLeaveOfAbsence:
return domain.UserStatusLeaveOfAbsence
default:
return domain.UserStatusActive return domain.UserStatusActive
} }
return normalized
} }

View File

@@ -96,3 +96,16 @@ func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testin
assert.Empty(t, repo.replacedUsers) assert.Empty(t, repo.replacedUsers)
kratos.AssertExpectations(t) 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)
}

View File

@@ -924,6 +924,7 @@ type fakeWorksmobileDirectoryClient struct {
deletedUsers []string deletedUsers []string
activeUsers []string activeUsers []string
suspendedUsers []string suspendedUsers []string
users []WorksmobileRemoteUser
orgUnitMatchKeys []string orgUnitMatchKeys []string
groups []WorksmobileRemoteGroup groups []WorksmobileRemoteGroup
} }
@@ -1029,7 +1030,7 @@ func (f *fakeWorksmobileDirectoryClient) SetUserActive(ctx context.Context, user
} }
func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) { 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) { func (f *fakeWorksmobileDirectoryClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) {

View File

@@ -402,8 +402,12 @@ func shuffleBytes(values []byte) {
} }
func WorksmobileUserStatusAction(status string) string { func WorksmobileUserStatusAction(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) { normalized := domain.NormalizeUserStatus(status)
case domain.UserStatusInactive, domain.UserStatusSuspended, domain.UserStatusLeaveOfAbsence: if domain.IsWorksDeprovisionUserStatus(normalized) {
return domain.WorksmobileActionDelete
}
switch normalized {
case domain.UserStatusSuspended:
return WorksmobileUserActionSuspend return WorksmobileUserActionSuspend
default: default:
return WorksmobileUserActionUpsert return WorksmobileUserActionUpsert

View File

@@ -371,9 +371,13 @@ func containsAny(value string, candidates string) bool {
func TestWorksmobileUserStatusAction(t *testing.T) { func TestWorksmobileUserStatusAction(t *testing.T) {
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusActive)) 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.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) { func TestValidateWorksmobileExternalKeyRejectsUnsupportedCharacters(t *testing.T) {

View File

@@ -215,6 +215,7 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena
if err != nil { if err != nil {
return WorksmobileBackfillDryRun{}, err return WorksmobileBackfillDryRun{}, err
} }
users = worksmobileSyncScopeUsers(users)
_ = s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{ _ = s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit, ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: root.ID, ResourceID: root.ID,
@@ -366,6 +367,12 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
if err != nil { if err != nil {
return nil, err 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...)) tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants( payload, err := BuildWorksmobileUserPayloadForDomainTenants(
*user, *user,
@@ -510,6 +517,13 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
if err != nil { if err != nil {
return err 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...)) tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants( payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user, user,
@@ -545,16 +559,32 @@ func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context,
if err != nil || !ok { if err != nil || !ok {
return err 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, ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID, ResourceID: user.ID,
Action: domain.WorksmobileActionDelete, Action: domain.WorksmobileActionDelete,
DedupeKey: "user:delete:" + user.ID, DedupeKey: dedupeKey,
Payload: domain.JSONMap{ Payload: payload,
"userExternalKey": user.ID, }
"loginEmail": user.Email, 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) { 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{} localByID := map[string]domain.User{}
matchedRemoteIDs := map[string]bool{} matchedRemoteIDs := map[string]bool{}
excludedLocalIDs := map[string]bool{}
result := make([]WorksmobileComparisonItem, 0) result := make([]WorksmobileComparisonItem, 0)
for _, user := range localUsers { 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 localByID[user.ID] = user
remote, matched := remoteByExternalID[user.ID] remote, matched := remoteByExternalID[user.ID]
if !matched { if !matched {
@@ -848,6 +888,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
if matchedRemoteIDs[remote.ID] { if matchedRemoteIDs[remote.ID] {
continue continue
} }
if excludedLocalIDs[remote.ExternalID] {
continue
}
if remote.ExternalID == "" { if remote.ExternalID == "" {
result = append(result, WorksmobileComparisonItem{ result = append(result, WorksmobileComparisonItem{
ResourceType: "USER", ResourceType: "USER",
@@ -1094,3 +1137,17 @@ func worksmobileTenantParentSlug(tenant domain.Tenant, tenantByID map[string]dom
} }
return strings.TrimSpace(tenantByID[parentID].Slug) 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
}

View File

@@ -101,6 +101,51 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"]) 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) { func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) {
t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1") t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1")
root := domain.Tenant{ root := domain.Tenant{
@@ -759,6 +804,88 @@ func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T)
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs) 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 { type fakeWorksmobileTenantService struct {
tenants map[string]domain.Tenant tenants map[string]domain.Tenant
list []domain.Tenant list []domain.Tenant

View File

@@ -156,6 +156,7 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
- 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다. - 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다.
- `tree`는 같은 tenant 집합을 계층 구조로 제공하고, `tenants`는 slug/id lookup용 flat array로 제공한다. - `tree`는 같은 tenant 집합을 계층 구조로 제공하고, `tenants`는 slug/id lookup용 flat array로 제공한다.
- 사용자 목록은 top-level `users`가 아니라 각 tenant의 `members`에 직접 소속 사용자 배열로 제공한다. - 사용자 목록은 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` 값이 있을 때만 포함한다. - tenant 세부 분류는 `type``orgUnitType`으로 구분한다. `orgUnitType`은 tenant `config.orgUnitType` 값이 있을 때만 포함한다.
- 기본 사용자 응답은 로그인 claim 수준의 표시 정보만 제공한다. UUID, role/status, metadata, 생성/수정 시각은 기본 응답에 포함하지 않는다. - 기본 사용자 응답은 로그인 claim 수준의 표시 정보만 제공한다. UUID, role/status, metadata, 생성/수정 시각은 기본 응답에 포함하지 않는다.
- 사용자 UUID와 전화번호가 필요한 연동은 `includeUserIds=true`를 사용한다. 이때 각 tenant `members[].id``members[].phone`만 추가된다. - 사용자 UUID와 전화번호가 필요한 연동은 `includeUserIds=true`를 사용한다. 이때 각 tenant `members[].id``members[].phone`만 추가된다.

View File

@@ -28,8 +28,11 @@ Baron SSO는 Ory Stack을 SoT로 두고, PostgreSQL은 read-model 및 비즈니
한맥가족 이메일 정책은 이미 #668에서 다음 방향으로 구현되어 있습니다. 한맥가족 이메일 정책은 이미 #668에서 다음 방향으로 구현되어 있습니다.
- `hanmac-family` root tenant와 descendant subtree에서 email local-part를 unique로 강제합니다. - `hanmac-family` root tenant와 descendant subtree에서 email local-part를 unique로 강제합니다.
- local-part unique 검사는 `archived`를 포함한 모든 사용자 상태를 대상으로 합니다.
- 단건 생성은 중복 시 `409 Conflict`로 차단합니다. - 단건 생성은 중복 시 `409 Conflict`로 차단합니다.
- bulk import는 `@domain` 입력 시 이름 기반 local-part를 제안하고, 생성 직전 재검증합니다. - bulk import는 `@domain` 입력 시 이름 기반 local-part를 제안하고, 생성 직전 재검증합니다.
- `preboarding`, `baron_guest`, `extended_leave`, `archived` 사용자는 Worksmobile 구성원 생성/갱신/backfill 대상에서 제외합니다.
- `baron_guest`, `extended_leave`, `archived` 상태로 전환된 사용자는 기존 Worksmobile 계정 delete/deprovision 대상입니다.
## 웍스모바일 Directory API 확인 사항 ## 웍스모바일 Directory API 확인 사항
@@ -335,9 +338,12 @@ Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant deta
- 후보 위치: `UserHandler.UpdateUser`에서 Kratos update와 local DB sync 후 - 후보 위치: `UserHandler.UpdateUser`에서 Kratos update와 local DB sync 후
- `email`, `name`, `phone`, `companyCode`, `tenant_id`, `metadata.additionalAppointments` 변경 시 `USER UPSERT` enqueue - `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로 동기화합니다. - 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가 선행되어야 합니다. - Developer Console External Key Mapping이 비어 있는 기존 조직/구성원은 CSV mapping 또는 API external-key update가 선행되어야 합니다.
6. 상태 정책 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로 동기화합니다. - Baron delete는 Worksmobile delete로 동기화합니다.
- leave-of-absence는 별도 user 상태 확장 이슈로 분리합니다.
- 직급/직책/사용자 유형 External Key sync는 이번 scope에서 제외합니다. - 직급/직책/사용자 유형 External Key sync는 이번 scope에서 제외합니다.
7. adminfront 권한 경계 7. adminfront 권한 경계

View File

@@ -1733,6 +1733,7 @@ title = "Affiliation & Organization Info"
[ui.admin.users.list] [ui.admin.users.list]
add = "User Add" add = "User Add"
bulk_import = "Bulk Import" bulk_import = "Bulk Import"
change_status = "Change {{name}} status"
empty = "No users found." empty = "No users found."
fetch_error = "Failed to load the user list." fetch_error = "Failed to load the user list."
search_placeholder = "Search Placeholder" search_placeholder = "Search Placeholder"
@@ -1977,12 +1978,19 @@ system = "System"
[ui.common.status] [ui.common.status]
active = "Active" active = "Active"
archived = "Archived"
baron_guest = "Baron Guest"
blocked = "ui.common.status.blocked" blocked = "ui.common.status.blocked"
extended_leave = "Extended Leave"
failure = "Failure" failure = "Failure"
inactive = "Inactive" inactive = "Inactive"
leave_of_absence = "Leave of absence"
ok = "Ok" ok = "Ok"
pending = "Pending" pending = "Pending"
preboarding = "Preboarding"
success = "Success" success = "Success"
suspended = "Suspended"
temporary_leave = "Temporary Leave"
[ui.dev] [ui.dev]
brand = "Brand" brand = "Brand"

View File

@@ -2197,6 +2197,7 @@ title = "소속 및 조직 정보"
[ui.admin.users.list] [ui.admin.users.list]
add = "사용자 추가" add = "사용자 추가"
bulk_import = "일괄 임포트" bulk_import = "일괄 임포트"
change_status = "{{name}} 상태 변경"
empty = "검색 결과가 없습니다." empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다." fetch_error = "사용자 목록 조회에 실패했습니다."
search_placeholder = "이름 또는 이메일 검색..." search_placeholder = "이름 또는 이메일 검색..."
@@ -2441,12 +2442,19 @@ system = "System"
[ui.common.status] [ui.common.status]
active = "활성" active = "활성"
archived = "보관됨"
baron_guest = "Baron 게스트"
blocked = "ui.common.status.blocked" blocked = "ui.common.status.blocked"
extended_leave = "장기휴직"
failure = "실패" failure = "실패"
inactive = "비활성" inactive = "비활성"
leave_of_absence = "휴직"
ok = "정상" ok = "정상"
pending = "준비 중" pending = "준비 중"
preboarding = "입사대기"
success = "성공" success = "성공"
suspended = "정지"
temporary_leave = "단기휴무"
[ui.dev] [ui.dev]
brand = "Baron 로그인" brand = "Baron 로그인"

View File

@@ -2076,6 +2076,7 @@ title = ""
[ui.admin.users.list] [ui.admin.users.list]
add = "" add = ""
bulk_import = "" bulk_import = ""
change_status = ""
empty = "" empty = ""
fetch_error = "" fetch_error = ""
search_placeholder = "" search_placeholder = ""
@@ -2320,12 +2321,19 @@ system = ""
[ui.common.status] [ui.common.status]
active = "" active = ""
archived = ""
baron_guest = ""
blocked = "" blocked = ""
extended_leave = ""
failure = "" failure = ""
inactive = "" inactive = ""
leave_of_absence = ""
ok = "" ok = ""
pending = "" pending = ""
preboarding = ""
success = "" success = ""
suspended = ""
temporary_leave = ""
[ui.dev] [ui.dev]
brand = "" brand = ""