forked from baron/baron-sso
사용자 상태 세분화
This commit is contained in:
16
README.md
16
README.md
@@ -155,6 +155,7 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
|
||||
|
||||
### 한맥가족 User Import Email 정책
|
||||
- 전체 시스템에서 `users.email`은 unique입니다.
|
||||
- `active`, `temporary_leave`, `suspended`, `preboarding`, `baron_guest`, `extended_leave`, `archived` 등 모든 사용자 상태가 unique 검사 대상입니다. 특히 `preboarding`, `baron_guest`, `archived` 사용자는 email/local-part 선점 대상입니다.
|
||||
- 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다.
|
||||
- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다.
|
||||
- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다.
|
||||
@@ -171,6 +172,21 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
|
||||
- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다.
|
||||
- bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다.
|
||||
|
||||
### User Status 정책
|
||||
| 상태 | 표시명 | Baron 사용 | Works 처리 | 일반 조직도 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `active` | 재직 | 가능 | 생성/갱신 | 노출 |
|
||||
| `temporary_leave` | 단기휴무 | 가능 | 계정 유지 | 노출 |
|
||||
| `suspended` | 정지 | 불가 | suspend | 노출 |
|
||||
| `preboarding` | 입사대기 | 불가 | 생성 안 함 | 비노출 |
|
||||
| `baron_guest` | Baron 게스트 | 가능 | 생성 금지, 기존 계정 delete/deprovision | 비노출 |
|
||||
| `extended_leave` | 장기휴직 | 불가 | delete/deprovision | 비노출 |
|
||||
| `archived` | 보관 | 불가 | delete/deprovision | 비노출 |
|
||||
|
||||
- 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다.
|
||||
- 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다.
|
||||
- `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다.
|
||||
|
||||
|
||||
### 4. 주요 시나리오 (Core Scenarios)
|
||||
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
||||
|
||||
@@ -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개
|
||||
@@ -44,6 +44,13 @@ import {
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../components/ui/select";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Tabs,
|
||||
@@ -78,6 +85,11 @@ import {
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import {
|
||||
normalizeUserStatusValue,
|
||||
userStatusLabel,
|
||||
userStatusValues,
|
||||
} from "./userStatus";
|
||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
@@ -603,7 +615,7 @@ function UserDetailPage() {
|
||||
name: user.name,
|
||||
phone: user.phone || "",
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
status: normalizeUserStatusValue(user.status),
|
||||
tenantSlug:
|
||||
user.tenantSlug ||
|
||||
user.joinedTenants?.find(
|
||||
@@ -1044,21 +1056,25 @@ function UserDetailPage() {
|
||||
>
|
||||
{t("ui.admin.users.detail.form.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
|
||||
<Switch
|
||||
id="status"
|
||||
checked={watchedStatus === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue("status", checked ? "active" : "inactive")
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
`ui.common.status.${watchedStatus}`,
|
||||
watchedStatus || "inactive",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={normalizeUserStatusValue(watchedStatus || "")}
|
||||
onValueChange={(value) =>
|
||||
setValue("status", normalizeUserStatusValue(value), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="status" className="h-11 shadow-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../components/ui/select";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -93,6 +92,7 @@ import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole } from "../../lib/roles";
|
||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||
import {
|
||||
normalizeUserStatusValue,
|
||||
type UserStatusValue,
|
||||
userStatusLabel,
|
||||
userStatusValues,
|
||||
@@ -776,30 +776,37 @@ function UserListPage() {
|
||||
{user.id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={user.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status: checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
<Select
|
||||
value={normalizeUserStatusValue(user.status)}
|
||||
onValueChange={(status) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
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(
|
||||
"ui.admin.users.list.toggle_status",
|
||||
"{{name}} 활성 상태",
|
||||
"ui.admin.users.list.change_status",
|
||||
"{{name}} 상태 변경",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-toggle-${user.id}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</span>
|
||||
</div>
|
||||
data-testid={`user-status-select-${user.id}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
@@ -894,13 +901,11 @@ function UserListPage() {
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues
|
||||
.filter((s) => s === "active" || s === "inactive")
|
||||
.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{canPromoteSuperAdmin && (
|
||||
|
||||
38
adminfront/src/features/users/userStatus.test.ts
Normal file
38
adminfront/src/features/users/userStatus.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,42 @@ import { t } from "../../lib/i18n";
|
||||
|
||||
export const userStatusValues = [
|
||||
"active",
|
||||
"inactive",
|
||||
"temporary_leave",
|
||||
"suspended",
|
||||
"leave_of_absence",
|
||||
"preboarding",
|
||||
"baron_guest",
|
||||
"extended_leave",
|
||||
"archived",
|
||||
] as const;
|
||||
|
||||
export type UserStatusValue = (typeof userStatusValues)[number];
|
||||
|
||||
export function userStatusLabel(status: string) {
|
||||
return t(`ui.common.status.${status}`, status);
|
||||
export function normalizeUserStatusValue(status: string): UserStatusValue {
|
||||
switch (status.trim().toLowerCase()) {
|
||||
case "active":
|
||||
return "active";
|
||||
case "temporary_leave":
|
||||
case "leave_of_absence":
|
||||
return "temporary_leave";
|
||||
case "suspended":
|
||||
case "blocked":
|
||||
return "suspended";
|
||||
case "preboarding":
|
||||
case "inactive":
|
||||
return "preboarding";
|
||||
case "baron_guest":
|
||||
case "baron_only":
|
||||
return "baron_guest";
|
||||
case "extended_leave":
|
||||
return "extended_leave";
|
||||
case "archived":
|
||||
return "archived";
|
||||
default:
|
||||
return "preboarding";
|
||||
}
|
||||
}
|
||||
|
||||
export function userStatusLabel(status: string) {
|
||||
const normalized = normalizeUserStatusValue(status);
|
||||
return t(`ui.common.status.${normalized}`, normalized);
|
||||
}
|
||||
|
||||
@@ -1281,6 +1281,7 @@ title = "Affiliation & Organization Info"
|
||||
add = "Add User"
|
||||
add_to_tenant = "Add to Tenant"
|
||||
bulk_import = "Bulk Import"
|
||||
change_status = "Change {{name}} status"
|
||||
empty = "No users found."
|
||||
fetch_error = "Failed to fetch user list."
|
||||
search_label = "Search Users"
|
||||
@@ -1360,15 +1361,20 @@ user = "User"
|
||||
|
||||
[ui.common.status]
|
||||
active = "Active"
|
||||
archived = "Archived"
|
||||
baron_guest = "Baron Guest"
|
||||
blocked = "Blocked"
|
||||
extended_leave = "Extended Leave"
|
||||
failure = "Failure"
|
||||
inactive = "Inactive"
|
||||
leave_of_absence = "Leave of absence"
|
||||
ok = "Ok"
|
||||
pending = "Pending"
|
||||
preboarding = "Preboarding"
|
||||
status = "Status"
|
||||
success = "Success"
|
||||
suspended = "Suspended"
|
||||
temporary_leave = "Temporary Leave"
|
||||
|
||||
[test]
|
||||
key = "Test"
|
||||
|
||||
@@ -1283,6 +1283,7 @@ title = "소속 및 조직 정보"
|
||||
add = "사용자 추가"
|
||||
add_to_tenant = "테넌트에 추가"
|
||||
bulk_import = "일괄 임포트"
|
||||
change_status = "{{name}} 상태 변경"
|
||||
empty = "검색 결과가 없습니다."
|
||||
fetch_error = "사용자 목록 조회에 실패했습니다."
|
||||
search_label = "사용자 검색"
|
||||
@@ -1362,15 +1363,20 @@ user = "User"
|
||||
|
||||
[ui.common.status]
|
||||
active = "활성"
|
||||
archived = "보관됨"
|
||||
baron_guest = "Baron 게스트"
|
||||
blocked = "차단됨"
|
||||
extended_leave = "장기휴직"
|
||||
failure = "실패"
|
||||
inactive = "비활성"
|
||||
leave_of_absence = "휴직"
|
||||
ok = "정상"
|
||||
pending = "준비 중"
|
||||
preboarding = "입사대기"
|
||||
status = "상태"
|
||||
success = "성공"
|
||||
suspended = "정지"
|
||||
temporary_leave = "단기휴무"
|
||||
|
||||
[test]
|
||||
key = "테스트"
|
||||
|
||||
@@ -1295,6 +1295,7 @@ title = ""
|
||||
[ui.admin.users.list]
|
||||
add = ""
|
||||
bulk_import = ""
|
||||
change_status = ""
|
||||
empty = ""
|
||||
fetch_error = ""
|
||||
search_placeholder = ""
|
||||
@@ -1340,15 +1341,20 @@ user = ""
|
||||
|
||||
[ui.common.status]
|
||||
active = ""
|
||||
archived = ""
|
||||
baron_guest = ""
|
||||
blocked = ""
|
||||
extended_leave = ""
|
||||
failure = ""
|
||||
inactive = ""
|
||||
leave_of_absence = ""
|
||||
ok = ""
|
||||
pending = ""
|
||||
preboarding = ""
|
||||
status = ""
|
||||
success = ""
|
||||
suspended = ""
|
||||
temporary_leave = ""
|
||||
|
||||
[test]
|
||||
key = ""
|
||||
|
||||
@@ -431,7 +431,7 @@ test.describe("User Management", () => {
|
||||
expect(exportUrl).toContain("includeIds=false");
|
||||
});
|
||||
|
||||
test("should show contact info in one row, hide roles, and toggle user status", async ({
|
||||
test("should show contact info in one row, hide roles, and change user status", async ({
|
||||
page,
|
||||
}) => {
|
||||
let updatePayload: Record<string, unknown> | undefined;
|
||||
@@ -446,7 +446,7 @@ test.describe("User Management", () => {
|
||||
email: "john@test.com",
|
||||
phone: "010-1111-2222",
|
||||
loginId: "johndoe",
|
||||
status: "inactive",
|
||||
status: "preboarding",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
@@ -460,10 +460,11 @@ test.describe("User Management", () => {
|
||||
table.getByRole("columnheader", { name: /ROLE|역할/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-status-toggle-u-1").click();
|
||||
await page.getByTestId("user-status-select-u-1").click();
|
||||
await page.getByRole("option", { name: /입사대기|Preboarding/ }).click();
|
||||
await expect
|
||||
.poll(() => updatePayload)
|
||||
.toMatchObject({ status: "inactive" });
|
||||
.toMatchObject({ status: "preboarding" });
|
||||
});
|
||||
|
||||
test("should expose internal user uuid in the users table", async ({
|
||||
|
||||
@@ -24,8 +24,70 @@ const (
|
||||
UserStatusInactive = "inactive"
|
||||
UserStatusSuspended = "suspended"
|
||||
UserStatusLeaveOfAbsence = "leave_of_absence"
|
||||
UserStatusTemporaryLeave = "temporary_leave"
|
||||
UserStatusPreboarding = "preboarding"
|
||||
UserStatusBaronGuest = "baron_guest"
|
||||
UserStatusExtendedLeave = "extended_leave"
|
||||
UserStatusArchived = "archived"
|
||||
)
|
||||
|
||||
func NormalizeUserStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "", UserStatusActive:
|
||||
return UserStatusActive
|
||||
case "blocked", UserStatusSuspended:
|
||||
return UserStatusSuspended
|
||||
case UserStatusInactive, UserStatusPreboarding:
|
||||
return UserStatusPreboarding
|
||||
case UserStatusLeaveOfAbsence, UserStatusTemporaryLeave:
|
||||
return UserStatusTemporaryLeave
|
||||
case "baron_only", UserStatusBaronGuest:
|
||||
return UserStatusBaronGuest
|
||||
case UserStatusExtendedLeave:
|
||||
return UserStatusExtendedLeave
|
||||
case UserStatusArchived:
|
||||
return UserStatusArchived
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(status))
|
||||
}
|
||||
}
|
||||
|
||||
func IsBaronActivityAllowedStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusActive, UserStatusTemporaryLeave, UserStatusBaronGuest:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsOrgVisibleUserStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsWorksProvisionedUserStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsWorksDeprovisionUserStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusBaronGuest, UserStatusExtendedLeave, UserStatusArchived:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeRole maps legacy/synonym role values to canonical role keys.
|
||||
func NormalizeRole(role string) string {
|
||||
if normalized, ok := NormalizeRoleAlias(role); ok {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2580,6 +2580,9 @@ func (h *AuthHandler) authenticatePasswordLogin(ctx context.Context, loginID, pa
|
||||
slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr)
|
||||
return nil, fmt.Errorf("failed to resolve user identity")
|
||||
}
|
||||
if err := h.ensureUserActivityAllowed(ctx, subject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authInfo.Subject = subject
|
||||
return authInfo, nil
|
||||
@@ -2598,9 +2601,30 @@ func passwordLoginErrorSpec(err error) (int, string, string) {
|
||||
if strings.Contains(err.Error(), "failed to resolve user identity") {
|
||||
return fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity"
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot perform Baron activity") {
|
||||
return fiber.StatusForbidden, "user_status_forbidden", "This user status cannot sign in"
|
||||
}
|
||||
return fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials"
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ensureUserActivityAllowed(ctx context.Context, userID string) error {
|
||||
if h == nil || h.UserRepo == nil || strings.TrimSpace(userID) == "" {
|
||||
return nil
|
||||
}
|
||||
user, err := h.UserRepo.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return nil
|
||||
}
|
||||
if !domain.IsBaronActivityAllowedStatus(user.Status) {
|
||||
return fmt.Errorf("user status %s cannot perform Baron activity", domain.NormalizeUserStatus(user.Status))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUserActivityForbiddenError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "cannot perform Baron activity")
|
||||
}
|
||||
|
||||
func headlessAssertionAudiences(c *fiber.Ctx) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
@@ -4522,6 +4546,9 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
|
||||
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
||||
profile, err := h.resolveCurrentProfile(c)
|
||||
if err != nil {
|
||||
if isUserActivityForbiddenError(err) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity")
|
||||
}
|
||||
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
|
||||
}
|
||||
return c.JSON(profile)
|
||||
@@ -6198,6 +6225,9 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
|
||||
if err != nil || subject == "" {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
||||
}
|
||||
if err := h.ensureUserActivityAllowed(c.Context(), subject); err != nil {
|
||||
return fiber.NewError(fiber.StatusForbidden, "This user status cannot sign in")
|
||||
}
|
||||
c.Locals("user_id", subject)
|
||||
approvedSessionID := strings.TrimSpace(req.ApprovedSessionID)
|
||||
if approvedSessionID == "" {
|
||||
@@ -7472,6 +7502,9 @@ func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domai
|
||||
slog.Warn("Hydra token session validation failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
if err := h.ensureUserActivityAllowed(ctx, intro.Subject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID)
|
||||
|
||||
@@ -7655,6 +7688,9 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.applySessionInfoFromWhoami(
|
||||
h.mapKratosIdentityToProfile(identityID, traits),
|
||||
authenticatedAt,
|
||||
@@ -7667,6 +7703,9 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.applySessionInfoFromWhoami(
|
||||
h.mapKratosIdentityToProfile(identityID, traits),
|
||||
authenticatedAt,
|
||||
@@ -7699,6 +7738,9 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
||||
}
|
||||
if err := h.ensureUserActivityAllowed(c.Context(), identityID); err != nil {
|
||||
return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity")
|
||||
}
|
||||
|
||||
currentPhone, _ := traits["phone_number"].(string)
|
||||
newPhoneStorage := h.formatPhoneForStorage(req.Phone)
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- Mocks ---
|
||||
@@ -159,6 +160,61 @@ func newHeadlessPasswordLoginTestApp(h *AuthHandler) *fiber.App {
|
||||
return app
|
||||
}
|
||||
|
||||
type passwordLoginUserRepo struct {
|
||||
usersByID map[string]domain.User
|
||||
}
|
||||
|
||||
func (r *passwordLoginUserRepo) Create(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (r *passwordLoginUserRepo) Update(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (r *passwordLoginUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
func (r *passwordLoginUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
if r != nil {
|
||||
if user, ok := r.usersByID[id]; ok {
|
||||
return &user, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
func (r *passwordLoginUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) Delete(ctx context.Context, id string) error { return nil }
|
||||
func (r *passwordLoginUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
|
||||
return nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *passwordLoginUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func mustHeadlessRSAJWK(t *testing.T) (*rsa.PrivateKey, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1947,6 +2003,88 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordLogin_ArchivedUserRejected(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "archived@example.com", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "archived-jwt"},
|
||||
Subject: "archived-user-id",
|
||||
}, nil)
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "archived@example.com").Return("archived-user-id", nil)
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
UserRepo: &passwordLoginUserRepo{usersByID: map[string]domain.User{
|
||||
"archived-user-id": {
|
||||
ID: "archived-user-id",
|
||||
Email: "archived@example.com",
|
||||
Name: "Archived User",
|
||||
Status: domain.UserStatusArchived,
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
app := newAuthLoginTestApp(h)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"loginId": "archived@example.com",
|
||||
"password": "password",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUserActivityAllowedByStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "active allowed", status: domain.UserStatusActive},
|
||||
{name: "temporary leave allowed", status: domain.UserStatusTemporaryLeave},
|
||||
{name: "baron guest allowed", status: domain.UserStatusBaronGuest},
|
||||
{name: "suspended rejected", status: domain.UserStatusSuspended, wantErr: true},
|
||||
{name: "preboarding rejected", status: domain.UserStatusPreboarding, wantErr: true},
|
||||
{name: "extended leave rejected", status: domain.UserStatusExtendedLeave, wantErr: true},
|
||||
{name: "archived rejected", status: domain.UserStatusArchived, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
UserRepo: &passwordLoginUserRepo{usersByID: map[string]domain.User{
|
||||
"user-id": {
|
||||
ID: "user-id",
|
||||
Email: "user@example.com",
|
||||
Name: "User",
|
||||
Status: tc.status,
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
err := h.ensureUserActivityAllowed(context.Background(), "user-id")
|
||||
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다"))
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -415,6 +416,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
tenants := filterTenantCSVDescendants(allTenants, parentID)
|
||||
sortTenantsByInputOrder(tenants)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := csv.NewWriter(&buf)
|
||||
@@ -483,6 +485,15 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
||||
return c.Send(buf.Bytes())
|
||||
}
|
||||
|
||||
func sortTenantsByInputOrder(tenants []domain.Tenant) {
|
||||
sort.SliceStable(tenants, func(i, j int) bool {
|
||||
if tenants[i].CreatedAt.Equal(tenants[j].CreatedAt) {
|
||||
return tenants[i].ID < tenants[j].ID
|
||||
}
|
||||
return tenants[i].CreatedAt.Before(tenants[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func filterTenantCSVDescendants(tenants []domain.Tenant, parentID string) []domain.Tenant {
|
||||
parentID = strings.TrimSpace(parentID)
|
||||
if parentID == "" {
|
||||
@@ -2231,7 +2242,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
|
||||
users := append(usersByID, usersBySlug...)
|
||||
users = append(users, usersByAppointment...)
|
||||
for _, user := range users {
|
||||
if seen[user.ID] || user.Status != domain.UserStatusActive {
|
||||
if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) {
|
||||
continue
|
||||
}
|
||||
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
|
||||
|
||||
@@ -616,6 +616,66 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "user-archived",
|
||||
Email: "archived@example.com",
|
||||
Name: "보관 사용자",
|
||||
Status: domain.UserStatusArchived,
|
||||
TenantID: parent("dept-platform"),
|
||||
CompanyCode: "platform",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "user-suspended",
|
||||
Email: "suspended@example.com",
|
||||
Name: "정지 사용자",
|
||||
Status: domain.UserStatusSuspended,
|
||||
TenantID: parent("dept-platform"),
|
||||
CompanyCode: "platform",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "user-temporary-leave",
|
||||
Email: "temporary-leave@example.com",
|
||||
Name: "단기휴무 사용자",
|
||||
Status: domain.UserStatusTemporaryLeave,
|
||||
TenantID: parent("dept-platform"),
|
||||
CompanyCode: "platform",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "user-preboarding",
|
||||
Email: "preboarding@example.com",
|
||||
Name: "입사대기 사용자",
|
||||
Status: domain.UserStatusPreboarding,
|
||||
TenantID: parent("dept-platform"),
|
||||
CompanyCode: "platform",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "user-baron-guest",
|
||||
Email: "baron-guest@example.com",
|
||||
Name: "Baron Guest",
|
||||
Status: domain.UserStatusBaronGuest,
|
||||
TenantID: parent("dept-platform"),
|
||||
CompanyCode: "platform",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "user-extended-leave",
|
||||
Email: "extended-leave@example.com",
|
||||
Name: "장기휴직 사용자",
|
||||
Status: domain.UserStatusExtendedLeave,
|
||||
TenantID: parent("dept-platform"),
|
||||
CompanyCode: "platform",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
usersBySlug := []domain.User{
|
||||
{ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now},
|
||||
@@ -668,7 +728,7 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
||||
require.NotContains(t, got, "users")
|
||||
deptPlatform := tenantsPayload[2].(map[string]any)
|
||||
platformMembers := deptPlatform["members"].([]any)
|
||||
require.Len(t, platformMembers, 1)
|
||||
require.Len(t, platformMembers, 3)
|
||||
firstUser := platformMembers[0].(map[string]any)
|
||||
require.NotContains(t, firstUser, "id")
|
||||
require.NotContains(t, firstUser, "phone")
|
||||
@@ -703,6 +763,12 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
||||
require.NotContains(t, toJSONString(t, got), "directUserIds")
|
||||
require.NotContains(t, toJSONString(t, got), "private-team")
|
||||
require.NotContains(t, toJSONString(t, got), "root-other")
|
||||
require.NotContains(t, toJSONString(t, got), "archived@example.com")
|
||||
require.Contains(t, toJSONString(t, got), "suspended@example.com")
|
||||
require.Contains(t, toJSONString(t, got), "temporary-leave@example.com")
|
||||
require.NotContains(t, toJSONString(t, got), "preboarding@example.com")
|
||||
require.NotContains(t, toJSONString(t, got), "baron-guest@example.com")
|
||||
require.NotContains(t, toJSONString(t, got), "extended-leave@example.com")
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
|
||||
@@ -963,6 +1029,37 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T)
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ExportTenantsCSV_OrdersByInputOrder(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
h := &TenantHandler{Service: mockSvc}
|
||||
|
||||
app.Get("/tenants/export", h.ExportTenantsCSV)
|
||||
|
||||
oldest := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC)
|
||||
middle := oldest.Add(time.Hour)
|
||||
newest := oldest.Add(2 * time.Hour)
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "newest", Name: "Newest Tenant", Type: domain.TenantTypeCompany, Slug: "newest", CreatedAt: newest},
|
||||
{ID: "middle", Name: "Middle Tenant", Type: domain.TenantTypeCompany, Slug: "middle", CreatedAt: middle},
|
||||
{ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
|
||||
resp, _ := app.Test(req)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Len(t, lines, 4)
|
||||
assert.Contains(t, lines[1], "oldest,Oldest Tenant")
|
||||
assert.Contains(t, lines[2], "middle,Middle Tenant")
|
||||
assert.Contains(t, lines[3], "newest,Newest Tenant")
|
||||
mockSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
|
||||
@@ -1644,10 +1644,9 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
|
||||
state := identity.State
|
||||
if req.Status != nil {
|
||||
if *req.Status == "active" {
|
||||
state = "active"
|
||||
} else {
|
||||
state = "inactive"
|
||||
state = normalizeKratosState(req.Status)
|
||||
if state == "" {
|
||||
state = identity.State
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1667,7 +1666,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
localUser.Role = *req.Role
|
||||
}
|
||||
if req.Status != nil {
|
||||
localUser.Status = *req.Status
|
||||
localUser.Status = normalizeStatus(*req.Status)
|
||||
}
|
||||
if req.Department != nil {
|
||||
localUser.Department = *req.Department
|
||||
@@ -2610,20 +2609,7 @@ func formatTime(value time.Time) string {
|
||||
}
|
||||
|
||||
func normalizeStatus(state string) string {
|
||||
state = strings.ToLower(strings.TrimSpace(state))
|
||||
if state == "blocked" {
|
||||
return domain.UserStatusInactive
|
||||
}
|
||||
if state == domain.UserStatusInactive ||
|
||||
state == domain.UserStatusSuspended ||
|
||||
state == domain.UserStatusLeaveOfAbsence ||
|
||||
state == domain.UserStatusActive {
|
||||
return state
|
||||
}
|
||||
if state == "" {
|
||||
return domain.UserStatusActive
|
||||
}
|
||||
return state
|
||||
return domain.NormalizeUserStatus(state)
|
||||
}
|
||||
|
||||
func normalizeKratosState(status *string) string {
|
||||
@@ -2637,9 +2623,13 @@ func normalizeKratosState(status *string) string {
|
||||
if value == domain.UserStatusActive {
|
||||
return domain.UserStatusActive
|
||||
}
|
||||
if value == domain.UserStatusInactive ||
|
||||
value == domain.UserStatusSuspended ||
|
||||
value == domain.UserStatusLeaveOfAbsence {
|
||||
normalized := domain.NormalizeUserStatus(value)
|
||||
if normalized == domain.UserStatusPreboarding ||
|
||||
normalized == domain.UserStatusSuspended ||
|
||||
normalized == domain.UserStatusTemporaryLeave ||
|
||||
normalized == domain.UserStatusBaronGuest ||
|
||||
normalized == domain.UserStatusExtendedLeave ||
|
||||
normalized == domain.UserStatusArchived {
|
||||
return domain.UserStatusInactive
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -1017,7 +1017,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
|
||||
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
|
||||
assert.Len(t, worksmobile.upserts, 1)
|
||||
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
|
||||
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
|
||||
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
|
||||
})
|
||||
|
||||
t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -45,24 +46,21 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Resolve email conflicts: If another user in the local DB has this email but a different ID,
|
||||
// we must remove the old local record because Kratos is the source of truth for ID <-> Email mapping.
|
||||
var existing domain.User
|
||||
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
|
||||
if existing.ID != user.ID {
|
||||
// Delete associated login IDs first to prevent FK constraint violation
|
||||
if strings.EqualFold(strings.TrimSpace(existing.Status), domain.UserStatusArchived) {
|
||||
return fmt.Errorf("email is reserved by archived user: %s", user.Email)
|
||||
}
|
||||
if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Different ID holds this email locally. Hard delete the old record to avoid constraint violation.
|
||||
if err := tx.Unscoped().Delete(&domain.User{}, "id = ?", existing.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Perform Upsert based on ID.
|
||||
// In GORM v2, true upsert requires Create() with OnConflict on the primary key.
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
|
||||
@@ -53,6 +53,36 @@ func TestUserRepository(t *testing.T) {
|
||||
assert.Equal(t, "010-1234-5678", found.Phone)
|
||||
})
|
||||
|
||||
t.Run("Update preserves archived email reservation", func(t *testing.T) {
|
||||
testDB.Exec("DELETE FROM user_login_ids")
|
||||
testDB.Exec("DELETE FROM users")
|
||||
|
||||
archived := &domain.User{
|
||||
ID: "00000000-0000-0000-0000-00000000a001",
|
||||
Email: "reserved@example.com",
|
||||
Name: "Archived User",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusArchived,
|
||||
}
|
||||
replacement := &domain.User{
|
||||
ID: "00000000-0000-0000-0000-00000000a002",
|
||||
Email: "reserved@example.com",
|
||||
Name: "Replacement User",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, archived))
|
||||
|
||||
err := repo.Update(ctx, replacement)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "archived user")
|
||||
found, err := repo.FindByEmail(ctx, archived.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, archived.ID, found.ID)
|
||||
require.Equal(t, domain.UserStatusArchived, found.Status)
|
||||
})
|
||||
|
||||
t.Run("List Users with Search", func(t *testing.T) {
|
||||
// Add some users
|
||||
_ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"})
|
||||
|
||||
@@ -384,14 +384,7 @@ func userGroupTraitStringArray(traits map[string]interface{}, key string) []stri
|
||||
}
|
||||
|
||||
func userGroupIdentityStatus(state string) string {
|
||||
switch state {
|
||||
case "", "active":
|
||||
return domain.UserStatusActive
|
||||
case "inactive":
|
||||
return domain.UserStatusInactive
|
||||
default:
|
||||
return state
|
||||
}
|
||||
return domain.NormalizeUserStatus(state)
|
||||
}
|
||||
|
||||
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
|
||||
|
||||
@@ -145,14 +145,9 @@ func kratosProjectionTraitStringArray(traits map[string]interface{}, key string)
|
||||
}
|
||||
|
||||
func normalizeProjectionStatus(state string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "blocked", domain.UserStatusInactive:
|
||||
return domain.UserStatusInactive
|
||||
case domain.UserStatusSuspended:
|
||||
return domain.UserStatusSuspended
|
||||
case domain.UserStatusLeaveOfAbsence:
|
||||
return domain.UserStatusLeaveOfAbsence
|
||||
default:
|
||||
normalized := domain.NormalizeUserStatus(state)
|
||||
if normalized == "" {
|
||||
return domain.UserStatusActive
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -96,3 +96,16 @@ func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testin
|
||||
assert.Empty(t, repo.replacedUsers)
|
||||
kratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
|
||||
user := MapKratosIdentityToLocalUser(KratosIdentity{
|
||||
ID: "00000000-0000-0000-0000-000000000201",
|
||||
State: domain.UserStatusArchived,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "archived@example.com",
|
||||
"name": "Archived User",
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, domain.UserStatusArchived, user.Status)
|
||||
}
|
||||
|
||||
@@ -924,6 +924,7 @@ type fakeWorksmobileDirectoryClient struct {
|
||||
deletedUsers []string
|
||||
activeUsers []string
|
||||
suspendedUsers []string
|
||||
users []WorksmobileRemoteUser
|
||||
orgUnitMatchKeys []string
|
||||
groups []WorksmobileRemoteGroup
|
||||
}
|
||||
@@ -1029,7 +1030,7 @@ func (f *fakeWorksmobileDirectoryClient) SetUserActive(ctx context.Context, user
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
|
||||
return nil, nil
|
||||
return f.users, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) {
|
||||
|
||||
@@ -402,8 +402,12 @@ func shuffleBytes(values []byte) {
|
||||
}
|
||||
|
||||
func WorksmobileUserStatusAction(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case domain.UserStatusInactive, domain.UserStatusSuspended, domain.UserStatusLeaveOfAbsence:
|
||||
normalized := domain.NormalizeUserStatus(status)
|
||||
if domain.IsWorksDeprovisionUserStatus(normalized) {
|
||||
return domain.WorksmobileActionDelete
|
||||
}
|
||||
switch normalized {
|
||||
case domain.UserStatusSuspended:
|
||||
return WorksmobileUserActionSuspend
|
||||
default:
|
||||
return WorksmobileUserActionUpsert
|
||||
|
||||
@@ -371,9 +371,13 @@ func containsAny(value string, candidates string) bool {
|
||||
|
||||
func TestWorksmobileUserStatusAction(t *testing.T) {
|
||||
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusActive))
|
||||
require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusInactive))
|
||||
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusTemporaryLeave))
|
||||
require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusSuspended))
|
||||
require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence))
|
||||
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusExtendedLeave))
|
||||
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusBaronGuest))
|
||||
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusArchived))
|
||||
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence))
|
||||
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction("baron_only"))
|
||||
}
|
||||
|
||||
func TestValidateWorksmobileExternalKeyRejectsUnsupportedCharacters(t *testing.T) {
|
||||
|
||||
@@ -215,6 +215,7 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena
|
||||
if err != nil {
|
||||
return WorksmobileBackfillDryRun{}, err
|
||||
}
|
||||
users = worksmobileSyncScopeUsers(users)
|
||||
_ = s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceOrgUnit,
|
||||
ResourceID: root.ID,
|
||||
@@ -366,6 +367,12 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
||||
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
|
||||
}
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
return nil, errors.New("target user status is excluded from Worksmobile sync")
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
*user,
|
||||
@@ -510,6 +517,13 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if domain.IsWorksDeprovisionUserStatus(user.Status) {
|
||||
_, err := s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, root.ID)
|
||||
return err
|
||||
}
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
return nil
|
||||
}
|
||||
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
||||
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
|
||||
user,
|
||||
@@ -545,16 +559,32 @@ func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context,
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
|
||||
_, err = s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) enqueueUserDelete(ctx context.Context, user domain.User, dedupeKey string, rootID string) (*domain.WorksmobileOutbox, error) {
|
||||
payload := domain.JSONMap{
|
||||
"userExternalKey": user.ID,
|
||||
"loginEmail": user.Email,
|
||||
}
|
||||
if rootID != "" {
|
||||
payload["tenantRootId"] = rootID
|
||||
}
|
||||
if status := domain.NormalizeUserStatus(user.Status); status != "" {
|
||||
payload["baronStatus"] = status
|
||||
}
|
||||
item := &domain.WorksmobileOutbox{
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: user.ID,
|
||||
Action: domain.WorksmobileActionDelete,
|
||||
DedupeKey: "user:delete:" + user.ID,
|
||||
Payload: domain.JSONMap{
|
||||
"userExternalKey": user.ID,
|
||||
"loginEmail": user.Email,
|
||||
},
|
||||
})
|
||||
DedupeKey: dedupeKey,
|
||||
Payload: payload,
|
||||
}
|
||||
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *worksmobileSyncService) hanmacRoot(ctx context.Context, tenantID string) (*domain.Tenant, error) {
|
||||
@@ -803,8 +833,18 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
}
|
||||
localByID := map[string]domain.User{}
|
||||
matchedRemoteIDs := map[string]bool{}
|
||||
excludedLocalIDs := map[string]bool{}
|
||||
result := make([]WorksmobileComparisonItem, 0)
|
||||
for _, user := range localUsers {
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
excludedLocalIDs[user.ID] = true
|
||||
if remote, ok := remoteByExternalID[user.ID]; ok {
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
} else if remote, ok := remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]; ok {
|
||||
matchedRemoteIDs[remote.ID] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
localByID[user.ID] = user
|
||||
remote, matched := remoteByExternalID[user.ID]
|
||||
if !matched {
|
||||
@@ -848,6 +888,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
|
||||
if matchedRemoteIDs[remote.ID] {
|
||||
continue
|
||||
}
|
||||
if excludedLocalIDs[remote.ExternalID] {
|
||||
continue
|
||||
}
|
||||
if remote.ExternalID == "" {
|
||||
result = append(result, WorksmobileComparisonItem{
|
||||
ResourceType: "USER",
|
||||
@@ -1094,3 +1137,17 @@ func worksmobileTenantParentSlug(tenant domain.Tenant, tenantByID map[string]dom
|
||||
}
|
||||
return strings.TrimSpace(tenantByID[parentID].Slug)
|
||||
}
|
||||
|
||||
func worksmobileSyncScopeUsers(users []domain.User) []domain.User {
|
||||
if len(users) == 0 {
|
||||
return users
|
||||
}
|
||||
filtered := make([]domain.User, 0, len(users))
|
||||
for _, user := range users {
|
||||
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, user)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -101,6 +101,51 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
|
||||
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
target := domain.User{
|
||||
ID: "archived-user",
|
||||
Email: "archived@samaneng.com",
|
||||
Name: "Archived",
|
||||
Status: domain.UserStatusArchived,
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
|
||||
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Equal(t, domain.WorksmobileActionDelete, item.Action)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
|
||||
err = service.EnqueueUserUpsertIfInScope(context.Background(), target)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, outboxRepo.created, 2)
|
||||
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[1].Action)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) {
|
||||
t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1")
|
||||
root := domain.Tenant{
|
||||
@@ -759,6 +804,88 @@ func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T)
|
||||
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
companyID := "company-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
company := domain.Tenant{
|
||||
ID: companyID,
|
||||
Name: "계열사",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
archived := domain.User{
|
||||
ID: "archived-user",
|
||||
Email: "archived@samaneng.com",
|
||||
Name: "Archived",
|
||||
TenantID: &companyID,
|
||||
Status: domain.UserStatusArchived,
|
||||
}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
|
||||
&fakeWorksmobileUserRepo{byTenant: []domain.User{archived}},
|
||||
&fakeWorksmobileOutboxRepo{},
|
||||
&fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{
|
||||
ID: "works-archived",
|
||||
ExternalID: archived.ID,
|
||||
Email: archived.Email,
|
||||
}}},
|
||||
)
|
||||
|
||||
comparison, err := service.GetComparison(context.Background(), rootID, true)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, comparison.Users)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
|
||||
rootID := "root-tenant"
|
||||
companyID := "company-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "한맥가족",
|
||||
}
|
||||
company := domain.Tenant{
|
||||
ID: companyID,
|
||||
Name: "계열사",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
}
|
||||
active := domain.User{
|
||||
ID: "active-user",
|
||||
Email: "active@samaneng.com",
|
||||
Name: "Active",
|
||||
TenantID: &companyID,
|
||||
Status: domain.UserStatusActive,
|
||||
}
|
||||
archived := domain.User{
|
||||
ID: "archived-user",
|
||||
Email: "archived@samaneng.com",
|
||||
Name: "Archived",
|
||||
TenantID: &companyID,
|
||||
Status: domain.UserStatusArchived,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
|
||||
&fakeWorksmobileUserRepo{byTenant: []domain.User{active, archived}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
dryRun, err := service.EnqueueBackfillDryRun(context.Background(), rootID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, dryRun.UserCount)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
|
||||
}
|
||||
|
||||
type fakeWorksmobileTenantService struct {
|
||||
tenants map[string]domain.Tenant
|
||||
list []domain.Tenant
|
||||
|
||||
@@ -156,6 +156,7 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
|
||||
- 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다.
|
||||
- `tree`는 같은 tenant 집합을 계층 구조로 제공하고, `tenants`는 slug/id lookup용 flat array로 제공한다.
|
||||
- 사용자 목록은 top-level `users`가 아니라 각 tenant의 `members`에 직접 소속 사용자 배열로 제공한다.
|
||||
- `members`에는 `active`, `temporary_leave`, `suspended` 사용자만 포함한다. `preboarding`, `baron_guest`, `extended_leave`, `archived` 사용자는 email/local-part 선점 대상일 수 있지만 일반 조직도와 외부 연동 조직 조회에는 노출하지 않는다.
|
||||
- tenant 세부 분류는 `type`과 `orgUnitType`으로 구분한다. `orgUnitType`은 tenant `config.orgUnitType` 값이 있을 때만 포함한다.
|
||||
- 기본 사용자 응답은 로그인 claim 수준의 표시 정보만 제공한다. UUID, role/status, metadata, 생성/수정 시각은 기본 응답에 포함하지 않는다.
|
||||
- 사용자 UUID와 전화번호가 필요한 연동은 `includeUserIds=true`를 사용한다. 이때 각 tenant `members[].id`와 `members[].phone`만 추가된다.
|
||||
|
||||
@@ -28,8 +28,11 @@ Baron SSO는 Ory Stack을 SoT로 두고, PostgreSQL은 read-model 및 비즈니
|
||||
한맥가족 이메일 정책은 이미 #668에서 다음 방향으로 구현되어 있습니다.
|
||||
|
||||
- `hanmac-family` root tenant와 descendant subtree에서 email local-part를 unique로 강제합니다.
|
||||
- local-part unique 검사는 `archived`를 포함한 모든 사용자 상태를 대상으로 합니다.
|
||||
- 단건 생성은 중복 시 `409 Conflict`로 차단합니다.
|
||||
- bulk import는 `@domain` 입력 시 이름 기반 local-part를 제안하고, 생성 직전 재검증합니다.
|
||||
- `preboarding`, `baron_guest`, `extended_leave`, `archived` 사용자는 Worksmobile 구성원 생성/갱신/backfill 대상에서 제외합니다.
|
||||
- `baron_guest`, `extended_leave`, `archived` 상태로 전환된 사용자는 기존 Worksmobile 계정 delete/deprovision 대상입니다.
|
||||
|
||||
## 웍스모바일 Directory API 확인 사항
|
||||
|
||||
@@ -335,9 +338,12 @@ Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant deta
|
||||
|
||||
- 후보 위치: `UserHandler.UpdateUser`에서 Kratos update와 local DB sync 후
|
||||
- `email`, `name`, `phone`, `companyCode`, `tenant_id`, `metadata.additionalAppointments` 변경 시 `USER UPSERT` enqueue
|
||||
- `inactive`는 Worksmobile suspend로 동기화합니다.
|
||||
- `suspended`는 Worksmobile suspend로 동기화합니다.
|
||||
- `temporary_leave`는 Worksmobile 계정을 유지합니다.
|
||||
- `preboarding`은 Worksmobile 계정을 생성하지 않습니다.
|
||||
- `baron_guest`, `extended_leave`, `archived`는 Worksmobile delete/deprovision으로 동기화합니다.
|
||||
- Baron user delete는 Worksmobile delete로 동기화합니다.
|
||||
- `leave-of-absence`는 필요하지만 orgfront/Baron user status model 확장이 선행되어야 하므로 별도 scope로 분리합니다.
|
||||
- 기존 `inactive` 입력은 `preboarding`, `leave_of_absence` 입력은 `temporary_leave`, `baron_only` 입력은 `baron_guest`로 호환 처리합니다.
|
||||
|
||||
## 테스트 전략
|
||||
|
||||
@@ -410,9 +416,11 @@ Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant deta
|
||||
- Developer Console External Key Mapping이 비어 있는 기존 조직/구성원은 CSV mapping 또는 API external-key update가 선행되어야 합니다.
|
||||
|
||||
6. 상태 정책
|
||||
- Baron `inactive`는 Worksmobile suspend로 동기화합니다.
|
||||
- Baron `active`, `temporary_leave`, `suspended`는 Worksmobile 구성원 비교 및 backfill scope에 포함합니다.
|
||||
- Baron `suspended`는 Worksmobile suspend로 동기화합니다.
|
||||
- Baron `preboarding`은 Worksmobile 계정을 생성하지 않습니다.
|
||||
- Baron `baron_guest`, `extended_leave`, `archived`는 Worksmobile delete/deprovision으로 동기화합니다.
|
||||
- Baron delete는 Worksmobile delete로 동기화합니다.
|
||||
- leave-of-absence는 별도 user 상태 확장 이슈로 분리합니다.
|
||||
- 직급/직책/사용자 유형 External Key sync는 이번 scope에서 제외합니다.
|
||||
|
||||
7. adminfront 권한 경계
|
||||
|
||||
@@ -1733,6 +1733,7 @@ title = "Affiliation & Organization Info"
|
||||
[ui.admin.users.list]
|
||||
add = "User Add"
|
||||
bulk_import = "Bulk Import"
|
||||
change_status = "Change {{name}} status"
|
||||
empty = "No users found."
|
||||
fetch_error = "Failed to load the user list."
|
||||
search_placeholder = "Search Placeholder"
|
||||
@@ -1977,12 +1978,19 @@ system = "System"
|
||||
|
||||
[ui.common.status]
|
||||
active = "Active"
|
||||
archived = "Archived"
|
||||
baron_guest = "Baron Guest"
|
||||
blocked = "ui.common.status.blocked"
|
||||
extended_leave = "Extended Leave"
|
||||
failure = "Failure"
|
||||
inactive = "Inactive"
|
||||
leave_of_absence = "Leave of absence"
|
||||
ok = "Ok"
|
||||
pending = "Pending"
|
||||
preboarding = "Preboarding"
|
||||
success = "Success"
|
||||
suspended = "Suspended"
|
||||
temporary_leave = "Temporary Leave"
|
||||
|
||||
[ui.dev]
|
||||
brand = "Brand"
|
||||
|
||||
@@ -2197,6 +2197,7 @@ title = "소속 및 조직 정보"
|
||||
[ui.admin.users.list]
|
||||
add = "사용자 추가"
|
||||
bulk_import = "일괄 임포트"
|
||||
change_status = "{{name}} 상태 변경"
|
||||
empty = "검색 결과가 없습니다."
|
||||
fetch_error = "사용자 목록 조회에 실패했습니다."
|
||||
search_placeholder = "이름 또는 이메일 검색..."
|
||||
@@ -2441,12 +2442,19 @@ system = "System"
|
||||
|
||||
[ui.common.status]
|
||||
active = "활성"
|
||||
archived = "보관됨"
|
||||
baron_guest = "Baron 게스트"
|
||||
blocked = "ui.common.status.blocked"
|
||||
extended_leave = "장기휴직"
|
||||
failure = "실패"
|
||||
inactive = "비활성"
|
||||
leave_of_absence = "휴직"
|
||||
ok = "정상"
|
||||
pending = "준비 중"
|
||||
preboarding = "입사대기"
|
||||
success = "성공"
|
||||
suspended = "정지"
|
||||
temporary_leave = "단기휴무"
|
||||
|
||||
[ui.dev]
|
||||
brand = "Baron 로그인"
|
||||
|
||||
@@ -2076,6 +2076,7 @@ title = ""
|
||||
[ui.admin.users.list]
|
||||
add = ""
|
||||
bulk_import = ""
|
||||
change_status = ""
|
||||
empty = ""
|
||||
fetch_error = ""
|
||||
search_placeholder = ""
|
||||
@@ -2320,12 +2321,19 @@ system = ""
|
||||
|
||||
[ui.common.status]
|
||||
active = ""
|
||||
archived = ""
|
||||
baron_guest = ""
|
||||
blocked = ""
|
||||
extended_leave = ""
|
||||
failure = ""
|
||||
inactive = ""
|
||||
leave_of_absence = ""
|
||||
ok = ""
|
||||
pending = ""
|
||||
preboarding = ""
|
||||
success = ""
|
||||
suspended = ""
|
||||
temporary_leave = ""
|
||||
|
||||
[ui.dev]
|
||||
brand = ""
|
||||
|
||||
Reference in New Issue
Block a user