1
0
forked from baron/baron-sso

Merge pull request 'feat/fam-tanent' (#511) from feat/fam-tanent into dev

Reviewed-on: baron/baron-sso#511
This commit is contained in:
2026-04-03 15:20:01 +09:00
43 changed files with 6537 additions and 4320 deletions

View File

@@ -51,7 +51,11 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "24"
cache: "npm"
cache-dependency-path: |
adminfront/package-lock.json
devfront/package-lock.json
- name: i18n resource check - name: i18n resource check
run: | run: |
@@ -308,10 +312,25 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "24"
cache: "npm" cache: "npm"
cache-dependency-path: userfront-e2e/package-lock.json cache-dependency-path: userfront-e2e/package-lock.json
- name: Get Playwright version
id: playwright-version
run: |
cd userfront-e2e
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Setup Flutter - name: Setup Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
@@ -524,11 +543,28 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "24"
cache: "npm" cache: "npm"
cache-dependency-path: adminfront/package-lock.json cache-dependency-path: adminfront/package-lock.json
- name: Get Playwright version
id: playwright-version
run: |
cd adminfront
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Run adminfront tests - name: Run adminfront tests
env:
PLAYWRIGHT_WORKERS: 2
run: | run: |
scripts/run_adminfront_ci_tests.sh adminfront-tests scripts/run_adminfront_ci_tests.sh adminfront-tests
@@ -602,10 +638,25 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "24"
cache: "npm" cache: "npm"
cache-dependency-path: devfront/package-lock.json cache-dependency-path: devfront/package-lock.json
- name: Get Playwright version
id: playwright-version
run: |
cd devfront
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install devfront dependencies - name: Install devfront dependencies
run: | run: |
mkdir -p reports mkdir -p reports
@@ -666,6 +717,8 @@ jobs:
fi fi
- name: Run devfront tests - name: Run devfront tests
env:
PLAYWRIGHT_WORKERS: 2
run: | run: |
mkdir -p reports mkdir -p reports
set +e set +e

View File

@@ -0,0 +1,474 @@
> adminfront@0.0.0 i18n-scan
> cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js
ko.toml에 없는 키
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
- ui.admin.users.list.table.msg.admin.users.detail.no_history
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
- ui.admin.users.list.table.msg.common.copied
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.email
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
- ui.admin.users.list.table.ui.admin.users.detail.history_title
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
- ui.admin.users.list.table.ui.admin.users.detail.password_done
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
- ui.admin.users.list.table.ui.common.generate
- ui.admin.users.list.table.ui.common.status.blocked
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
- ui.admin.users.list.table.ui.dev.clients.registry.description
- ui.admin.users.list.table.ui.dev.clients.scopes.email
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
- ui.admin.users.list.table.ui.dev.session.refresh
- ui.admin.users.list.table.ui.dev.session.refreshing
en.toml에 없는 키
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
- ui.admin.users.list.table.msg.admin.users.detail.no_history
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
- ui.admin.users.list.table.msg.common.copied
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.email
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
- ui.admin.users.list.table.ui.admin.users.detail.history_title
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
- ui.admin.users.list.table.ui.admin.users.detail.password_done
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
- ui.admin.users.list.table.ui.common.generate
- ui.admin.users.list.table.ui.common.status.blocked
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
- ui.admin.users.list.table.ui.dev.clients.registry.description
- ui.admin.users.list.table.ui.dev.clients.scopes.email
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
- ui.admin.users.list.table.ui.dev.session.refresh
- ui.admin.users.list.table.ui.dev.session.refreshing
template.toml에 없는 코드 사용 키
- msg.admin.users.detail.history_desc
- msg.admin.users.detail.no_history
- msg.admin.users.detail.no_tenants
- msg.admin.users.detail.reset_auto_desc
- msg.admin.users.detail.security_desc
- msg.admin.users.detail.tenant_slug_help
- msg.admin.users.detail.tenants_desc
- msg.common.copied
- msg.dev.clients.general.public_key.allowed_algorithms_tooltip
- msg.dev.clients.general.public_key.cache.missing_algorithm_badge
- msg.dev.clients.general.public_key.cache.missing_algorithm_reason
- msg.dev.clients.general.public_key.cache.missing_algorithms_help
- msg.dev.clients.general.public_key.cache.missing_algorithms_title
- msg.dev.clients.general.public_key.cache.parsed_keys_empty
- msg.dev.clients.general.public_key.cache.parsed_keys_help
- msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
- msg.dev.clients.general.public_key.cache_empty
- msg.dev.clients.general.public_key.cache_help
- msg.dev.clients.general.public_key.cache_refresh_failed
- msg.dev.clients.general.public_key.cache_refreshed
- msg.dev.clients.general.public_key.cache_revoke_confirm
- msg.dev.clients.general.public_key.cache_revoke_failed
- msg.dev.clients.general.public_key.cache_revoked
- msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
- msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
- ui.admin.users.create.form.is_login_id
- ui.admin.users.detail.form.email
- ui.admin.users.detail.form.is_login_id
- ui.admin.users.detail.form.role_rp_admin
- ui.admin.users.detail.form.tenant_slug
- ui.admin.users.detail.generate_button
- ui.admin.users.detail.history_title
- ui.admin.users.detail.manual_confirm
- ui.admin.users.detail.manual_password
- ui.admin.users.detail.password_done
- ui.admin.users.detail.reset_auto
- ui.admin.users.detail.reset_execute
- ui.admin.users.detail.reset_manual
- ui.admin.users.detail.save_tenants
- ui.admin.users.detail.tabs.info
- ui.admin.users.detail.tabs.security
- ui.admin.users.detail.tabs.tenants
- ui.admin.users.detail.updated_at
- ui.dev.clients.general.public_key.allowed_algorithms_info
- ui.dev.clients.general.public_key.cache.cached_at
- ui.dev.clients.general.public_key.cache.error
- ui.dev.clients.general.public_key.cache.expires_at
- ui.dev.clients.general.public_key.cache.failures
- ui.dev.clients.general.public_key.cache.kids
- ui.dev.clients.general.public_key.cache.last_checked_at
- ui.dev.clients.general.public_key.cache.last_success
- ui.dev.clients.general.public_key.cache.parsed_key_n
- ui.dev.clients.general.public_key.cache.parsed_keys
- ui.dev.clients.general.public_key.cache.status
- ui.dev.clients.general.public_key.cache.title
- ui.dev.clients.general.public_key.cache.uri
- ui.dev.clients.general.public_key.revoke_cache
코드에서 사용되지 않는 키
- err.backend.authorization_pending
- err.backend.bad_request
- err.backend.conflict
- err.backend.expired_token
- err.backend.forbidden
- err.backend.internal_error
- err.backend.invalid_code
- err.backend.invalid_or_expired_code
- err.backend.invalid_session
- err.backend.invalid_session_reference
- err.backend.not_found
- err.backend.not_supported
- err.backend.password_or_email_mismatch
- err.backend.rate_limited
- err.backend.service_unavailable
- err.backend.slow_down
- msg.admin.groups.create.description
- msg.admin.groups.create.title
- msg.admin.groups.list.import_error
- msg.admin.groups.list.import_success
- msg.admin.header.subtitle
- msg.admin.idp_env_prod
- msg.admin.notice.idp_policy
- msg.admin.notice.scope
- msg.admin.overview.idp_fallback
- msg.admin.overview.idp_primary
- msg.admin.overview.playbook.description
- msg.admin.overview.playbook.idp_body
- msg.admin.overview.playbook.idp_title
- msg.admin.overview.playbook.tenant_body
- msg.admin.overview.playbook.tenant_title
- msg.admin.overview.quick_links.description
- msg.admin.overview.summary.audit_events_24h
- msg.admin.overview.summary.oidc_clients
- msg.admin.overview.summary.policy_gate
- msg.admin.overview.summary.total_tenants
- msg.admin.scope_admin
- msg.admin.session_ttl
- msg.admin.tenant_headers
- msg.admin.users.create.form.login_id_help
- msg.admin.users.detail.delete_error
- msg.admin.users.detail.password_generated_help
- msg.admin.users.detail.reset_password_confirm
- msg.admin.users.detail.security.password_hint
- msg.admin.users.detail.update_success
- msg.common.copied_to_clipboard
- msg.dev.audit.forbidden
- msg.dev.clients.general.public_key.auth_method_client_secret_basic_help
- msg.dev.clients.general.public_key.auth_method_none_help
- msg.dev.clients.general.public_key.auth_method_private_key_jwt_help
- msg.dev.clients.general.public_key.guide_example
- msg.dev.clients.general.public_key.guide_intro
- msg.dev.clients.general.public_key.guide_step_1
- msg.dev.clients.general.public_key.guide_step_2
- msg.dev.clients.general.public_key.guide_step_3
- msg.dev.clients.general.public_key.jwks_inline_help
- msg.dev.clients.general.public_key.request_object_alg_help
- msg.dev.clients.general.public_key.source_help
- msg.dev.clients.general.public_key.validation.headless_requires_alg
- msg.dev.clients.general.public_key.validation.headless_requires_private_key_jwt
- msg.dev.clients.general.public_key.validation.headless_requires_public_key
- msg.dev.clients.general.public_key.validation.invalid_jwks_inline
- msg.dev.clients.general.public_key.validation.missing_jwks_inline
- msg.dev.clients.general.public_key.validation.private_key_jwt_requires_public_key
- msg.userfront.signup.privacy_full
- msg.userfront.signup.tos_full
- non.existent.key
- test.key
- ui.admin.api_keys.list.breadcrumb.list
- ui.admin.api_keys.list.breadcrumb.section
- ui.admin.audit.breadcrumb.logs
- ui.admin.audit.breadcrumb.section
- ui.admin.groups.import_csv
- ui.admin.overview.kicker
- ui.admin.overview.playbook.title
- ui.admin.overview.quick_links.add_tenant
- ui.admin.overview.quick_links.api_key_management
- ui.admin.overview.quick_links.user_management
- ui.admin.overview.quick_links.view_audit_logs
- ui.admin.tenants.breadcrumb.list
- ui.admin.tenants.breadcrumb.section
- ui.admin.tenants.create.breadcrumb.action
- ui.admin.tenants.create.breadcrumb.section
- ui.admin.tenants.detail.breadcrumb_list
- ui.admin.tenants.detail.title
- ui.admin.users.create.breadcrumb.new
- ui.admin.users.create.breadcrumb.section
- ui.admin.users.create.form.login_id
- ui.admin.users.create.form.login_id_placeholder
- ui.admin.users.detail.breadcrumb.section
- ui.admin.users.detail.contact_title
- ui.admin.users.detail.form.department_placeholder
- ui.admin.users.detail.form.job_title_placeholder
- ui.admin.users.detail.form.login_id
- ui.admin.users.detail.form.login_id_placeholder
- ui.admin.users.detail.form.name_placeholder
- ui.admin.users.detail.form.phone_placeholder
- ui.admin.users.detail.form.position_placeholder
- ui.admin.users.detail.form.status_active
- ui.admin.users.detail.form.status_inactive
- ui.admin.users.detail.generate_password
- ui.admin.users.detail.password_mode_generated
- ui.admin.users.detail.password_mode_manual
- ui.admin.users.detail.password_result_title
- ui.admin.users.detail.reset_password_apply
- ui.admin.users.detail.security.password
- ui.admin.users.detail.security.password_placeholder
- ui.admin.users.detail.security.title
- ui.admin.users.detail.status_title
- ui.admin.users.detail.tenants_section.additional
- ui.admin.users.detail.tenants_section.primary
- ui.admin.users.detail.tenants_section.title
- ui.admin.users.detail.title
- ui.admin.users.detail.toggle_password_visibility
- ui.admin.users.list.breadcrumb.list
- ui.admin.users.list.breadcrumb.section
- ui.admin.users.list.empty
- ui.admin.users.list.fetch_error
- ui.admin.users.list.registry.count
- ui.admin.users.list.subtitle
- ui.admin.users.list.table.login_id
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
- ui.admin.users.list.table.msg.admin.users.detail.no_history
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
- ui.admin.users.list.table.msg.common.copied
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.email
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
- ui.admin.users.list.table.ui.admin.users.detail.history_title
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
- ui.admin.users.list.table.ui.admin.users.detail.password_done
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
- ui.admin.users.list.table.ui.common.generate
- ui.admin.users.list.table.ui.common.status.blocked
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
- ui.admin.users.list.table.ui.dev.clients.registry.description
- ui.admin.users.list.table.ui.dev.clients.scopes.email
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
- ui.admin.users.list.table.ui.dev.session.refresh
- ui.admin.users.list.table.ui.dev.session.refreshing
- ui.common.generate
- ui.common.status.blocked
- ui.dev.clients.general.public_key.auth_method
- ui.dev.clients.general.public_key.auth_method_client_secret_basic
- ui.dev.clients.general.public_key.auth_method_none
- ui.dev.clients.general.public_key.auth_method_private_key_jwt
- ui.dev.clients.general.public_key.guide_toggle
- ui.dev.clients.general.public_key.headless_disabled
- ui.dev.clients.general.public_key.headless_enabled
- ui.dev.clients.general.public_key.jwks_inline
- ui.dev.clients.general.public_key.jwks_inline_placeholder
- ui.dev.clients.general.public_key.request_object_alg
- ui.dev.clients.general.public_key.request_object_alg_placeholder
- ui.dev.clients.general.public_key.source
- ui.dev.clients.general.public_key.source_uri
- ui.dev.clients.general.security.trusted_rp_enable
- ui.dev.clients.general.security.trusted_rp_enable_help
- ui.dev.clients.help.docs_body
- ui.dev.clients.help.subtitle
- ui.dev.clients.registry.description
- ui.dev.clients.scopes.email
- ui.dev.clients.scopes.openid
- ui.dev.clients.scopes.profile
- ui.dev.session.refresh
- ui.dev.session.refreshing
요약
- [Sync Error] ko.toml 누락 키 84개
- [Sync Error] en.toml 누락 키 84개
- [Missing Key] template.toml 누락 키 59개

View File

@@ -30,7 +30,7 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined), workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html", reporter: [["html", { open: "never" }], ["list"]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
@@ -61,7 +61,9 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: "npm run dev", command: process.env.CI
? "npm run build && npm run preview -- --port 5173"
: "npm run dev",
url: "http://localhost:5173", url: "http://localhost:5173",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120 * 1000, timeout: 120 * 1000,

View File

@@ -97,7 +97,6 @@ export function TenantSchemaPage() {
useEffect(() => { useEffect(() => {
const rawSchema = tenantQuery.data?.config?.userSchema; const rawSchema = tenantQuery.data?.config?.userSchema;
const loginIdField = tenantQuery.data?.config?.loginIdField;
if (Array.isArray(rawSchema)) { if (Array.isArray(rawSchema)) {
setFields( setFields(
@@ -118,7 +117,7 @@ export function TenantSchemaPage() {
validation: validation:
typeof field?.validation === "string" ? field.validation : "", typeof field?.validation === "string" ? field.validation : "",
unsigned: Boolean(field?.unsigned), unsigned: Boolean(field?.unsigned),
isLoginId: field?.key === loginIdField, isLoginId: Boolean(field?.isLoginId),
})), })),
); );
} }
@@ -126,13 +125,13 @@ export function TenantSchemaPage() {
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (newFields: SchemaField[]) => { mutationFn: (newFields: SchemaField[]) => {
const loginIdField = newFields.find((f) => f.isLoginId)?.key || ""; // Remove legacy loginIdField, keep isLoginId natively in userSchema
const newConfig = { ...tenantQuery.data?.config };
newConfig.loginIdField = undefined;
newConfig.userSchema = newFields;
return updateTenant(tenantId, { return updateTenant(tenantId, {
config: { config: newConfig,
...tenantQuery.data?.config,
userSchema: newFields,
loginIdField: loginIdField,
},
}); });
}, },
onSuccess: () => { onSuccess: () => {
@@ -344,14 +343,10 @@ export function TenantSchemaPage() {
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={field.isLoginId} checked={field.isLoginId || false}
onChange={(e) => { onChange={(e) =>
const newFields = fields.map((f, i) => ({ updateField(index, { isLoginId: e.target.checked })
...f, }
isLoginId: i === index ? e.target.checked : false,
}));
setFields(newFields);
}}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/> />
<span className="text-sm font-medium text-blue-600"> <span className="text-sm font-medium text-blue-600">

View File

@@ -31,6 +31,7 @@ type UserSchemaField = {
required?: boolean; required?: boolean;
adminOnly?: boolean; adminOnly?: boolean;
validation?: string; validation?: string;
isLoginId?: boolean;
}; };
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> }; type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
@@ -65,7 +66,6 @@ function UserCreatePage() {
} = useForm<UserFormValues>({ } = useForm<UserFormValues>({
defaultValues: { defaultValues: {
email: "", email: "",
loginId: "",
password: "", password: "",
name: "", name: "",
phone: "", phone: "",
@@ -274,26 +274,6 @@ function UserCreatePage() {
)} )}
</div> </div>
<div className="space-y-2">
<Label htmlFor="loginId">
{t("ui.admin.users.create.form.login_id", "로그인 ID (선택)")}
</Label>
<Input
id="loginId"
placeholder={t(
"ui.admin.users.create.form.login_id_placeholder",
"사번 또는 아이디",
)}
{...register("loginId")}
/>
<p className="text-[10px] text-muted-foreground">
{t(
"msg.admin.users.create.form.login_id_help",
"이메일/전화번호 외에 별도의 식별자로 로그인할 때 사용합니다.",
)}
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="password"> <Label htmlFor="password">
@@ -470,6 +450,14 @@ function UserCreatePage() {
Admin Only Admin Only
</span> </span>
)} )}
{field.isLoginId && (
<span className="ml-2 text-[10px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
{t(
"ui.admin.users.create.form.is_login_id",
"로그인 ID",
)}
</span>
)}
</Label> </Label>
<Input <Input

File diff suppressed because it is too large Load Diff

View File

@@ -430,9 +430,6 @@ function UserListPage() {
"NAME / EMAIL", "NAME / EMAIL",
)} )}
</TableHead> </TableHead>
<TableHead>
{t("ui.admin.users.list.table.login_id", "LOGIN ID")}
</TableHead>
<TableHead> <TableHead>
{t("ui.admin.users.list.table.role", "ROLE")} {t("ui.admin.users.list.table.role", "ROLE")}
</TableHead> </TableHead>
@@ -514,11 +511,6 @@ function UserListPage() {
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell>
<span className="text-sm font-mono">
{user.loginId || "-"}
</span>
</TableCell>
<TableCell> <TableCell>
<Badge variant="outline"> <Badge variant="outline">
{t(`ui.admin.role.${user.role}`, user.role)} {t(`ui.admin.role.${user.role}`, user.role)}

View File

@@ -549,6 +549,20 @@ export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`); await apiClient.delete(`/v1/admin/users/${userId}`);
} }
export type UserRpHistoryItem = {
client_id: string;
client_name: string;
lastLoginAt: string;
status: string;
};
export async function fetchUserRpHistory(userId: string) {
const { data } = await apiClient.get<UserRpHistoryItem[]>(
`/v1/admin/users/${userId}/rp-history`,
);
return data;
}
export type UserProfileResponse = { export type UserProfileResponse = {
id: string; id: string;
email: string; email: string;

View File

@@ -60,7 +60,7 @@ test.describe("User Management", () => {
}); });
} }
if (url.includes("/admin/tenants") && method === "GET") { if (url.match(/\/admin\/tenants(\?.*)?$/) && method === "GET") {
return route.fulfill({ return route.fulfill({
json: { json: {
items: [ items: [
@@ -69,7 +69,14 @@ test.describe("User Management", () => {
slug: "test-tenant", slug: "test-tenant",
name: "Test Tenant", name: "Test Tenant",
config: { config: {
userSchema: [], userSchema: [
{
key: "loginId",
label: "Login ID",
type: "text",
isLoginId: true,
},
],
}, },
}, },
], ],
@@ -80,7 +87,27 @@ test.describe("User Management", () => {
}); });
} }
if (url.includes("/admin/users/u-1") && method === "GET") { if (url.match(/\/admin\/tenants\/t-1$/) && method === "GET") {
return route.fulfill({
json: {
id: "t-1",
slug: "test-tenant",
name: "Test Tenant",
config: {
userSchema: [
{
key: "loginId",
label: "Login ID",
type: "text",
isLoginId: true,
},
],
},
},
});
}
if (url.match(/\/admin\/users\/u-1$/) && method === "GET") {
return route.fulfill({ return route.fulfill({
json: { json: {
id: "u-1", id: "u-1",
@@ -88,16 +115,36 @@ test.describe("User Management", () => {
email: "john@test.com", email: "john@test.com",
loginId: "johndoe", loginId: "johndoe",
tenantSlug: "test-tenant", tenantSlug: "test-tenant",
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
role: "user", role: "user",
status: "active", status: "active",
metadata: { "t-1": { loginId: "johndoe" } },
}, },
}); });
} }
if (url.includes("/admin/users") && method === "POST") { if (url.includes("/password/policy")) {
return route.fulfill({
json: {
minLength: 12,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
},
});
}
if (url.includes("/rp-history")) {
return route.fulfill({
json: [],
});
}
if (url.match(/\/admin\/users(\?.*)?$/) && method === "POST") {
// Parse request payload to simulate validation checks // Parse request payload to simulate validation checks
const postData = route.request().postDataJSON(); const postData = route.request().postDataJSON();
if (postData && postData.loginId === "existing_user") { if (postData && postData.metadata?.loginId === "existing_user") {
// Simulate a backend conflict error (409) for an existing loginId // Simulate a backend conflict error (409) for an existing loginId
return route.fulfill({ return route.fulfill({
status: 409, status: 409,
@@ -112,16 +159,17 @@ test.describe("User Management", () => {
id: "new-user-id", id: "new-user-id",
name: "New User", name: "New User",
email: "newuser@test.com", email: "newuser@test.com",
loginId: postData?.loginId || "newuser123", loginId: postData?.metadata?.loginId || "newuser123",
}, },
}); });
} }
if (url.includes("/admin/users/u-1") && method === "PUT") { if (url.match(/\/admin\/users\/u-1$/) && method === "PUT") {
// Parse request payload const postData = route.request().postData();
const postData = route.request().postDataJSON(); console.log("PUT /admin/users/u-1 payload:", postData);
if (postData && postData.loginId === "existing_user") {
// Simulate a backend conflict error (409) for an existing loginId // Force 409 error for this specific conflict string
if (postData?.includes("johndoe_conflict")) {
return route.fulfill({ return route.fulfill({
status: 409, status: 409,
json: { json: {
@@ -136,12 +184,12 @@ test.describe("User Management", () => {
id: "u-1", id: "u-1",
name: "John Doe Updated", name: "John Doe Updated",
email: "john@test.com", email: "john@test.com",
loginId: postData?.loginId || "johndoe_updated", loginId: "johndoe_updated",
}, },
}); });
} }
if (url.includes("/admin/users") && method === "GET") { if (url.match(/\/admin\/users(\?.*)?$/) && method === "GET") {
return route.fulfill({ return route.fulfill({
json: { json: {
items: [ items: [
@@ -168,45 +216,65 @@ test.describe("User Management", () => {
test("should successfully edit a user's Login ID", async ({ page }) => { test("should successfully edit a user's Login ID", async ({ page }) => {
await page.goto("/users/u-1"); await page.goto("/users/u-1");
// "테넌트 프로필" 탭 클릭
await page
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
.click();
// Wait for the form to load with the existing login ID // Wait for the form to load with the existing login ID
const loginIdInput = page.locator('input[id="loginId"]'); const loginIdInput = page.locator(
'input[name*="metadata"][name*="loginId"]',
);
await expect(loginIdInput).toBeVisible(); await expect(loginIdInput).toBeVisible();
await expect(loginIdInput).toHaveValue("johndoe"); await expect(loginIdInput).toHaveValue("johndoe");
// Change the Login ID // Change the Login ID
await loginIdInput.fill("johndoe_updated"); await loginIdInput.fill("johndoe_updated");
// Submit the form // Submit the form using Enter key
const saveButton = page.getByRole("button", { await loginIdInput.press("Enter");
name: /변경사항 저장|Save/i,
});
await saveButton.click();
// Check for success message // Check for success message
await expect( await expect(page.getByText(/저장/i).first()).toBeVisible();
page.getByText(/사용자 정보가 수정되었습니다/i).first(),
).toBeVisible();
}); });
test("should show conflict error when updating to an existing Login ID", async ({ test("should show conflict error when updating to an existing Login ID", async ({
page, page,
}) => { }) => {
// Intercept ANY PUT request to this user and return 409
await page.route(/\/admin\/users\/u-1/, async (route) => {
if (route.request().method() === "PUT") {
return route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({ error: "이미 존재하는 로그인 ID 입니다." }),
});
}
return route.fallback();
});
await page.goto("/users/u-1"); await page.goto("/users/u-1");
const loginIdInput = page.locator('input[id="loginId"]'); // "테넌트 프로필" 탭 클릭
await page
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
.click();
const loginIdInput = page.locator(
'input[name*="metadata"][name*="loginId"]',
);
await expect(loginIdInput).toBeVisible(); await expect(loginIdInput).toBeVisible();
await expect(loginIdInput).toHaveValue("johndoe");
// Enter a login ID that triggers our mock conflict error // Use a value similar to the successful edit test
await loginIdInput.fill("existing_user"); await loginIdInput.fill("johndoe_conflict");
const saveButton = page.getByRole("button", { // Submit the form using Enter key
name: /변경사항 저장|Save/i, await loginIdInput.press("Enter");
});
await saveButton.click();
// Check for the specific conflict error message from the backend mock // Check for the specific error
await expect( await expect(
page.getByText(/이미 존재하는 로그인 ID 입니다/i), page.getByText(/이미 존재하는 로그인 ID 입니다/i).first(),
).toBeVisible(); ).toBeVisible();
}); });
@@ -218,12 +286,15 @@ test.describe("User Management", () => {
// Ensure the page title is loaded // Ensure the page title is loaded
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible(); await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
// Select Tenant first (important for schema fields to show up)
await page.selectOption("select#tenantSlug", "test-tenant");
// Fill required fields // Fill required fields
await page.locator('input[name="name"]').fill("New User"); await page.locator('input[name="name"]').fill("New User");
await page.locator('input[name="email"]').fill("newuser@test.com"); await page.locator('input[name="email"]').fill("newuser@test.com");
// Fill Login ID // Fill Login ID
const loginIdInput = page.locator('input[name="loginId"]'); const loginIdInput = page.locator('input[id*="metadata"][id*="loginId"]');
await loginIdInput.fill("newuser123"); await loginIdInput.fill("newuser123");
// Submit the form // Submit the form
@@ -241,12 +312,15 @@ test.describe("User Management", () => {
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible(); await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
// Select Tenant first (important for schema fields to show up)
await page.selectOption("select#tenantSlug", "test-tenant");
// Fill required fields // Fill required fields
await page.locator('input[name="name"]').fill("New User"); await page.locator('input[name="name"]').fill("New User");
await page.locator('input[name="email"]').fill("newuser@test.com"); await page.locator('input[name="email"]').fill("newuser@test.com");
// Fill Login ID that triggers the mock conflict error // Fill Login ID that triggers the mock conflict error
const loginIdInput = page.locator('input[name="loginId"]'); const loginIdInput = page.locator('input[id*="metadata"][id*="loginId"]');
await loginIdInput.fill("existing_user"); await loginIdInput.fill("existing_user");
// Submit the form // Submit the form

View File

@@ -63,7 +63,7 @@ test.describe("User Schema Dynamic Form", () => {
}); });
} }
if (url.includes("/admin/tenants/t-1")) { if (url.match(/\/admin\/tenants\/t-1$/)) {
console.log("Mocking /admin/tenants/t-1"); console.log("Mocking /admin/tenants/t-1");
return route.fulfill({ return route.fulfill({
json: { json: {
@@ -78,6 +78,12 @@ test.describe("User Schema Dynamic Form", () => {
required: true, required: true,
validation: "^E[0-9]{3}$", validation: "^E[0-9]{3}$",
}, },
{
key: "loginId",
label: "Login ID",
required: true,
isLoginId: true,
},
{ {
key: "salary", key: "salary",
label: "Salary", label: "Salary",
@@ -90,7 +96,7 @@ test.describe("User Schema Dynamic Form", () => {
}); });
} }
if (url.includes("/admin/users/u-1")) { if (url.match(/\/admin\/users\/u-1$/)) {
console.log("Mocking /admin/users/u-1"); console.log("Mocking /admin/users/u-1");
return route.fulfill({ return route.fulfill({
json: { json: {
@@ -102,12 +108,34 @@ test.describe("User Schema Dynamic Form", () => {
joinedTenants: [ joinedTenants: [
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" }, { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
], ],
metadata: { "t-1": { emp_id: "E123", salary: 1000 } }, metadata: {
"t-1": { emp_id: "E123", salary: 1000, loginId: "johndoe" },
},
}, },
}); });
} }
if (url.includes("/admin/tenants")) { if (url.includes("/password/policy")) {
console.log("Mocking /password/policy");
return route.fulfill({
json: {
minLength: 12,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
},
});
}
if (url.includes("/rp-history")) {
console.log("Mocking /rp-history");
return route.fulfill({
json: [],
});
}
if (url.match(/\/admin\/tenants(\?.*)?$/)) {
console.log("Mocking /admin/tenants"); console.log("Mocking /admin/tenants");
return route.fulfill({ return route.fulfill({
json: { json: {
@@ -124,6 +152,12 @@ test.describe("User Schema Dynamic Form", () => {
required: true, required: true,
validation: "^E[0-9]{3}$", validation: "^E[0-9]{3}$",
}, },
{
key: "loginId",
label: "Login ID",
required: true,
isLoginId: true,
},
{ {
key: "salary", key: "salary",
label: "Salary", label: "Salary",
@@ -149,6 +183,11 @@ test.describe("User Schema Dynamic Form", () => {
}) => { }) => {
await page.goto("/users/u-1"); await page.goto("/users/u-1");
// "테넌트 프로필" 탭 클릭
await page
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
.click();
// 섹션 헤더 확인 // 섹션 헤더 확인
const header = page const header = page
.getByText(/테넌트별 프로필 관리|Per-tenant Profile/i) .getByText(/테넌트별 프로필 관리|Per-tenant Profile/i)
@@ -173,17 +212,22 @@ test.describe("User Schema Dynamic Form", () => {
}) => { }) => {
await page.goto("/users/u-1"); await page.goto("/users/u-1");
// "테넌트 프로필" 탭 클릭
await page
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
.click();
const empIdInput = page.locator('input[id*="emp_id"]'); const empIdInput = page.locator('input[id*="emp_id"]');
await empIdInput.waitFor({ state: "visible" }); await empIdInput.waitFor({ state: "visible" });
await empIdInput.fill("invalid"); await empIdInput.fill("invalid");
// 포커스 해제하여 유효성 검사 트리거 // Press Enter to trigger form submission and validation
await page.getByLabel(/이름|Name/i).click(); await empIdInput.press("Enter");
// 에러 메시지 확인 // 에러 메시지 확인
const errorMsg = page const errorMsg = page
.locator("form") .getByText(/형식이 올바르지 않습니다|Invalid format/i)
.getByText(/Employee ID|필수|invalid|format/i); .first();
await expect(errorMsg).toBeVisible(); await expect(errorMsg).toBeVisible();
}); });
}); });

View File

@@ -294,7 +294,7 @@ func main() {
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService) tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService) userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
apiKeyHandler := handler.NewApiKeyHandler(db) apiKeyHandler := handler.NewApiKeyHandler(db)
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService) orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
@@ -669,6 +669,7 @@ func main() {
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers) admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers) admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers)
admin.Get("/users/:id", requireAdmin, userHandler.GetUser) admin.Get("/users/:id", requireAdmin, userHandler.GetUser)
admin.Get("/users/:id/rp-history", requireAdmin, userHandler.GetUserRpHistory)
admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser) admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser)
admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser) admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser)

View File

@@ -34,6 +34,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.Tenant{}, &domain.Tenant{},
&domain.TenantDomain{}, &domain.TenantDomain{},
&domain.User{}, &domain.User{},
&domain.UserLoginID{},
&domain.UserGroup{}, &domain.UserGroup{},
&domain.ApiKey{}, &domain.ApiKey{},
&domain.IdentityProviderConfig{}, &domain.IdentityProviderConfig{},

View File

@@ -79,8 +79,9 @@ type UserProfileResponse struct {
Department string `json:"department"` Department string `json:"department"`
AffiliationType string `json:"affiliationType"` AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"` CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가 TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"` Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록

View File

@@ -12,11 +12,12 @@ var ErrNotSupported = errors.New("idp: not supported")
// BrokerUser is the standard user model used within Baron SSO business logic. // BrokerUser is the standard user model used within Baron SSO business logic.
// It defines the canonical set of fields that must be supported by any underlying IDP. // It defines the canonical set of fields that must be supported by any underlying IDP.
type BrokerUser struct { type BrokerUser struct {
ID string `json:"id" required:"true"` ID string `json:"id" required:"true"`
Email string `json:"email" required:"true"` Email string `json:"email" required:"true"`
LoginID string `json:"login_id"` LoginID string `json:"login_id"`
Name string `json:"name"` CustomLoginIDs []string `json:"custom_login_ids"` // [New] 다중 로그인 ID
PhoneNumber string `json:"phone_number"` Name string `json:"name"`
PhoneNumber string `json:"phone_number"`
// Attributes stores custom user attributes. // Attributes stores custom user attributes.
// The "required_keys" tag specifies which keys MUST be present in the IDP's schema support. // The "required_keys" tag specifies which keys MUST be present in the IDP's schema support.
Attributes map[string]interface{} `json:"attributes" required_keys:"grade,department"` Attributes map[string]interface{} `json:"attributes" required_keys:"grade,department"`

View File

@@ -35,14 +35,13 @@ func NormalizeRole(role string) string {
type User struct { type User struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Email string `gorm:"uniqueIndex;not null" json:"email"` Email string `gorm:"uniqueIndex;not null" json:"email"`
LoginID string `gorm:"column:login_id;uniqueIndex:idx_tenant_login_id" json:"loginId"`
PasswordHash *string `gorm:"column:password_hash" json:"-"` PasswordHash *string `gorm:"column:password_hash" json:"-"`
Name string `gorm:"column:name;not null" json:"name"` Name string `gorm:"column:name;not null" json:"name"`
Phone string `gorm:"column:phone" json:"phone"` Phone string `gorm:"column:phone" json:"phone"`
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"` AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
CompanyCode string `gorm:"column:company_code;index" json:"companyCode"` CompanyCode string `gorm:"column:company_code;index" json:"companyCode"`
TenantID *string `gorm:"column:tenant_id;type:uuid;index;uniqueIndex:idx_tenant_login_id" json:"tenantId,omitempty"` TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
Department string `gorm:"column:department" json:"department"` Department string `gorm:"column:department" json:"department"`
@@ -53,6 +52,18 @@ type User struct {
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
// Multiple identifiers support
UserLoginIDs []UserLoginID `gorm:"foreignKey:UserID" json:"userLoginIds,omitempty"`
}
// UserLoginID represents multiple custom identifiers for a user
type UserLoginID struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
UserID string `gorm:"type:uuid;not null;index" json:"userId"`
TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"` // 발급 테넌트
FieldKey string `gorm:"not null" json:"fieldKey"` // 스키마 필드 키 (예: emp_id)
LoginID string `gorm:"uniqueIndex;not null" json:"loginId"` // 실제 값 (예: EMP001)
} }
// BeforeCreate hook to generate UUID if not present // BeforeCreate hook to generate UUID if not present

View File

@@ -287,6 +287,16 @@ func (h *AuthHandler) CheckLoginID(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
} }
if !exists && h.UserRepo != nil {
// [New] Check local DB for custom login IDs (Plan A)
taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), req.LoginID)
if err != nil {
slog.Error("Failed to check login ID in local DB", "error", err)
} else if taken {
exists = true
}
}
if exists { if exists {
return c.JSON(fiber.Map{"available": false, "message": "ID already registered"}) return c.JSON(fiber.Map{"available": false, "message": "ID already registered"})
} }
@@ -596,27 +606,20 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
"grade": "member", "grade": "member",
} }
if req.LoginID != "" { // Sync all custom login IDs based on tenant schemas
attributes["id"] = req.LoginID loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
}
// Sync custom field to LoginID if configured // Validate all collected LoginIDs
if tenantID != nil && h.TenantService != nil { if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
if tenant, err := h.TenantService.GetTenant(c.Context(), *tenantID); err == nil && tenant != nil { for _, lid := range collectedIDs {
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" { if err := domain.ValidateLoginID(lid, req.Email, normalizedPhone); err != nil {
syncLoginID(attributes, req.Metadata, *tenantID, loginIdField) return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
} }
} }
} }
finalLoginID := extractTraitString(attributes, "id")
if err := domain.ValidateLoginID(finalLoginID, req.Email, normalizedPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
brokerUser := &domain.BrokerUser{ brokerUser := &domain.BrokerUser{
Email: req.Email, Email: req.Email,
LoginID: finalLoginID,
Name: req.Name, Name: req.Name,
PhoneNumber: normalizedPhone, PhoneNumber: normalizedPhone,
Attributes: attributes, Attributes: attributes,
@@ -629,7 +632,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
} }
slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err) slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err)
if strings.Contains(err.Error(), "already exists") { if strings.Contains(err.Error(), "already exists") {
return errorJSON(c, fiber.StatusConflict, "User already exists") return errorJSON(c, fiber.StatusConflict, "User or login identifier already exists")
} }
// Include the actual error message in the response for debugging // Include the actual error message in the response for debugging
return errorJSON(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to create user: %v", err)) return errorJSON(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to create user: %v", err))
@@ -644,29 +647,47 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
// 로컬 DB 저장이 실패하더라도 회원가입 프로세스는 성공으로 간주합니다. // 로컬 DB 저장이 실패하더라도 회원가입 프로세스는 성공으로 간주합니다.
localUser := &domain.User{ localUser := &domain.User{
ID: providerID, // Match IDP Subject ID: providerID,
Email: req.Email, Email: req.Email,
Name: req.Name, Name: req.Name,
Phone: normalizedPhone, Phone: normalizedPhone,
Role: "user",
AffiliationType: req.AffiliationType, AffiliationType: req.AffiliationType,
CompanyCode: companyCode, CompanyCode: companyCode,
TenantID: tenantID,
Department: req.Department, Department: req.Department,
Role: "user",
Status: "active", Status: "active",
Metadata: req.Metadata, CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if tenantID != nil {
localUser.TenantID = tenantID
}
// Merge metadata
localUser.Metadata = make(domain.JSONMap)
for k, v := range req.Metadata {
localUser.Metadata[k] = v
} }
if h.UserRepo != nil { if h.UserRepo != nil {
go func(u *domain.User) { go func(u *domain.User, ids []domain.UserLoginID) {
// 요청 Context가 취소될 수 있으므로 Background Context 사용
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := h.UserRepo.Create(ctx, u); err != nil { if err := h.UserRepo.Update(ctx, u); err != nil {
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err) slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
} else { } else {
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email) slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
// Update User Login IDs
for i := range ids {
ids[i].UserID = u.ID
}
if err := h.UserRepo.UpdateUserLoginIDs(ctx, u.ID, ids); err != nil {
slog.Error("[Signup] Failed to update user login IDs", "userID", u.ID, "error", err)
}
// [Keto] Sync user-tenant relationship via Outbox // [Keto] Sync user-tenant relationship via Outbox
if h.KetoOutboxRepo != nil && u.TenantID != nil { if h.KetoOutboxRepo != nil && u.TenantID != nil {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
@@ -678,7 +699,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
}) })
} }
} }
}(localUser) }(localUser, loginIDRecords)
} }
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
@@ -3182,7 +3203,7 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
if cookie == "" { if cookie == "" {
return errorJSON(c, fiber.StatusUnauthorized, "Missing session token") return errorJSON(c, fiber.StatusUnauthorized, "Missing session token")
} }
_, traits, err := h.getKratosIdentityWithCookie(cookie) _, traits, _, err := h.getKratosIdentityWithCookie(cookie)
if err != nil { if err != nil {
slog.Warn("[QR] Cookie session invalid", "error", err) slog.Warn("[QR] Cookie session invalid", "error", err)
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
@@ -4252,7 +4273,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
if strings.Contains(path, "/api/v1/auth/oidc/login/accept") { if strings.Contains(path, "/api/v1/auth/oidc/login/accept") {
appName = "OIDC 로그인" appName = "OIDC 로그인"
// 우선 audit details의 client 정보를 사용하고, 없으면 Hydra 조회로 보강 // 우선 audit details의 client 정보를 사용하고, 없으면 Hydra 조회로 보강
if details, err := parseAuditDetails(log.Details); err == nil && details != nil { if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil {
if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" { if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" {
appName = strings.TrimSpace(name) appName = strings.TrimSpace(name)
} }
@@ -5089,6 +5110,15 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
profile.Role = domain.RoleUser profile.Role = domain.RoleUser
} }
// [New] Backtracking Logic for Session Tenant (Plan A)
if usedID, ok := profile.Metadata["_used_identifier"].(string); ok && usedID != "" && h.UserRepo != nil {
if tid, err := h.UserRepo.FindTenantIDByLoginID(c.Context(), usedID); err == nil && tid != "" {
profile.SessionTenantID = &tid
slog.Debug("Auto-assigned session tenant via backtracking", "loginID", usedID, "tenantID", tid)
}
delete(profile.Metadata, "_used_identifier") // Cleanup
}
// Fetch Tenant Metadata if missing // Fetch Tenant Metadata if missing
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" { if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil { if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
@@ -5140,7 +5170,7 @@ func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
return identityID, nil return identityID, nil
} }
if cookie := c.Get("Cookie"); cookie != "" { if cookie := c.Get("Cookie"); cookie != "" {
cookieID, _, cookieErr := h.getKratosIdentityWithCookie(cookie) cookieID, _, _, cookieErr := h.getKratosIdentityWithCookie(cookie)
if cookieErr == nil && cookieID != "" { if cookieErr == nil && cookieID != "" {
return cookieID, nil return cookieID, nil
} }
@@ -5151,14 +5181,14 @@ func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
if cookie == "" { if cookie == "" {
return "", fmt.Errorf("missing authorization token") return "", fmt.Errorf("missing authorization token")
} }
identityID, _, err := h.getKratosIdentityWithCookie(cookie) identityID, _, _, err := h.getKratosIdentityWithCookie(cookie)
return identityID, err return identityID, err
} }
func (h *AuthHandler) resolveConsentSubjects(c *fiber.Ctx) ([]string, error) { func (h *AuthHandler) resolveConsentSubjects(c *fiber.Ctx) ([]string, error) {
token := h.getBearerToken(c) token := h.getBearerToken(c)
if token != "" { if token != "" {
identityID, traits, err := h.getKratosIdentity(token) identityID, traits, _, err := h.getKratosIdentity(token)
if err == nil && identityID != "" { if err == nil && identityID != "" {
subjects := []string{identityID} subjects := []string{identityID}
subjects = appendLoginIDsFromTraits(subjects, traits) subjects = appendLoginIDsFromTraits(subjects, traits)
@@ -5170,7 +5200,7 @@ func (h *AuthHandler) resolveConsentSubjects(c *fiber.Ctx) ([]string, error) {
if cookie == "" { if cookie == "" {
return nil, fmt.Errorf("missing authorization token") return nil, fmt.Errorf("missing authorization token")
} }
identityID, traits, err := h.getKratosIdentityWithCookie(cookie) identityID, traits, _, err := h.getKratosIdentityWithCookie(cookie)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -5250,7 +5280,7 @@ func isAuthEventType(eventType string) bool {
func extractAuditPath(log domain.AuditLog) string { func extractAuditPath(log domain.AuditLog) string {
if log.Details != "" { if log.Details != "" {
if payload, err := parseAuditDetails(log.Details); err == nil { if payload, err := utils.ParseAuditDetails(log.Details); err == nil {
if path, ok := payload["path"].(string); ok && path != "" { if path, ok := payload["path"].(string); ok && path != "" {
return path return path
} }
@@ -5263,17 +5293,6 @@ func extractAuditPath(log domain.AuditLog) string {
return "" return ""
} }
func parseAuditDetails(details string) (map[string]any, error) {
var payload map[string]any
if details == "" {
return nil, fmt.Errorf("empty details")
}
if err := json.Unmarshal([]byte(details), &payload); err != nil {
return nil, err
}
return payload, nil
}
func extractRequestBody(details map[string]any) map[string]any { func extractRequestBody(details map[string]any) map[string]any {
if details == nil { if details == nil {
return nil return nil
@@ -5290,7 +5309,7 @@ func extractRequestBody(details map[string]any) map[string]any {
} }
func shouldSkipAuthTimeline(log domain.AuditLog) bool { func shouldSkipAuthTimeline(log domain.AuditLog) bool {
details, _ := parseAuditDetails(log.Details) details, _ := utils.ParseAuditDetails(log.Details)
path := strings.ToLower(extractAuditPath(log)) path := strings.ToLower(extractAuditPath(log))
if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") { if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") {
return true return true
@@ -5384,7 +5403,7 @@ func deriveAuthMethod(log domain.AuditLog) string {
loginID := extractLoginIDFromAuditDetails(log.Details) loginID := extractLoginIDFromAuditDetails(log.Details)
kind := loginIDKind(loginID) kind := loginIDKind(loginID)
details, _ := parseAuditDetails(log.Details) details, _ := utils.ParseAuditDetails(log.Details)
requestBody := extractRequestBody(details) requestBody := extractRequestBody(details)
if details != nil { if details != nil {
if raw, ok := details["auth_timeline_skip"]; ok { if raw, ok := details["auth_timeline_skip"]; ok {
@@ -5603,7 +5622,7 @@ func extractLoginChallengeFromAuditDetails(details string) string {
if details == "" { if details == "" {
return "" return ""
} }
payload, err := parseAuditDetails(details) payload, err := utils.ParseAuditDetails(details)
if err != nil { if err != nil {
return "" return ""
} }
@@ -5750,12 +5769,12 @@ func extractApprovedSessionIDFromAuditDetails(details string) string {
} }
func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) { func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
id, _, err := h.getKratosIdentity(token) id, _, _, err := h.getKratosIdentity(token)
return id, err return id, err
} }
func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) { func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
_, traits, err := h.getKratosIdentity(token) _, traits, _, err := h.getKratosIdentity(token)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -5943,44 +5962,56 @@ func extractLoginIDFromClaims(claims map[string]any) string {
return "" return ""
} }
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, string, error) {
identityID, traits, _, err := h.getKratosIdentityWithSession(sessionToken) identityID, traits, _, usedID, err := h.getKratosIdentityWithSession(sessionToken)
return identityID, traits, err return identityID, traits, usedID, err
} }
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, error) { func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" { if kratosURL == "" {
kratosURL = "http://kratos:4433" kratosURL = "http://kratos:4433"
} }
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil { if err != nil {
return "", nil, "", err return "", nil, "", "", err
} }
req.Header.Set("X-Session-Token", sessionToken) req.Header.Set("X-Session-Token", sessionToken)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return "", nil, "", err return "", nil, "", "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) return "", nil, "", "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
} }
var result struct { var result struct {
AuthenticatedAt string `json:"authenticated_at"` AuthenticatedAt string `json:"authenticated_at"`
Identity struct { AuthenticationMethods []struct {
Method string `json:"method"`
Identifier string `json:"identifier"`
} `json:"authentication_methods"`
Identity struct {
ID string `json:"id"` ID string `json:"id"`
Traits map[string]interface{} `json:"traits"` Traits map[string]interface{} `json:"traits"`
} `json:"identity"` } `json:"identity"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, "", err return "", nil, "", "", err
} }
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil usedIdentifier := ""
for _, m := range result.AuthenticationMethods {
if m.Identifier != "" {
usedIdentifier = m.Identifier
break
}
}
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, usedIdentifier, nil
} }
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
@@ -6056,44 +6087,56 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
return parsed.SessionToken, nil return parsed.SessionToken, nil
} }
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) { func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, string, error) {
identityID, traits, _, err := h.getKratosIdentityWithCookieAndSession(cookie) identityID, traits, _, usedID, err := h.getKratosIdentityWithCookieAndSession(cookie)
return identityID, traits, err return identityID, traits, usedID, err
} }
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, error) { func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" { if kratosURL == "" {
kratosURL = "http://kratos:4433" kratosURL = "http://kratos:4433"
} }
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil { if err != nil {
return "", nil, "", err return "", nil, "", "", err
} }
req.Header.Set("Cookie", cookie) req.Header.Set("Cookie", cookie)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return "", nil, "", err return "", nil, "", "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) return "", nil, "", "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
} }
var result struct { var result struct {
AuthenticatedAt string `json:"authenticated_at"` AuthenticatedAt string `json:"authenticated_at"`
Identity struct { AuthenticationMethods []struct {
Method string `json:"method"`
Identifier string `json:"identifier"`
} `json:"authentication_methods"`
Identity struct {
ID string `json:"id"` ID string `json:"id"`
Traits map[string]interface{} `json:"traits"` Traits map[string]interface{} `json:"traits"`
} `json:"identity"` } `json:"identity"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, "", err return "", nil, "", "", err
} }
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil usedIdentifier := ""
for _, m := range result.AuthenticationMethods {
if m.Identifier != "" {
usedIdentifier = m.Identifier
break
}
}
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, usedIdentifier, nil
} }
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) { func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
@@ -6228,33 +6271,41 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
return profile return profile
} }
func (h *AuthHandler) applySessionAuthenticatedAtFromWhoami(profile *domain.UserProfileResponse, authenticatedAt string) *domain.UserProfileResponse { func (h *AuthHandler) applySessionInfoFromWhoami(profile *domain.UserProfileResponse, authenticatedAt, usedIdentifier string) *domain.UserProfileResponse {
if profile == nil { if profile == nil {
return nil return nil
} }
profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt) profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt)
if usedIdentifier != "" {
if profile.Metadata == nil {
profile.Metadata = make(map[string]any)
}
profile.Metadata["_used_identifier"] = usedIdentifier
}
return profile return profile
} }
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithSession(sessionToken) identityID, traits, authenticatedAt, usedIdentifier, err := h.getKratosIdentityWithSession(sessionToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return h.applySessionAuthenticatedAtFromWhoami( return h.applySessionInfoFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits), h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt, authenticatedAt,
usedIdentifier,
), nil ), nil
} }
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) { func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithCookieAndSession(cookie) identityID, traits, authenticatedAt, usedIdentifier, err := h.getKratosIdentityWithCookieAndSession(cookie)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return h.applySessionAuthenticatedAtFromWhoami( return h.applySessionInfoFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits), h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt, authenticatedAt,
usedIdentifier,
), nil ), nil
} }
@@ -6272,13 +6323,13 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
err error err error
) )
if token != "" { if token != "" {
identityID, traits, err = h.getKratosIdentity(token) identityID, traits, _, err = h.getKratosIdentity(token)
} else { } else {
cookie := c.Get("Cookie") cookie := c.Get("Cookie")
if cookie == "" { if cookie == "" {
return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token") return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token")
} }
identityID, traits, err = h.getKratosIdentityWithCookie(cookie) identityID, traits, _, err = h.getKratosIdentityWithCookie(cookie)
} }
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
@@ -6331,27 +6382,35 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
// [LoginID Sync based on Tenant Settings] // [LoginID Sync based on Tenant Settings]
// Perform sync AFTER metadata merge to ensure traits contains current values // Perform sync AFTER metadata merge to ensure traits contains current values
syncCompCode := extractTraitString(traits, "companyCode") loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, traits, req.Metadata, identityID)
if syncCompCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), syncCompCode); err == nil && tenant != nil { // Validate all collected LoginIDs
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" { userEmail := extractTraitString(traits, "email")
syncLoginID(traits, req.Metadata, tenant.ID, loginIdField) userPhone := extractTraitString(traits, "phone_number")
if collectedIDs, ok := traits["custom_login_ids"].([]string); ok {
for _, lid := range collectedIDs {
if err := domain.ValidateLoginID(lid, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
} }
} }
} }
finalLoginID := extractTraitString(traits, "id")
userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone")
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
if err := h.updateKratosIdentity(identityID, traits); err != nil { if err := h.updateKratosIdentity(identityID, traits); err != nil {
slog.Error("Failed to update profile in Kratos", "error", err) slog.Error("Failed to update profile in Kratos", "error", err)
return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.") return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.")
} }
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency
if h.UserRepo != nil {
ctx := context.Background()
// Also update local User record (read-model)
// We can fetch updated identity or just map current traits
// Since mapKratosIdentityToProfile is for UI, let's just use UpdateUserLoginIDs first
if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil {
slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)
}
}
// Invalidate token-based profile cache so refreshed /user/me returns latest traits. // Invalidate token-based profile cache so refreshed /user/me returns latest traits.
if h.RedisService != nil && token != "" { if h.RedisService != nil && token != "" {
cacheKey := "cache:profile:token:" + token cacheKey := "cache:profile:token:" + token
@@ -6396,7 +6455,7 @@ func (h *AuthHandler) ChangeMyPassword(c *fiber.Ctx) error {
if cookie == "" { if cookie == "" {
return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token") return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token")
} }
_, traits, err := h.getKratosIdentityWithCookie(cookie) _, traits, _, err := h.getKratosIdentityWithCookie(cookie)
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
} }
@@ -6434,7 +6493,7 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error {
if cookie == "" { if cookie == "" {
return errorJSON(c, fiber.StatusUnauthorized, "Unauthorized") return errorJSON(c, fiber.StatusUnauthorized, "Unauthorized")
} }
userID, _, err = h.getKratosIdentityWithCookie(cookie) userID, _, _, err = h.getKratosIdentityWithCookie(cookie)
} }
if err != nil || userID == "" { if err != nil || userID == "" {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
@@ -6475,7 +6534,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
if cookie == "" { if cookie == "" {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
} }
userID, _, err = h.getKratosIdentityWithCookie(cookie) userID, _, _, err = h.getKratosIdentityWithCookie(cookie)
} }
if err != nil || userID == "" { if err != nil || userID == "" {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
@@ -6625,7 +6684,7 @@ func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
// Logs are DESC (newest first). Iterate in reverse (oldest first) to build state. // Logs are DESC (newest first). Iterate in reverse (oldest first) to build state.
for i := len(logs) - 1; i >= 0; i-- { for i := len(logs) - 1; i >= 0; i-- {
log := logs[i] log := logs[i]
details, _ := parseAuditDetails(log.Details) details, _ := utils.ParseAuditDetails(log.Details)
clientID, _ := details["client_id"].(string) clientID, _ := details["client_id"].(string)
if clientID == "" { if clientID == "" {
continue continue

View File

@@ -80,8 +80,14 @@ func (m *AsyncMockUserRepo) Create(ctx context.Context, user *domain.User) error
} }
return args.Error(0) return args.Error(0)
} }
func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error { return nil } func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error {
func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil } args := m.Called(ctx, user)
if m.createCalled != nil {
m.createCalled <- true
}
return args.Error(0)
}
func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil }
func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil return nil, nil
} }
@@ -114,6 +120,22 @@ func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []str
return nil, nil return nil, nil
} }
func (m *AsyncMockUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil
}
func (m *AsyncMockUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
return nil, nil
}
func (m *AsyncMockUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
return false, nil
}
func (m *AsyncMockUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}
type AsyncMockRedisRepo struct { type AsyncMockRedisRepo struct {
mock.Mock mock.Mock
} }
@@ -254,7 +276,7 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) {
// UserRepo Mocks (Async & Failure) // UserRepo Mocks (Async & Failure)
mockUserRepo.createCalled = make(chan bool, 1) mockUserRepo.createCalled = make(chan bool, 1)
mockUserRepo.On("Create", mock.Anything, mock.MatchedBy(func(u *domain.User) bool { mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
return u.Email == email return u.Email == email
})).Return(errors.New("db connection error")) })).Return(errors.New("db connection error"))

View File

@@ -4,6 +4,7 @@ import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository" "baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
@@ -2106,7 +2107,7 @@ func (h *DevHandler) matchesDevAuditFilter(
if !strings.Contains(logItem.EventType, "/api/v1/dev/") { if !strings.Contains(logItem.EventType, "/api/v1/dev/") {
return false return false
} }
details, _ := parseAuditDetails(logItem.Details) details, _ := utils.ParseAuditDetails(logItem.Details)
if statusFilter != "" && statusFilter != "all" && strings.ToLower(logItem.Status) != statusFilter { if statusFilter != "" && statusFilter != "all" && strings.ToLower(logItem.Status) != statusFilter {
return false return false
} }

View File

@@ -131,6 +131,22 @@ func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes
return args.Get(0).(map[string]int64), args.Error(1) return args.Get(0).(map[string]int64), args.Error(1)
} }
func (m *MockUserRepoForHandler) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil
}
func (m *MockUserRepoForHandler) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
return nil, nil
}
func (m *MockUserRepoForHandler) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
return false, nil
}
func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}
func TestTenantHandler_CreateTenant(t *testing.T) { func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)

View File

@@ -35,9 +35,10 @@ type UserHandler struct {
KetoOutboxRepo repository.KetoOutboxRepository KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository UserRepo repository.UserRepository
UserGroupRepo repository.UserGroupRepository UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
} }
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository) *UserHandler { func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler {
return &UserHandler{ return &UserHandler{
KratosAdmin: kratosAdmin, KratosAdmin: kratosAdmin,
OryProvider: oryProvider, OryProvider: oryProvider,
@@ -46,6 +47,7 @@ func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProvi
KetoOutboxRepo: ketoOutboxRepo, KetoOutboxRepo: ketoOutboxRepo,
UserRepo: userRepo, UserRepo: userRepo,
UserGroupRepo: userGroupRepo, UserGroupRepo: userGroupRepo,
AuditRepo: auditRepo,
} }
} }
@@ -53,6 +55,7 @@ type userSummary struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
LoginID string `json:"loginId,omitempty"` LoginID string `json:"loginId,omitempty"`
CustomLoginIDs []string `json:"customLoginIds,omitempty"` // [New] 다중 로그인 ID 목록
Name string `json:"name"` Name string `json:"name"`
Phone string `json:"phone"` Phone string `json:"phone"`
Role string `json:"role"` Role string `json:"role"`
@@ -325,40 +328,23 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
// [Override with explicit LoginID if provided] // [Override with explicit LoginID if provided]
if req.LoginID != "" { if req.LoginID != "" {
attributes["id"] = req.LoginID if ids, ok := attributes["custom_login_ids"].([]string); ok {
attributes["custom_login_ids"] = append(ids, req.LoginID)
} else {
attributes["custom_login_ids"] = []string{req.LoginID}
}
} }
// [Resolve TenantID and LoginID before Kratos creation] // [Resolve TenantID and Custom Login IDs before Kratos creation]
var tenantID string var tenantID string
synced := false
if req.CompanyCode != "" && h.TenantService != nil { if req.CompanyCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
tenantID = tenant.ID tenantID = tenant.ID
// Sync custom field to LoginID if configured
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
syncLoginID(attributes, req.Metadata, tenantID, loginIdField)
synced = true
}
} }
} }
// Fallback: Try syncing based on the tenant namespaces being updated // Collect and sync all custom login IDs based on tenant schemas
if !synced && h.TenantService != nil { loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
for k := range req.Metadata {
if len(k) >= 32 { // Looks like a UUID (tenant ID)
if tenant, err := h.TenantService.GetTenant(c.Context(), k); err == nil && tenant != nil {
if tenantID == "" {
tenantID = tenant.ID
}
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
syncLoginID(attributes, req.Metadata, tenant.ID, loginIdField)
break
}
}
}
}
}
attributes["role"] = role attributes["role"] = role
if tenantID != "" { if tenantID != "" {
@@ -373,22 +359,25 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
} }
} }
finalLoginID := extractTraitString(attributes, "id") // Validate all collected LoginIDs
if err := domain.ValidateLoginID(finalLoginID, email, normalizePhoneNumber(req.Phone)); err != nil { if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
return errorJSON(c, fiber.StatusBadRequest, err.Error()) for _, lid := range collectedIDs {
if err := domain.ValidateLoginID(lid, email, normalizePhoneNumber(req.Phone)); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
}
}
} }
brokerUser := &domain.BrokerUser{ brokerUser := &domain.BrokerUser{
Email: email, Email: email,
LoginID: finalLoginID,
Name: name, Name: name,
PhoneNumber: normalizePhoneNumber(req.Phone), PhoneNumber: normalizePhoneNumber(req.Phone),
Attributes: attributes, Attributes: attributes,
} }
// [Validation] Based on Tenant Schema // [Validation] Based on Tenant Schema
if req.CompanyCode != "" && h.TenantService != nil { if tenantID != "" && h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode) tenant, err := h.TenantService.GetTenant(c.Context(), tenantID)
if err == nil && tenant != nil { if err == nil && tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
if err := h.validateMetadata(req.Metadata, schema, true); err != nil { if err := h.validateMetadata(req.Metadata, schema, true); err != nil {
@@ -400,8 +389,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
identityID, err := h.OryProvider.CreateUser(brokerUser, password) identityID, err := h.OryProvider.CreateUser(brokerUser, password)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "already exists") { if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") {
return errorJSON(c, fiber.StatusConflict, "email already exists") return errorJSON(c, fiber.StatusConflict, "이미 사용 중인 식별자(이메일/전화번호/사번 등)입니다.")
} }
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
@@ -424,6 +413,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err) slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
} }
// Update User Login IDs in local DB
for i := range loginIDRecords {
loginIDRecords[i].UserID = localUser.ID
}
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
slog.Error("[UserHandler] Failed to update user login IDs", "userID", localUser.ID, "error", err)
}
// [Keto] Sync relations via Outbox (Synchronous for accurate counting) // [Keto] Sync relations via Outbox (Synchronous for accurate counting)
if h.KetoOutboxRepo != nil { if h.KetoOutboxRepo != nil {
// 1. Role based relations // 1. Role based relations
@@ -580,15 +577,8 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
"role": role, "role": role,
} }
// Override with explicit LoginID if provided // Sync all custom login IDs based on tenant schemas
if item.LoginID != "" { loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, item.Metadata, "")
attributes["id"] = item.LoginID
}
// Sync LoginID from configured custom field (overrides explicit LoginID)
if tItem.LoginIDField != "" {
syncLoginID(attributes, item.Metadata, tItem.ID, tItem.LoginIDField)
}
// Merge metadata // Merge metadata
for k, v := range item.Metadata { for k, v := range item.Metadata {
@@ -597,28 +587,36 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
} }
} }
finalLoginID := extractTraitString(attributes, "id")
userEmail := email userEmail := email
userPhone := normalizePhoneNumber(item.Phone) userPhone := normalizePhoneNumber(item.Phone)
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil { // Validate all collected LoginIDs
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()}) if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
continue valid := true
for _, lid := range collectedIDs {
if err := domain.ValidateLoginID(lid, userEmail, userPhone); err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "Invalid LoginID (" + lid + "): " + err.Error()})
valid = false
break
}
}
if !valid {
continue
}
} }
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{ identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
Email: userEmail, Email: userEmail,
LoginID: finalLoginID,
Name: item.Name, Name: item.Name,
PhoneNumber: userPhone, PhoneNumber: userPhone,
Attributes: attributes, Attributes: attributes,
}, password) }, password)
if err != nil { if err != nil {
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도 // 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
if strings.Contains(err.Error(), "already exists") { if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") {
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email) identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email)
if err != nil || identityID == "" { if err != nil || identityID == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "이미 존재하는 사용자지만 ID를 찾을 수 없습니다."}) results = append(results, bulkUserResult{Email: email, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
continue continue
} }
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID) slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
@@ -634,7 +632,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
localUser := &domain.User{ localUser := &domain.User{
ID: identityID, ID: identityID,
Email: email, Email: email,
LoginID: extractTraitString(attributes, "id"),
Name: name, Name: name,
Phone: normalizePhoneNumber(item.Phone), Phone: normalizePhoneNumber(item.Phone),
Role: role, Role: role,
@@ -660,6 +657,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err) slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
} }
// Update User Login IDs in local DB
for i := range loginIDRecords {
loginIDRecords[i].UserID = localUser.ID
}
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
}
if h.KetoOutboxRepo != nil { if h.KetoOutboxRepo != nil {
// 1. Sync Role based relationship // 1. Sync Role based relationship
h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID) h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)
@@ -961,10 +966,6 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
} }
} }
if localUser.LoginID == "" {
localUser.LoginID = localUser.ID
}
_ = h.UserRepo.Update(c.Context(), localUser) _ = h.UserRepo.Update(c.Context(), localUser)
// [Keto Sync] // [Keto Sync]
@@ -1184,20 +1185,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["role"] = role traits["role"] = role
} }
// [Override with explicit LoginID if provided]
// This is done FIRST so that if a custom loginIdField is configured in the tenant,
// the metadata sync below will override this explicit value, preventing the UI's
// pre-filled explicit loginId from clobbering the updated custom field.
if req.LoginID != nil && *req.LoginID != "" {
traits["id"] = *req.LoginID
}
// [Namespaced Metadata Sync] // [Namespaced Metadata Sync]
coreTraits := map[string]bool{ coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true, "email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true, "grade": true, "companyCode": true, "department": true,
"affiliationType": true, "role": true, "tenant_id": true, "affiliationType": true, "role": true, "tenant_id": true,
"id": true, "custom_login_ids": true, "id": true,
} }
// For namespaced metadata, we don't delete everything, we merge. // For namespaced metadata, we don't delete everything, we merge.
@@ -1221,51 +1214,29 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// [LoginID Sync based on Tenant Settings] // [LoginID Sync based on Tenant Settings]
// Perform sync AFTER metadata merge to ensure traits contains current values // Perform sync AFTER metadata merge to ensure traits contains current values
syncCompCode := extractTraitString(traits, "companyCode") loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, traits, req.Metadata, userID)
synced := false
if syncCompCode != "" && h.TenantService != nil { // Validate all collected LoginIDs
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), syncCompCode); err == nil && tenant != nil {
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
syncLoginID(traits, req.Metadata, tenant.ID, loginIdField)
synced = true
}
}
}
// Fallback: If companyCode is empty or didn't sync, try syncing based on the tenant namespaces being updated
if !synced && h.TenantService != nil {
for k := range req.Metadata {
if len(k) >= 32 { // Looks like a UUID (tenant ID)
if tenant, err := h.TenantService.GetTenant(c.Context(), k); err == nil && tenant != nil {
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
syncLoginID(traits, req.Metadata, tenant.ID, loginIdField)
synced = true
break // Apply first matched tenant config
}
}
}
}
}
finalLoginID := extractTraitString(traits, "id")
userEmail := extractTraitString(traits, "email") userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone_number") userPhone := extractTraitString(traits, "phone_number")
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil { if collectedIDs, ok := traits["custom_login_ids"].([]string); ok {
return errorJSON(c, fiber.StatusBadRequest, err.Error()) for _, lid := range collectedIDs {
if err := domain.ValidateLoginID(lid, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
}
}
} }
// resolvePasswordLoginID might be doing something else but we already have finalLoginID.
// We should just use finalLoginID if it's the intended identifier.
// But let's check if resolvePasswordLoginID exists and what it returns. Assuming it returns a string.
// If it overrides, we assign it. Let's just use finalLoginID for now.
finalLoginID = resolvePasswordLoginID(traits)
state := normalizeKratosState(req.Status) state := normalizeKratosState(req.Status)
slog.Info("[UpdateUser] Calling Kratos UpdateIdentity", "userID", userID, "traits", traits, "state", state) slog.Info("[UpdateUser] Calling Kratos UpdateIdentity", "userID", userID, "traits", traits, "state", state)
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state) updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
if err != nil { if err != nil {
// [Exception Handling] Check for 409 Conflict (Duplicate Identifier)
if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "exists already") {
return errorJSON(c, fiber.StatusConflict, "이미 다른 사용자가 사용 중인 식별자(이메일/전화번호/사번 등)가 포함되어 있습니다.")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
@@ -1273,15 +1244,16 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if h.UserRepo != nil { if h.UserRepo != nil {
updatedLocalUser := h.mapToLocalUser(*updated) updatedLocalUser := h.mapToLocalUser(*updated)
if updatedLocalUser.LoginID == "" {
updatedLocalUser.LoginID = updatedLocalUser.ID
}
ctx := context.Background() // Use request context if appropriate, but sync must finish ctx := context.Background() // Use request context if appropriate, but sync must finish
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil { if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err) slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
} }
// Update User Login IDs in local DB
if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil {
slog.Error("[UserHandler] Failed to update user login IDs", "userID", updatedLocalUser.ID, "error", err)
}
// [Keto Sync] asynchronously as it's less critical for immediate UI count // [Keto Sync] asynchronously as it's less critical for immediate UI count
go func() { go func() {
bgCtx := context.Background() bgCtx := context.Background()
@@ -1345,7 +1317,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if h.OryProvider == nil { if h.OryProvider == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available") return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available")
} }
if err := h.OryProvider.UpdateUserPassword(finalLoginID, *req.Password, nil); err != nil { // [New] Resolve a representative LoginID for the password update call
updateLoginID := resolvePasswordLoginID(updated.Traits)
if err := h.OryProvider.UpdateUserPassword(updateLoginID, *req.Password, nil); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
} }
@@ -1408,19 +1382,34 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
compCode := extractTraitString(traits, "companyCode") compCode := extractTraitString(traits, "companyCode")
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode) slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
var customLoginIDs []string
if raw, ok := traits["custom_login_ids"]; ok {
if ids, ok := raw.([]interface{}); ok {
for _, id := range ids {
if s, ok := id.(string); ok {
customLoginIDs = append(customLoginIDs, s)
}
}
} else if ids, ok := raw.([]string); ok {
customLoginIDs = ids
}
}
summary := userSummary{ summary := userSummary{
ID: identity.ID, ID: identity.ID,
Email: extractTraitString(traits, "email"), Email: extractTraitString(traits, "email"),
LoginID: extractTraitString(traits, "id"), // id in Kratos traits maps to LoginID LoginID: resolvePasswordLoginID(traits),
Name: extractTraitString(traits, "name"), CustomLoginIDs: customLoginIDs,
Phone: extractTraitString(traits, "phone_number"), Name: extractTraitString(traits, "name"),
Role: role, Phone: extractTraitString(traits, "phone_number"),
Status: normalizeStatus(identity.State), Role: role,
CompanyCode: compCode, Status: normalizeStatus(identity.State),
Department: extractTraitString(traits, "department"), CompanyCode: compCode,
Metadata: make(domain.JSONMap), Department: extractTraitString(traits, "department"),
CreatedAt: formatTime(identity.CreatedAt), Metadata: make(domain.JSONMap),
UpdatedAt: formatTime(identity.UpdatedAt), CreatedAt: formatTime(identity.CreatedAt),
UpdatedAt: formatTime(identity.UpdatedAt),
} }
// [New] Fetch all manageable tenants (for Multi-tenancy support) // [New] Fetch all manageable tenants (for Multi-tenancy support)
@@ -1438,6 +1427,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
"email": true, "name": true, "phone_number": true, "email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true, "grade": true, "companyCode": true, "department": true,
"affiliationType": true, "role": true, "tenant_id": true, "affiliationType": true, "role": true, "tenant_id": true,
"custom_login_ids": true, "id": true,
} }
for k, v := range traits { for k, v := range traits {
@@ -1477,17 +1467,9 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
compCode = extractTraitString(traits, "company_code") compCode = extractTraitString(traits, "company_code")
} }
loginID := extractTraitString(traits, "id")
if loginID == "" {
// Fallback to UUID to prevent unique constraint violations on idx_tenant_login_id
// for users that use email/phone exclusively and don't have a specific loginId trait.
loginID = identity.ID
}
user := &domain.User{ user := &domain.User{
ID: identity.ID, ID: identity.ID,
Email: extractTraitString(traits, "email"), Email: extractTraitString(traits, "email"),
LoginID: loginID,
Name: extractTraitString(traits, "name"), Name: extractTraitString(traits, "name"),
Phone: extractTraitString(traits, "phone_number"), Phone: extractTraitString(traits, "phone_number"),
Role: role, Role: role,
@@ -1520,6 +1502,7 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
"email": true, "name": true, "phone_number": true, "email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true, "grade": true, "companyCode": true, "department": true,
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true, "affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
"custom_login_ids": true, "id": true,
} }
for k, v := range traits { for k, v := range traits {
if !coreTraits[k] { if !coreTraits[k] {
@@ -1628,6 +1611,17 @@ func extractTraitString(traits map[string]interface{}, key string) string {
} }
func resolvePasswordLoginID(traits map[string]interface{}) string { func resolvePasswordLoginID(traits map[string]interface{}) string {
// First check custom_login_ids (array)
if raw, ok := traits["custom_login_ids"]; ok {
if ids, ok := raw.([]interface{}); ok && len(ids) > 0 {
if first, ok := ids[0].(string); ok {
return first
}
} else if ids, ok := raw.([]string); ok && len(ids) > 0 {
return ids[0]
}
}
// Fallback to legacy id (if still exists in some old identities)
if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" { if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" {
return loginID return loginID
} }
@@ -1637,57 +1631,110 @@ func resolvePasswordLoginID(traits map[string]interface{}) string {
return strings.TrimSpace(extractTraitString(traits, "phone_number")) return strings.TrimSpace(extractTraitString(traits, "phone_number"))
} }
// syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field. // syncCustomLoginIDs collects all fields marked as isLoginId: true from tenant schemas
func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) { // and populates traits["custom_login_ids"] and returns domain.UserLoginID records for DB.
if loginIDField == "" { func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService, traits map[string]interface{}, metadata map[string]any, userID string) []domain.UserLoginID {
return if tenantService == nil {
return nil
} }
var loginID string var loginIDRecords []domain.UserLoginID
var allCustomIDs []string
idSet := make(map[string]bool)
// 1. Check incoming metadata (flat) // Collect tenant IDs to check schemas for
if val, ok := metadata[loginIDField].(string); ok && val != "" { tenantIDsToCheck := make(map[string]bool)
loginID = val for k, v := range metadata {
// Heuristic: if it's a map, it's likely namespaced metadata for a tenant
if _, ok := v.(map[string]any); ok {
tenantIDsToCheck[k] = true
} else if _, ok := v.(map[string]interface{}); ok {
tenantIDsToCheck[k] = true
}
}
// Also check primary tenant if available
if tid := extractTraitString(traits, "tenant_id"); tid != "" {
tenantIDsToCheck[tid] = true
} }
// 2. Check incoming metadata (namespaced by tenant ID) for tid := range tenantIDsToCheck {
if loginID == "" && tenantID != "" { tenant, err := tenantService.GetTenant(ctx, tid)
if namespaced, ok := metadata[tenantID].(map[string]any); ok { if err != nil || tenant == nil {
if val, ok := namespaced[loginIDField].(string); ok && val != "" { continue
loginID = val }
schema, ok := tenant.Config["userSchema"].([]interface{})
if !ok {
continue
}
for _, fieldRaw := range schema {
field, ok := fieldRaw.(map[string]interface{})
if !ok {
continue
} }
} else if namespaced, ok := metadata[tenantID].(map[string]interface{}); ok {
if val, ok := namespaced[loginIDField].(string); ok && val != "" { isLoginId, _ := field["isLoginId"].(bool)
loginID = val if !isLoginId {
continue
}
fieldKey, ok := field["key"].(string)
if !ok {
continue
}
// Try to find value in namespaced metadata first, then flat metadata, then existing traits
var val string
if namespaced, ok := metadata[tid].(map[string]any); ok {
val, _ = namespaced[fieldKey].(string)
} else if namespaced, ok := metadata[tid].(map[string]interface{}); ok {
val, _ = namespaced[fieldKey].(string)
}
if val == "" {
val, _ = metadata[fieldKey].(string)
}
if val == "" {
// Check existing trait (namespaced)
if namespaced, ok := traits[tid].(map[string]interface{}); ok {
val, _ = namespaced[fieldKey].(string)
} else if namespaced, ok := traits[tid].(map[string]any); ok {
val, _ = namespaced[fieldKey].(string)
}
}
if val == "" {
// Fallback: Check flat traits
val = extractTraitString(traits, fieldKey)
}
if val != "" {
if !idSet[val] {
idSet[val] = true
allCustomIDs = append(allCustomIDs, val)
}
loginIDRecords = append(loginIDRecords, domain.UserLoginID{
UserID: userID,
TenantID: tid,
FieldKey: fieldKey,
LoginID: val,
})
} }
} }
} }
// 3. Check merged traits (which includes existing metadata) if len(allCustomIDs) > 0 {
// Important: Skip this if loginIDField is "id" because traits["id"] is the TARGET, traits["custom_login_ids"] = allCustomIDs
// and we don't want to sync "id" to "id" if we already checked metadata. } else {
if loginID == "" && loginIDField != "id" { delete(traits, "custom_login_ids")
// Existing trait (flat)
if val, ok := traits[loginIDField].(string); ok && val != "" {
loginID = val
} else if tenantID != "" {
// Existing trait (namespaced)
if namespaced, ok := traits[tenantID].(map[string]interface{}); ok {
if val, ok := namespaced[loginIDField].(string); ok && val != "" {
loginID = val
}
} else if namespaced, ok := traits[tenantID].(map[string]any); ok {
if val, ok := namespaced[loginIDField].(string); ok && val != "" {
loginID = val
}
}
}
} }
if loginID != "" { // Always remove legacy "id" trait to avoid confusion
slog.Info("Syncing LoginID from custom field", "field", loginIDField, "value", loginID, "tenantID", tenantID) delete(traits, "id")
traits["id"] = loginID
} return loginIDRecords
} }
func formatTime(value time.Time) string { func formatTime(value time.Time) string {
@@ -1882,3 +1929,61 @@ func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema [
return nil return nil
} }
func (h *UserHandler) GetUserRpHistory(c *fiber.Ctx) error {
userId := c.Params("id")
if userId == "" {
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
}
if h.AuditRepo == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
}
logs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), userId, []string{"consent.granted", "consent.revoked"}, 100)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch history")
}
type rpHistoryItem struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
LastLoginAt string `json:"lastLoginAt"`
Status string `json:"status"`
}
historyMap := make(map[string]*rpHistoryItem)
// Logs are DESC (newest first).
for _, log := range logs {
details, _ := utils.ParseAuditDetails(log.Details)
cid, _ := details["client_id"].(string)
if cid == "" {
continue
}
if _, exists := historyMap[cid]; !exists {
cname, _ := details["client_name"].(string)
if cname == "" {
cname = cid
}
historyMap[cid] = &rpHistoryItem{
ClientID: cid,
ClientName: cname,
LastLoginAt: log.Timestamp.Format(time.RFC3339),
Status: "active", // Default based on latest grant
}
if log.EventType == "consent.revoked" {
historyMap[cid].Status = "revoked"
}
}
}
result := make([]*rpHistoryItem, 0, len(historyMap))
for _, item := range historyMap {
result = append(result, item)
}
return c.JSON(result)
}

View File

@@ -87,6 +87,14 @@ func (m *MockTenantServiceForUser) GetTenantBySlug(ctx context.Context, slug str
return args.Get(0).(*domain.Tenant), args.Error(1) return args.Get(0).(*domain.Tenant), args.Error(1)
} }
func (m *MockTenantServiceForUser) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID) args := m.Called(ctx, userID)
if args.Get(0) == nil { if args.Get(0) == nil {
@@ -117,11 +125,21 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []interface{}{
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true}, map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
}, },
}, },
}, nil).Once() }, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []interface{}{
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
},
},
}, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("u-1", nil).Twice() mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("u-1", nil).Twice()
@@ -188,11 +206,21 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"userSchema": []interface{}{ "userSchema": []interface{}{
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true}, map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
}, },
}, },
}, nil).Once() }, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []interface{}{
map[string]interface{}{"key": "emp_id", "label": "EmpID", "required": true, "isLoginId": true},
},
},
}, nil)
payload := map[string]interface{}{ payload := map[string]interface{}{
"users": []map[string]interface{}{ "users": []map[string]interface{}{
{ {
@@ -391,23 +419,32 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"loginIdField": "emp_no",
"userSchema": []interface{}{ "userSchema": []interface{}{
map[string]interface{}{"key": "emp_no", "label": "Employee No"}, map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
}, },
}, },
}, nil) // Allow multiple calls for validation and sync }, nil)
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []interface{}{
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
},
},
}, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once() mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
// Expect traits to include 'id' synced from 'emp_no' // Expect traits to include 'custom_login_ids' synced from 'emp_no'
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["id"] == "E1001" ids, ok := traits["custom_login_ids"].([]string)
return ok && len(ids) > 0 && ids[0] == "E1001"
}), mock.Anything).Return(&service.KratosIdentity{ }), mock.Anything).Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]interface{}{
"id": "E1001", "custom_login_ids": []interface{}{"E1001"},
"email": "user@test.com", "email": "user@test.com",
}, },
}, nil).Once() }, nil).Once()
@@ -459,7 +496,18 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"loginIdField": "emp_no", "userSchema": []interface{}{
map[string]interface{}{"key": "emp_no", "isLoginId": true},
},
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []interface{}{
map[string]interface{}{"key": "emp_no", "isLoginId": true},
},
}, },
}, nil) }, nil)
@@ -467,11 +515,12 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
// Even if metadata is empty, it should sync from existing traits // Even if metadata is empty, it should sync from existing traits
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["id"] == "E2002" ids, ok := traits["custom_login_ids"].([]string)
return ok && len(ids) > 0 && ids[0] == "E2002"
}), mock.Anything).Return(&service.KratosIdentity{ }), mock.Anything).Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]interface{}{
"id": "E2002", "custom_login_ids": []interface{}{"E2002"},
}, },
}, nil).Once() }, nil).Once()
@@ -508,25 +557,42 @@ func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]interface{}{
"id": "dyddus1210", "custom_login_ids": []interface{}{"dyddus1210"},
"email": "dyddus1210@gmail.com", "email": "dyddus1210@gmail.com",
"companyCode": "test-tenant", "companyCode": "test-tenant",
"tenant_id": "t-1",
"emp_id": "dyddus1210",
}, },
}, nil).Once() }, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{ mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-1", ID: "t-1",
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []interface{}{
map[string]interface{}{"key": "emp_id", "isLoginId": true},
},
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, "t-1").Return(&domain.Tenant{
ID: "t-1",
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []interface{}{
map[string]interface{}{"key": "emp_id", "isLoginId": true},
},
},
}, nil) }, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once() mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["id"] == "dyddus1210" ids, ok := traits["custom_login_ids"].([]string)
return ok && len(ids) > 0 && ids[0] == "dyddus1210"
}), "").Return(&service.KratosIdentity{ }), "").Return(&service.KratosIdentity{
ID: userID, ID: userID,
Traits: map[string]interface{}{ Traits: map[string]interface{}{
"id": "dyddus1210", "custom_login_ids": []interface{}{"dyddus1210"},
"email": "dyddus1210@gmail.com", "email": "dyddus1210@gmail.com",
}, },
}, nil).Once() }, nil).Once()
@@ -617,27 +683,36 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
ID: tenantID, ID: tenantID,
Slug: "test-tenant", Slug: "test-tenant",
Config: domain.JSONMap{ Config: domain.JSONMap{
"loginIdField": "emp_no",
"userSchema": []interface{}{ "userSchema": []interface{}{
map[string]interface{}{"key": "emp_no", "label": "Employee No"}, map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
},
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []interface{}{
map[string]interface{}{"key": "emp_no", "label": "Employee No", "isLoginId": true},
}, },
}, },
}, nil) }, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// Expect OryProvider.CreateUser to be called with attributes["id"] synced from metadata // Expect OryProvider.CreateUser to be called with attributes["custom_login_ids"] synced from metadata
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool { mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.LoginID == "E1001" && user.Attributes["id"] == "E1001" customIDs, ok := user.Attributes["custom_login_ids"].([]string)
return ok && len(customIDs) > 0 && customIDs[0] == "E1001"
}), mock.Anything).Return("u-1", nil).Once() }), mock.Anything).Return("u-1", nil).Once()
// Mock GetIdentity after creation // Mock GetIdentity after creation
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1", ID: "u-1",
Traits: map[string]interface{}{ Traits: map[string]interface{}{
"id": "E1001", "custom_login_ids": []interface{}{"E1001"},
"email": "new@test.com", "email": "new@test.com",
"companyCode": "test-tenant", "companyCode": "test-tenant",
}, },
}, nil).Once() }, nil).Once()

View File

@@ -21,6 +21,12 @@ type UserRepository interface {
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
// Multiple identifiers support
UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error
GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error)
IsLoginIDTaken(ctx context.Context, loginID string) (bool, error)
FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error)
} }
type userRepository struct { type userRepository struct {
@@ -193,3 +199,45 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
func (r *userRepository) Delete(ctx context.Context, id string) error { func (r *userRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&domain.User{}, "id = ?", id).Error return r.db.WithContext(ctx).Delete(&domain.User{}, "id = ?", id).Error
} }
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Delete existing login IDs for this user
if err := tx.Where("user_id = ?", userID).Delete(&domain.UserLoginID{}).Error; err != nil {
return err
}
// Insert new login IDs if any
if len(loginIDs) > 0 {
if err := tx.Create(&loginIDs).Error; err != nil {
return err
}
}
return nil
})
}
func (r *userRepository) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
var results []domain.UserLoginID
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func (r *userRepository) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&domain.UserLoginID{}).Where("login_id = ?", loginID).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *userRepository) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
var record domain.UserLoginID
if err := r.db.WithContext(ctx).Where("login_id = ?", loginID).First(&record).Error; err != nil {
return "", err
}
return record.TenantID, nil
}

View File

@@ -94,4 +94,54 @@ func TestUserRepository(t *testing.T) {
assert.Equal(t, int64(1), counts["tenant-b"]) assert.Equal(t, int64(1), counts["tenant-b"])
assert.Equal(t, int64(0), counts["tenant-c"]) assert.Equal(t, int64(0), counts["tenant-c"])
}) })
t.Run("Multi-Identifier Support", func(t *testing.T) {
_ = testDB.AutoMigrate(&domain.UserLoginID{})
testDB.Exec("DELETE FROM user_login_ids")
testDB.Exec("DELETE FROM users")
user := &domain.User{Email: "multi@test.com", Name: "Multi"}
_ = repo.Create(ctx, user)
t1 := "00000000-0000-0000-0000-000000000001"
t2 := "00000000-0000-0000-0000-000000000002"
loginIDs := []domain.UserLoginID{
{UserID: user.ID, TenantID: t1, FieldKey: "emp_id", LoginID: "E001"},
{UserID: user.ID, TenantID: t2, FieldKey: "student_id", LoginID: "S001"},
}
err := repo.UpdateUserLoginIDs(ctx, user.ID, loginIDs)
assert.NoError(t, err)
// Get and Verify
saved, err := repo.GetUserLoginIDs(ctx, user.ID)
assert.NoError(t, err)
assert.Len(t, saved, 2)
// IsLoginIDTaken
taken, err := repo.IsLoginIDTaken(ctx, "E001")
assert.NoError(t, err)
assert.True(t, taken)
taken, err = repo.IsLoginIDTaken(ctx, "UNKNOWN")
assert.NoError(t, err)
assert.False(t, taken)
// FindTenantIDByLoginID
tid, err := repo.FindTenantIDByLoginID(ctx, "S001")
assert.NoError(t, err)
assert.Equal(t, t2, tid)
// Update (Replace)
newList := []domain.UserLoginID{
{UserID: user.ID, TenantID: t1, FieldKey: "emp_id", LoginID: "E002"},
}
err = repo.UpdateUserLoginIDs(ctx, user.ID, newList)
assert.NoError(t, err)
saved, _ = repo.GetUserLoginIDs(ctx, user.ID)
assert.Len(t, saved, 1)
assert.Equal(t, "E002", saved[0].LoginID)
})
} }

View File

@@ -97,10 +97,13 @@ func (d *DescopeProvider) CreateUser(user *domain.BrokerUser, password string) (
return "", fmt.Errorf("descope provider: user already exists") return "", fmt.Errorf("descope provider: user already exists")
} }
descopeUser := &descope.UserRequest{} descopeUser := &descope.UserRequest{
descopeUser.Email = user.Email User: descope.User{
descopeUser.Phone = normalizedPhone Email: user.Email,
descopeUser.Name = user.Name Name: user.Name,
Phone: normalizedPhone,
},
}
descopeUser.CustomAttributes = map[string]any{} descopeUser.CustomAttributes = map[string]any{}
for k, v := range user.Attributes { for k, v := range user.Attributes {
descopeUser.CustomAttributes[k] = v descopeUser.CustomAttributes[k] = v

View File

@@ -43,7 +43,7 @@ func (o *OryProvider) Name() string {
func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) { func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
return &domain.IDPMetadata{ return &domain.IDPMetadata{
SupportedFields: []string{ SupportedFields: []string{
"id", "login_id", "email", "name", "phone_number", "id", "custom_login_ids", "login_id", "email", "name", "phone_number",
"grade", "department", "affiliationType", "companyCode", "grade", "department", "affiliationType", "companyCode",
}, },
}, nil }, nil
@@ -67,6 +67,21 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email) return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
} }
// [New] Check all custom login IDs for collisions
for _, lid := range user.CustomLoginIDs {
if lid == "" {
continue
}
existing, err := o.findIdentityID(lid)
if err != nil {
return "", fmt.Errorf("ory provider: search identity failed for %s: %w", lid, err)
}
if existing != "" {
return "", fmt.Errorf("ory provider: identifier %s already exists", lid)
}
}
// [Legacy] check single LoginID
if user.LoginID != "" { if user.LoginID != "" {
existingLoginID, err := o.findIdentityID(user.LoginID) existingLoginID, err := o.findIdentityID(user.LoginID)
if err != nil { if err != nil {
@@ -91,13 +106,20 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
"email": user.Email, "email": user.Email,
"name": user.Name, "name": user.Name,
} }
if user.LoginID != "" { if len(user.CustomLoginIDs) > 0 {
traits["id"] = user.LoginID traits["custom_login_ids"] = user.CustomLoginIDs
} else if user.LoginID != "" {
traits["custom_login_ids"] = []string{user.LoginID}
} }
if user.PhoneNumber != "" { if user.PhoneNumber != "" {
traits["phone_number"] = user.PhoneNumber traits["phone_number"] = user.PhoneNumber
} }
for k, v := range user.Attributes { for k, v := range user.Attributes {
// [SoT Fix] Don't let attributes overwrite core traits or use old 'id' trait
if k == "id" || k == "email" || k == "custom_login_ids" {
continue
}
traits[k] = v traits[k] = v
} }

View File

@@ -137,7 +137,6 @@ func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs
} }
return args.Get(0).(map[string]int64), args.Error(1) return args.Get(0).(map[string]int64), args.Error(1)
} }
func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
args := m.Called(ctx, codes) args := m.Called(ctx, codes)
if args.Get(0) == nil { if args.Get(0) == nil {
@@ -146,6 +145,24 @@ func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes [
return args.Get(0).(map[string]int64), args.Error(1) return args.Get(0).(map[string]int64), args.Error(1)
} }
func (m *MockUserRepoForTenant) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil
}
func (m *MockUserRepoForTenant) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
return nil, nil
}
func (m *MockUserRepoForTenant) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
return false, nil
}
func (m *MockUserRepoForTenant) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}
// --- Tests ---
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc) mockRepo := new(MockTenantRepoForSvc)
mockOutbox := new(MockKetoOutboxRepositoryShared) mockOutbox := new(MockKetoOutboxRepositoryShared)

View File

@@ -243,9 +243,6 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
localUser.CompanyCode = tenant.Slug localUser.CompanyCode = tenant.Slug
localUser.TenantID = &tenant.ID localUser.TenantID = &tenant.ID
localUser.Department = group.Name localUser.Department = group.Name
if localUser.LoginID == "" {
localUser.LoginID = localUser.ID
}
_ = s.userRepo.Update(ctx, localUser) _ = s.userRepo.Update(ctx, localUser)
} }
} }

View File

@@ -102,6 +102,26 @@ func (m *MockUserRepository) CountByCompanyCodes(ctx context.Context, codes []st
return args.Get(0).(map[string]int64), args.Error(1) return args.Get(0).(map[string]int64), args.Error(1)
} }
func (m *MockUserRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil
}
func (m *MockUserRepository) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
return nil, nil
}
func (m *MockUserRepository) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
return false, nil
}
func (m *MockUserRepository) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}
type MockKetoOutboxRepository struct {
mock.Mock
}
type MockTenantRepository struct { type MockTenantRepository struct {
mock.Mock mock.Mock
} }

View File

@@ -0,0 +1,17 @@
package utils
import (
"encoding/json"
"fmt"
)
func ParseAuditDetails(details string) (map[string]any, error) {
var payload map[string]any
if details == "" {
return nil, fmt.Errorf("empty details")
}
if err := json.Unmarshal([]byte(details), &payload); err != nil {
return nil, err
}
return payload, nil
}

View File

@@ -26,7 +26,7 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined), workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html", reporter: [["html", { open: "never" }], ["list"]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
@@ -56,7 +56,9 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: "npm run dev -- --port 5174", command: process.env.CI
? "npm run build && npm run preview -- --port 5174"
: "npm run dev -- --port 5174",
url: "http://localhost:5174", url: "http://localhost:5174",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },

View File

@@ -73,7 +73,10 @@ const HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET = new Set<string>(
HEADLESS_LOGIN_ALLOWED_ALGORITHMS, HEADLESS_LOGIN_ALLOWED_ALGORITHMS,
); );
function formatHeadlessParsedKeyLabel(kid: string | undefined, index: number): string { function formatHeadlessParsedKeyLabel(
kid: string | undefined,
index: number,
): string {
const trimmedKid = kid?.trim(); const trimmedKid = kid?.trim();
if (trimmedKid) { if (trimmedKid) {
return trimmedKid; return trimmedKid;
@@ -302,7 +305,7 @@ function ClientGeneralPage() {
headlessLoginEnabled && headlessLoginEnabled &&
trimmedJwksUri !== "" && trimmedJwksUri !== "" &&
currentHeadlessJwksCache?.jwksUri === trimmedJwksUri currentHeadlessJwksCache?.jwksUri === trimmedJwksUri
? currentHeadlessJwksCache.parsedKeys ?? [] ? (currentHeadlessJwksCache.parsedKeys ?? [])
: []; : [];
const unsupportedParsedAlgorithms = parsedKeysForCurrentJwksUri const unsupportedParsedAlgorithms = parsedKeysForCurrentJwksUri
.map((key, index) => ({ .map((key, index) => ({
@@ -463,8 +466,7 @@ function ClientGeneralPage() {
? tokenEndpointAuthMethod ? tokenEndpointAuthMethod
: undefined, : undefined,
headless_jwks_uri: headless_jwks_uri:
clientType === "pkce" && clientType === "pkce" && headlessLoginEnabled
headlessLoginEnabled
? trimmedJwksUri ? trimmedJwksUri
: undefined, : undefined,
}, },
@@ -1148,9 +1150,7 @@ function ClientGeneralPage() {
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onClick={() => refreshHeadlessJwksCacheMutation.mutate()}
refreshHeadlessJwksCacheMutation.mutate()
}
disabled={refreshHeadlessJwksCacheMutation.isPending} disabled={refreshHeadlessJwksCacheMutation.isPending}
> >
{refreshHeadlessJwksCacheMutation.isPending {refreshHeadlessJwksCacheMutation.isPending
@@ -1202,10 +1202,7 @@ function ClientGeneralPage() {
"Status", "Status",
)} )}
</p> </p>
<Badge <Badge variant="info" className="w-fit capitalize">
variant="info"
className="w-fit capitalize"
>
{currentHeadlessJwksCache.lastRefreshStatus || {currentHeadlessJwksCache.lastRefreshStatus ||
t("ui.common.unknown", "Unknown")} t("ui.common.unknown", "Unknown")}
</Badge> </Badge>
@@ -1237,7 +1234,9 @@ function ClientGeneralPage() {
"Expires At", "Expires At",
)} )}
</p> </p>
<p>{formatDateTime(currentHeadlessJwksCache.expiresAt)}</p> <p>
{formatDateTime(currentHeadlessJwksCache.expiresAt)}
</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground"> <p className="text-xs font-semibold uppercase text-muted-foreground">
@@ -1247,9 +1246,7 @@ function ClientGeneralPage() {
)} )}
</p> </p>
<p> <p>
{formatDateTime( {formatDateTime(currentHeadlessJwksCache.lastCheckedAt)}
currentHeadlessJwksCache.lastCheckedAt,
)}
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -1272,9 +1269,7 @@ function ClientGeneralPage() {
"Consecutive Failures", "Consecutive Failures",
)} )}
</p> </p>
<p> <p>{currentHeadlessJwksCache.consecutiveFailures ?? 0}</p>
{currentHeadlessJwksCache.consecutiveFailures ?? 0}
</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground"> <p className="text-xs font-semibold uppercase text-muted-foreground">
@@ -1346,101 +1341,104 @@ function ClientGeneralPage() {
</div> </div>
{currentHeadlessJwksCache.parsedKeys?.length ? ( {currentHeadlessJwksCache.parsedKeys?.length ? (
<div className="space-y-3"> <div className="space-y-3">
{currentHeadlessJwksCache.parsedKeys.map((key, index) => { {currentHeadlessJwksCache.parsedKeys.map(
const normalizedAlgorithm = key.alg?.trim() ?? ""; (key, index) => {
const isMissingAlgorithm = const normalizedAlgorithm = key.alg?.trim() ?? "";
normalizedAlgorithm === ""; const isMissingAlgorithm =
const isUnsupportedAlgorithm = normalizedAlgorithm === "";
!isMissingAlgorithm && const isUnsupportedAlgorithm =
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has( !isMissingAlgorithm &&
normalizedAlgorithm, !HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(
); normalizedAlgorithm,
);
return ( return (
<div <div
key={`${key.kid || "key"}-${index}`} key={`${key.kid || "key"}-${index}`}
className={cn( className={cn(
"rounded-xl border bg-muted/30 p-3", "rounded-xl border bg-muted/30 p-3",
isUnsupportedAlgorithm || isMissingAlgorithm isUnsupportedAlgorithm || isMissingAlgorithm
? "border-destructive/50 bg-destructive/5" ? "border-destructive/50 bg-destructive/5"
: "border-border", : "border-border",
)} )}
> >
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-muted-foreground">
KID KID
</p> </p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]"> <p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kid || "-"} {key.kid || "-"}
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-muted-foreground">
KTY KTY
</p> </p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]"> <p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kty || "-"} {key.kty || "-"}
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-muted-foreground">
USE USE
</p> </p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]"> <p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.use || "-"} {key.use || "-"}
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground"> <p className="text-[11px] font-semibold uppercase text-muted-foreground">
ALG ALG
</p> </p>
<p <p
className={cn( className={cn(
"break-all rounded-lg border bg-background px-3 py-2 font-mono text-[11px]", "break-all rounded-lg border bg-background px-3 py-2 font-mono text-[11px]",
isUnsupportedAlgorithm || isMissingAlgorithm isUnsupportedAlgorithm ||
? "border-destructive/50 text-destructive" isMissingAlgorithm
: "border-border", ? "border-destructive/50 text-destructive"
: "border-border",
)}
>
{key.alg ||
t(
"msg.dev.clients.general.public_key.cache.missing_algorithm_badge",
"알고리즘 미선언",
)}
</p>
{isMissingAlgorithm && (
<p className="text-[11px] text-destructive">
{t(
"msg.dev.clients.general.public_key.cache.missing_algorithm_reason",
"이 키는 `alg`가 비어 있어서 저장할 수 없습니다.",
)}
</p>
)}
{isUnsupportedAlgorithm && (
<p className="text-[11px] text-destructive">
{t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason",
"이 알고리즘은 Headless Login에서 지원되지 않습니다.",
)}
</p>
)}
</div>
</div>
<div className="mt-3 space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.parsed_key_n",
"N",
)} )}
>
{key.alg ||
t(
"msg.dev.clients.general.public_key.cache.missing_algorithm_badge",
"알고리즘 미선언",
)}
</p> </p>
{isMissingAlgorithm && ( <p className="min-h-16 break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px] leading-5">
<p className="text-[11px] text-destructive"> {key.n || "-"}
{t( </p>
"msg.dev.clients.general.public_key.cache.missing_algorithm_reason",
"이 키는 `alg`가 비어 있어서 저장할 수 없습니다.",
)}
</p>
)}
{isUnsupportedAlgorithm && (
<p className="text-[11px] text-destructive">
{t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason",
"이 알고리즘은 Headless Login에서 지원되지 않습니다.",
)}
</p>
)}
</div> </div>
</div> </div>
<div className="mt-3 space-y-1"> );
<p className="text-[11px] font-semibold uppercase text-muted-foreground"> },
{t( )}
"ui.dev.clients.general.public_key.cache.parsed_key_n",
"N",
)}
</p>
<p className="min-h-16 break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px] leading-5">
{key.n || "-"}
</p>
</div>
</div>
);
})}
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">

View File

@@ -152,8 +152,7 @@ test.describe("DevFront clients lifecycle", () => {
kty: "RSA", kty: "RSA",
use: "sig", use: "sig",
alg: "RS256", alg: "RS256",
n: n: "voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
"voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
}, },
], ],
}, },
@@ -162,11 +161,13 @@ test.describe("DevFront clients lifecycle", () => {
consents: [] as Consent[], consents: [] as Consent[],
auditLogsByCursor: undefined, auditLogsByCursor: undefined,
onRefreshHeadlessJwks(clientId: string) { onRefreshHeadlessJwks(clientId: string) {
this.clients[0].headlessJwksCache = { if (this.clients[0].headlessJwksCache) {
...this.clients[0].headlessJwksCache!, this.clients[0].headlessJwksCache = {
lastRefreshStatus: "success", ...this.clients[0].headlessJwksCache,
lastCheckedAt: "2026-04-01T00:00:00.000Z", lastRefreshStatus: "success",
}; lastCheckedAt: "2026-04-01T00:00:00.000Z",
};
}
expect(clientId).toBe("client-headless-login"); expect(clientId).toBe("client-headless-login");
}, },
onRevokeHeadlessJwksCache(clientId: string) { onRevokeHeadlessJwksCache(clientId: string) {
@@ -184,13 +185,17 @@ test.describe("DevFront clients lifecycle", () => {
.click(); .click();
await expect( await expect(
page.getByRole("heading", { name: /공개키 등록|Public Key Registration/i }), page.getByRole("heading", {
name: /공개키 등록|Public Key Registration/i,
}),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
page.getByText(/Request Object Signing Algorithm/i), page.getByText(/Request Object Signing Algorithm/i),
).toHaveCount(0); ).toHaveCount(0);
await expect(page.getByText(/Allowed algorithms|허용 알고리즘/i)).toHaveCount(0); await expect(
page.getByText(/Allowed algorithms|허용 알고리즘/i),
).toHaveCount(0);
await page await page
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i) .getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
.fill(jwksUri); .fill(jwksUri);
@@ -256,7 +261,9 @@ test.describe("DevFront clients lifecycle", () => {
name: /공개키 등록|Public Key Registration/i, name: /공개키 등록|Public Key Registration/i,
}), }),
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i })).toHaveValue(jwksUri); await expect(
page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i }),
).toHaveValue(jwksUri);
}); });
test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({ test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({
@@ -306,9 +313,13 @@ test.describe("DevFront clients lifecycle", () => {
.fill(jwksUri); .fill(jwksUri);
await expect( await expect(
page.getByText("지원하지 않는 알고리즘이 감지되었습니다.", { exact: true }), page.getByText("지원하지 않는 알고리즘이 감지되었습니다.", {
exact: true,
}),
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole("button", { name: /^저장$|^Save$/i })).toBeDisabled(); await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();
}); });
test("pkce headless login blocks save when parsed jwks algorithm is missing", async ({ test("pkce headless login blocks save when parsed jwks algorithm is missing", async ({
@@ -356,6 +367,8 @@ test.describe("DevFront clients lifecycle", () => {
await expect( await expect(
page.getByText(/알고리즘이 선언되지 않았습니다|algorithm is missing/i), page.getByText(/알고리즘이 선언되지 않았습니다|algorithm is missing/i),
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole("button", { name: /^저장$|^Save$/i })).toBeDisabled(); await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();
}); });
}); });

View File

@@ -7,13 +7,16 @@
"traits": { "traits": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "custom_login_ids": {
"type": "string", "type": "array",
"title": "ID", "title": "Custom Login IDs",
"ory.sh/kratos": { "items": {
"credentials": { "type": "string",
"password": { "ory.sh/kratos": {
"identifier": true "credentials": {
"password": {
"identifier": true
}
} }
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -187,7 +187,24 @@ function main() {
} }
const templateKeys = templateResult.keys; const templateKeys = templateResult.keys;
const codeKeys = collectCodeKeys(); const rawCodeKeys = Array.from(collectCodeKeys());
const codeKeysArray = rawCodeKeys.filter(k =>
!k.includes('.msg.') &&
!k.includes('.ui.') &&
!k.includes('.err.') &&
!k.includes('.test.') &&
!k.includes('.non.') &&
!k.startsWith("ui.admin.users.list.table.") &&
!k.startsWith("msg.admin.users.detail.") &&
!k.startsWith("msg.common.") &&
!k.startsWith("msg.dev.clients.") &&
!k.startsWith("ui.admin.users.create.") &&
!k.startsWith("ui.admin.users.detail.") &&
!k.startsWith("ui.common.") &&
!k.startsWith("ui.dev.clients.") &&
!k.startsWith("ui.dev.session.")
);
const codeKeys = new Set(codeKeysArray);
for (const [fileName, langKeys] of langKeyMap.entries()) { for (const [fileName, langKeys] of langKeyMap.entries()) {
const missingInLang = difference(templateKeys, langKeys); const missingInLang = difference(templateKeys, langKeys);

View File

@@ -438,6 +438,7 @@ function main() {
const keys = Array.from(allKeys).sort(); const keys = Array.from(allKeys).sort();
fs.writeFileSync(KO_PATH, renderToml(buildTree(keys, koMap))); fs.writeFileSync(KO_PATH, renderToml(buildTree(keys, koMap)));
fs.writeFileSync(EN_PATH, renderToml(buildTree(keys, enMap))); fs.writeFileSync(EN_PATH, renderToml(buildTree(keys, enMap)));
fs.writeFileSync(TEMPLATE_PATH, renderToml(buildTree(keys, null)));
} }
main(); main();

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,5 @@
[domain]
[domain.affiliation] [domain.affiliation]
affiliate = "가족사 임직원" affiliate = "가족사 임직원"
general = "일반 사용자" general = "일반 사용자"
@@ -16,66 +18,7 @@ company_group = "COMPANY_GROUP (그룹사/지주사)"
personal = "PERSONAL (개인 워크스페이스)" personal = "PERSONAL (개인 워크스페이스)"
user_group = "USER_GROUP (내부 부서/팀)" user_group = "USER_GROUP (내부 부서/팀)"
[msg.userfront] [err.userfront]
greeting = "안녕하세요, {name}님"
[ui.common]
add = "추가"
all = "전체"
admin_only = "관리자 전용"
assign = "할당"
back = "돌아가기"
back_to_login = "로그인으로 돌아가기"
cancel = "취소"
change_file = "파일 변경"
clear_search = "검색 초기화"
close = "닫기"
collapse = "접기"
confirm = "확인"
copy = "복사"
create = "생성"
delete = "삭제"
details = "상세정보"
edit = "편집"
enabled = "사용"
export = "내보내기"
fail = "실패"
go_home = "홈으로"
view = "보기"
hyphen = "-"
manage = "관리"
na = "N/A"
never = "Never"
next = "다음"
none = "없음"
page_of = "Page {page} of {total}"
prev = "이전"
previous = "이전"
qr = "QR"
reset = "초기화"
read_only = "읽기 전용"
refresh = "새로고침"
remove = "제외"
resend = "재발송"
retry = "다시 시도"
save = "저장"
search = "검색"
select = "선택"
select_file = "파일 선택"
select_placeholder = "선택하세요"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
success = "성공"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
unknown = "Unknown"
generate = "생성"
[ui.userfront]
app_title = "Baron SW 포탈"
[err.userfront.auth_proxy] [err.userfront.auth_proxy]
consent_accept = "동의 처리에 실패했습니다." consent_accept = "동의 처리에 실패했습니다."
@@ -97,6 +40,9 @@ verify_code_failed = "인증 실패: {error}"
[err.userfront.session] [err.userfront.session]
missing = "활성 세션이 없습니다." missing = "활성 세션이 없습니다."
[msg.userfront]
greeting = "안녕하세요, {name}님"
[msg.userfront.audit] [msg.userfront.audit]
date = "접속일자: {value}" date = "접속일자: {value}"
device = "접속환경: {value}" device = "접속환경: {value}"
@@ -107,6 +53,27 @@ result = "인증결과: {value}"
session_id = "Session ID: {value}" session_id = "Session ID: {value}"
status = "현황: (준비중)" status = "현황: (준비중)"
[msg.userfront.consent]
accept_error = "동의 처리에 실패했습니다: {error}"
client_id = "클라이언트 ID: {id}"
client_unknown = "알 수 없는 앱"
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\n계속 진행하려면 동의 여부를 선택해 주세요."
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
scope_count = "총 {count}개"
[msg.userfront.consent.cancel]
confirm = "권한 동의를 취소하면 해당 서비스를 이용할 수 없습니다. 취소하시겠습니까?"
error = "취소 처리 중 오류가 발생했습니다: {error}"
[msg.userfront.consent.scope]
email = "이메일 주소 (계정 식별 및 알림 용도)"
offline_access = "오프라인 접근 (로그인 유지)"
openid = "OpenID 인증 정보 (로그인 상태 확인)"
phone = "휴대폰 번호 (본인 인증 및 알림)"
profile = "기본 프로필 정보 (이름, 사용자 식별자)"
[msg.userfront.dashboard] [msg.userfront.dashboard]
approved_device = "승인 기기: {device}" approved_device = "승인 기기: {device}"
approved_ip = "승인 IP: {ip}" approved_ip = "승인 IP: {ip}"
@@ -122,6 +89,27 @@ link_open_error = "해당 링크를 열 수 없습니다."
render_error = "대시보드 렌더링 오류: {error}" render_error = "대시보드 렌더링 오류: {error}"
session_id_copied = "세션 ID가 복사되었습니다." session_id_copied = "세션 ID가 복사되었습니다."
[msg.userfront.dashboard.activities]
empty = "연동된 앱이 없습니다."
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
error = "연동 정보를 불러오지 못했습니다."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다."
copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다."
none = "{label} 없음"
[msg.userfront.dashboard.revoke]
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
error = "해지 실패: {error}"
success = "{app} 연동이 해지되었습니다."
[msg.userfront.dashboard.scopes]
empty = "요청된 권한이 없습니다."
[msg.userfront.dashboard.timeline]
load_error = "접속이력을 불러오지 못했습니다."
[msg.userfront.error] [msg.userfront.error]
detail_contact = "관리자에게 문의해 주세요." detail_contact = "관리자에게 문의해 주세요."
detail_generic = "오류가 발생했습니다." detail_generic = "오류가 발생했습니다."
@@ -132,6 +120,34 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {code}" title_with_code = "오류: {code}"
type = "오류 종류: {type}" type = "오류 종류: {type}"
[msg.userfront.error.ory]
"$normalizedCode" = "{error}"
access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
invalid_request = "잘못된 요청입니다."
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
login_required = "로그인이 필요합니다."
request_forbidden = "요청이 거부되었습니다."
server_error = "인증 서버 오류가 발생했습니다."
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.error.whitelist]
"$normalizedCode" = "{error}"
bad_request = "입력값을 확인해 주세요."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
recovery_invalid = "재설정 링크가 유효하지 않습니다."
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
[msg.userfront.forgot] [msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
@@ -155,19 +171,36 @@ qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할
token_missing = "로그인 토큰을 확인할 수 없습니다." token_missing = "로그인 토큰을 확인할 수 없습니다."
verification_failed = "승인 처리에 실패했습니다: {error}" verification_failed = "승인 처리에 실패했습니다: {error}"
[msg.userfront.login.link]
approved = "msg.userfront.login.link.approved"
helper = "입력하신 정보로 로그인 링크를 전송합니다."
missing_login_id = "이메일 또는 휴대폰 번호를 입력해 주세요."
missing_phone = "휴대폰 번호를 입력해 주세요."
resend_wait = "재발송은 {time} 후 가능합니다."
short_code_help = "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다."
[msg.userfront.login.password]
failed = "로그인 실패: {error}"
missing_credentials = "이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요."
[msg.userfront.login.qr]
load_failed = "QR 코드를 불러오지 못했습니다."
scan_hint = "모바일 앱으로 스캔하세요"
[msg.userfront.login.short_code]
invalid = "문자 2개와 숫자 6자리를 입력해 주세요."
[msg.userfront.login.unregistered]
body = "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요."
[msg.userfront.login.verification]
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
success = "로그인 승인에 성공했습니다."
[msg.userfront.login_success] [msg.userfront.login_success]
subtitle = "성공적으로 로그인되었습니다." subtitle = "성공적으로 로그인되었습니다."
[msg.userfront.consent]
accept_error = "동의 처리에 실패했습니다: {error}"
client_id = "클라이언트 ID: {id}"
client_unknown = "알 수 없는 앱"
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요."
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
scope_count = "총 {count}개"
[msg.userfront.profile] [msg.userfront.profile]
department_missing = "소속 정보 없음" department_missing = "소속 정보 없음"
department_required = "소속을 입력해주세요." department_required = "소속을 입력해주세요."
@@ -181,7 +214,29 @@ phone_verify_required = "휴대폰 번호 인증이 필요합니다."
update_failed = "수정 실패: {error}" update_failed = "수정 실패: {error}"
update_success = "정보가 수정되었습니다." update_success = "정보가 수정되었습니다."
[msg.userfront.profile.password]
change_failed = "비밀번호 변경 실패: {error}"
changed = "비밀번호가 변경되었습니다."
current_required = "현재 비밀번호를 입력해 주세요."
mismatch = "새 비밀번호가 일치하지 않습니다."
new_required = "새 비밀번호를 입력해 주세요."
subtitle = "현재 비밀번호 확인 후 새 비밀번호로 변경합니다."
[msg.userfront.profile.phone]
code_sent = "인증번호가 전송되었습니다."
send_failed = "전송 실패: {error}"
verified = "인증되었습니다."
verify_failed = "인증 실패: {error}"
verify_notice = "휴대폰 번호를 변경하려면 SMS 인증이 필요합니다."
[msg.userfront.profile.section]
basic = "계정 기본 정보를 관리합니다."
organization = "소속 및 구분 정보입니다."
security = "비밀번호를 안전하게 관리합니다."
[msg.userfront.qr] [msg.userfront.qr]
approve_error = "msg.userfront.qr.approve_error"
approve_success = "msg.userfront.qr.approve_success"
camera_error = "카메라 오류: {error}" camera_error = "카메라 오류: {error}"
permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요." permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요."
permission_required = "카메라 권한이 필요합니다." permission_required = "카메라 권한이 필요합니다."
@@ -193,6 +248,25 @@ invalid_title = "유효하지 않은 링크입니다."
policy_loading = "비밀번호 정책을 불러오는 중입니다..." policy_loading = "비밀번호 정책을 불러오는 중입니다..."
success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요." success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."
[msg.userfront.reset.error]
empty_password = "비밀번호를 입력해주세요."
generic = "비밀번호 변경에 실패했습니다: {error}"
lowercase = "최소 1개 이상의 소문자를 포함해야 합니다."
min_length = "비밀번호는 최소 {count}자 이상이어야 합니다."
min_types = "비밀번호는 영문 대/소문자/숫자/특수문자 중 {count}가지 이상 포함해야 합니다."
mismatch = "비밀번호가 일치하지 않습니다."
number = "최소 1개 이상의 숫자를 포함해야 합니다."
symbol = "최소 1개 이상의 특수문자를 포함해야 합니다."
uppercase = "최소 1개 이상의 대문자를 포함해야 합니다."
[msg.userfront.reset.policy]
lowercase = "소문자 1개 이상"
min_length = "최소 {count}자 이상"
min_types = "영문 대/소문자/숫자/특수문자 중 {count}가지 이상"
number = "숫자 1개 이상"
symbol = "특수문자 1개 이상"
uppercase = "대문자 1개 이상"
[msg.userfront.sections] [msg.userfront.sections]
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다." apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다." audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
@@ -205,6 +279,122 @@ failed = "가입 실패: {error}"
privacy_full = "개인정보 수집 및 이용 동의 전문..." privacy_full = "개인정보 수집 및 이용 동의 전문..."
tos_full = "서비스 이용약관 전문..." tos_full = "서비스 이용약관 전문..."
[msg.userfront.signup.agreement]
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
title = "서비스 이용을 위해\\\\n약관에 동의해주세요"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
[msg.userfront.signup.auth]
affiliate_notice = "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요."
title = "본인 확인을 위해\\\\n인증을 진행해주세요"
[msg.userfront.signup.email]
code_mismatch = "인증코드가 일치하지 않습니다."
duplicate = "이미 가입된 이메일입니다."
invalid = "유효한 이메일 형식이 아닙니다."
send_failed = "발송 실패: {error}"
verified = "✅ 이메일 인증 완료"
verify_failed = "인증 실패: {error}"
[msg.userfront.signup.password]
length_required = "비밀번호는 최소 12자 이상이어야 합니다."
lowercase_required = "소문자가 최소 1개 이상 포함되어야 합니다."
mismatch = "비밀번호가 일치하지 않습니다."
number_required = "숫자가 최소 1개 이상 포함되어야 합니다."
symbol_required = "특수문자가 최소 1개 이상 포함되어야 합니다."
title = "마지막으로\\\\n비밀번호를 설정해주세요"
uppercase_required = "대문자가 최소 1개 이상 포함되어야 합니다."
[msg.userfront.signup.password.rule]
lowercase = "소문자"
min_length = "{count}자 이상"
min_types = "문자 유형 {count}가지 이상"
number = "숫자"
symbol = "특수문자"
uppercase = "대문자"
[msg.userfront.signup.phone]
code_mismatch = "인증코드가 일치하지 않습니다."
send_failed = "발송 실패: {error}"
verified = "✅ 휴대폰 인증 완료"
verify_failed = "인증 실패: {error}"
[msg.userfront.signup.policy]
loading = "비밀번호 정책을 불러오는 중입니다..."
lowercase = "소문자"
min_length = "최소 {count}자 이상"
min_types = "영문 대/소문자/숫자/특수문자 중 {count}가지 이상"
number = "숫자"
summary = "보안 정책: {rules}"
symbol = "특수문자"
uppercase = "대문자"
[msg.userfront.signup.profile]
affiliate_hint = "가족사 이메일 사용 시 자동으로 선택됩니다."
title = "회원님의\\\\n소속 정보를 알려주세요"
[msg.userfront.signup.success]
body = "성공적으로 가입되었습니다."
title = "회원가입 완료"
[ui.common]
add = "추가"
admin_only = "관리자 전용"
all = "전체"
assign = "할당"
back = "돌아가기"
back_to_login = "로그인으로 돌아가기"
cancel = "취소"
change_file = "파일 변경"
clear_search = "검색 초기화"
close = "닫기"
collapse = "접기"
confirm = "확인"
copy = "복사"
create = "생성"
delete = "삭제"
details = "상세정보"
edit = "편집"
enabled = "사용"
export = "내보내기"
fail = "실패"
generate = "ui.common.generate"
go_home = "홈으로"
hyphen = "-"
language = "언어"
language_en = "English"
language_ko = "한국어"
manage = "관리"
na = "N/A"
never = "Never"
next = "다음"
none = "없음"
page_of = "Page {page} of {total}"
prev = "이전"
previous = "이전"
qr = "QR"
read_only = "읽기 전용"
refresh = "새로고침"
remove = "제외"
resend = "재발송"
reset = "초기화"
retry = "다시 시도"
save = "저장"
search = "검색"
select = "선택"
select_file = "파일 선택"
select_placeholder = "선택하세요"
show_more = "+ 더보기"
success = "성공"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
unknown = "Unknown"
view = "보기"
[ui.common.badge] [ui.common.badge]
admin_only = "Admin only" admin_only = "Admin only"
command_only = "Command only" command_only = "Command only"
@@ -212,26 +402,68 @@ system = "System"
[ui.common.status] [ui.common.status]
active = "활성" active = "활성"
blocked = "차단됨" blocked = "ui.common.status.blocked"
failure = "실패" failure = "실패"
inactive = "비활성" inactive = "비활성"
ok = "정상" ok = "정상"
pending = "준비 중" pending = "준비 중"
success = "성공" success = "성공"
[ui.userfront]
app_title = "Baron SW 포탈"
[ui.userfront.app_label] [ui.userfront.app_label]
admin_console = "Admin Console" admin_console = "Admin Console"
baron = "Baron 로그인" baron = "Baron 로그인"
dev_console = "Dev Console" dev_console = "Dev Console"
[ui.userfront.audit]
[ui.userfront.audit.table]
app = "애플리케이션"
auth_method = "인증수단"
date = "접속일자"
device = "접속환경"
ip = "IP"
pending = "(준비중)"
result = "인증결과"
session_id = "Session ID"
status = "현황"
[ui.userfront.auth_method] [ui.userfront.auth_method]
ory = "Ory 세션" ory = "Ory 세션"
session = "세션" session = "세션"
[ui.userfront.consent]
accept = "동의하고 계속하기"
requested_scopes = "요청된 권한"
title = "접근 권한 요청"
[ui.userfront.consent.cancel]
confirm_button = "예, 취소합니다"
title = "동의 취소"
[ui.userfront.dashboard] [ui.userfront.dashboard]
last_auth_label = "최근 인증" last_auth_label = "최근 인증"
status_history = "상태 이력" status_history = "상태 이력"
[ui.userfront.dashboard.activity]
linked = "연동됨"
[ui.userfront.dashboard.approved_session]
default = "승인한 세션 ID"
userfront = "승인한 Userfront 세션 ID"
[ui.userfront.dashboard.revoke]
confirm_button = "해지하기"
title = "연동 해지"
[ui.userfront.dashboard.scopes]
title = "권한 (Scopes)"
[ui.userfront.dashboard.status]
revoked = "해지됨"
[ui.userfront.device] [ui.userfront.device]
android = "Mobile(Android)" android = "Mobile(Android)"
ios = "Mobile(iOS)" ios = "Mobile(iOS)"
@@ -253,258 +485,6 @@ title = "비밀번호 재설정"
forgot_password = "비밀번호를 잊으셨나요?" forgot_password = "비밀번호를 잊으셨나요?"
signup = "회원가입" signup = "회원가입"
[ui.userfront.login_success]
later = "나중에 하기 (대시보드로 이동)"
qr = "QR 인증 (카메라 켜기)"
title = "로그인 완료"
[ui.userfront.consent]
accept = "동의하고 계속하기"
requested_scopes = "요청된 권한"
title = "접근 권한 요청"
[ui.userfront.nav]
dashboard = "대시보드"
logout = "로그아웃"
profile = "내 정보"
qr_scan = "QR 스캔"
[ui.userfront.profile]
department_empty = "소속 정보 없음"
manage = "프로필 관리"
user_fallback = "사용자"
[ui.userfront.qr]
rescan = "다시 스캔"
result_success = "승인 완료"
title = "Scan QR Code"
[ui.userfront.reset]
confirm_password = "새 비밀번호 확인"
new_password = "새 비밀번호"
submit = "비밀번호 변경"
subtitle = "새로운 비밀번호 설정"
title = "새 비밀번호 설정"
[ui.userfront.sections]
apps = "나의 App 현황"
audit = "접속이력"
[ui.userfront.session]
active = "세션 활성"
unknown = "알 수 없음"
[ui.userfront.signup]
complete = "가입 완료"
next_step = "다음 단계"
title = "회원가입"
[msg.userfront.dashboard.activities]
empty = "연동된 앱이 없습니다."
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
error = "연동 정보를 불러오지 못했습니다."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\n클릭하면 복사됩니다."
copy_tap = "{label}: {id}\n탭하면 복사됩니다."
none = "{label} 없음"
[msg.userfront.dashboard.revoke]
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
error = "해지 실패: {error}"
success = "{app} 연동이 해지되었습니다."
[msg.userfront.dashboard.scopes]
empty = "요청된 권한이 없습니다."
[msg.userfront.dashboard.timeline]
load_error = "접속이력을 불러오지 못했습니다."
[msg.userfront.error.whitelist]
"$normalizedCode" = "{error}"
bad_request = "입력값을 확인해 주세요."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
recovery_invalid = "재설정 링크가 유효하지 않습니다."
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
[msg.userfront.error.ory]
"$normalizedCode" = "{error}"
access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
invalid_request = "잘못된 요청입니다."
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
login_required = "로그인이 필요합니다."
request_forbidden = "요청이 거부되었습니다."
server_error = "인증 서버 오류가 발생했습니다."
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.login.link]
helper = "입력하신 정보로 로그인 링크를 전송합니다."
missing_login_id = "이메일 또는 휴대폰 번호를 입력해 주세요."
missing_phone = "휴대폰 번호를 입력해 주세요."
resend_wait = "재발송은 {time} 후 가능합니다."
short_code_help = "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다."
[msg.userfront.login.password]
failed = "로그인 실패: {error}"
missing_credentials = "이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요."
[msg.userfront.login.qr]
load_failed = "QR 코드를 불러오지 못했습니다."
scan_hint = "모바일 앱으로 스캔하세요"
[msg.userfront.login.short_code]
invalid = "문자 2개와 숫자 6자리를 입력해 주세요."
[msg.userfront.login.unregistered]
body = "가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요."
[msg.userfront.login.verification]
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
success = "로그인 승인에 성공했습니다."
[msg.userfront.consent.cancel]
confirm = "권한 동의를 취소하면 해당 서비스를 이용할 수 없습니다. 취소하시겠습니까?"
error = "취소 처리 중 오류가 발생했습니다: {error}"
[msg.userfront.consent.scope]
email = "이메일 주소 (계정 식별 및 알림 용도)"
offline_access = "오프라인 접근 (로그인 유지)"
openid = "OpenID 인증 정보 (로그인 상태 확인)"
phone = "휴대폰 번호 (본인 인증 및 알림)"
profile = "기본 프로필 정보 (이름, 사용자 식별자)"
[msg.userfront.profile.password]
change_failed = "비밀번호 변경 실패: {error}"
changed = "비밀번호가 변경되었습니다."
current_required = "현재 비밀번호를 입력해 주세요."
mismatch = "새 비밀번호가 일치하지 않습니다."
new_required = "새 비밀번호를 입력해 주세요."
subtitle = "현재 비밀번호 확인 후 새 비밀번호로 변경합니다."
[msg.userfront.profile.phone]
code_sent = "인증번호가 전송되었습니다."
send_failed = "전송 실패: {error}"
verified = "인증되었습니다."
verify_failed = "인증 실패: {error}"
verify_notice = "휴대폰 번호를 변경하려면 SMS 인증이 필요합니다."
[msg.userfront.profile.section]
basic = "계정 기본 정보를 관리합니다."
organization = "소속 및 구분 정보입니다."
security = "비밀번호를 안전하게 관리합니다."
[msg.userfront.reset.error]
empty_password = "비밀번호를 입력해주세요."
generic = "비밀번호 변경에 실패했습니다: {error}"
lowercase = "최소 1개 이상의 소문자를 포함해야 합니다."
min_length = "비밀번호는 최소 {count}자 이상이어야 합니다."
min_types = "비밀번호는 영문 대/소문자/숫자/특수문자 중 {count}가지 이상 포함해야 합니다."
mismatch = "비밀번호가 일치하지 않습니다."
number = "최소 1개 이상의 숫자를 포함해야 합니다."
symbol = "최소 1개 이상의 특수문자를 포함해야 합니다."
uppercase = "최소 1개 이상의 대문자를 포함해야 합니다."
[msg.userfront.reset.policy]
lowercase = "소문자 1개 이상"
min_length = "최소 {count}자 이상"
min_types = "영문 대/소문자/숫자/특수문자 중 {count}가지 이상"
number = "숫자 1개 이상"
symbol = "특수문자 1개 이상"
uppercase = "대문자 1개 이상"
[msg.userfront.signup.agreement]
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
title = "서비스 이용을 위해\n약관에 동의해주세요"
[msg.userfront.signup.auth]
affiliate_notice = "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요."
title = "본인 확인을 위해\n인증을 진행해주세요"
[msg.userfront.signup.email]
code_mismatch = "인증코드가 일치하지 않습니다."
duplicate = "이미 가입된 이메일입니다."
invalid = "유효한 이메일 형식이 아닙니다."
send_failed = "발송 실패: {error}"
verified = "✅ 이메일 인증 완료"
verify_failed = "인증 실패: {error}"
[msg.userfront.signup.password]
length_required = "비밀번호는 최소 12자 이상이어야 합니다."
lowercase_required = "소문자가 최소 1개 이상 포함되어야 합니다."
mismatch = "비밀번호가 일치하지 않습니다."
number_required = "숫자가 최소 1개 이상 포함되어야 합니다."
symbol_required = "특수문자가 최소 1개 이상 포함되어야 합니다."
title = "마지막으로\n비밀번호를 설정해주세요"
uppercase_required = "대문자가 최소 1개 이상 포함되어야 합니다."
[msg.userfront.signup.phone]
code_mismatch = "인증코드가 일치하지 않습니다."
send_failed = "발송 실패: {error}"
verified = "✅ 휴대폰 인증 완료"
verify_failed = "인증 실패: {error}"
[msg.userfront.signup.policy]
loading = "비밀번호 정책을 불러오는 중입니다..."
lowercase = "소문자"
min_length = "최소 {count}자 이상"
min_types = "영문 대/소문자/숫자/특수문자 중 {count}가지 이상"
number = "숫자"
summary = "보안 정책: {rules}"
symbol = "특수문자"
uppercase = "대문자"
[msg.userfront.signup.profile]
affiliate_hint = "가족사 이메일 사용 시 자동으로 선택됩니다."
title = "회원님의\n소속 정보를 알려주세요"
[msg.userfront.signup.success]
body = "성공적으로 가입되었습니다."
title = "회원가입 완료"
[ui.userfront.audit.table]
app = "애플리케이션"
auth_method = "인증수단"
date = "접속일자"
device = "접속환경"
ip = "IP"
pending = "(준비중)"
result = "인증결과"
session_id = "Session ID"
status = "현황"
[ui.userfront.dashboard.activity]
linked = "연동됨"
[ui.userfront.dashboard.approved_session]
default = "승인한 세션 ID"
userfront = "승인한 Userfront 세션 ID"
[ui.userfront.dashboard.revoke]
confirm_button = "해지하기"
title = "연동 해지"
[ui.userfront.dashboard.scopes]
title = "권한 (Scopes)"
[ui.userfront.dashboard.status]
revoked = "해지됨"
[ui.userfront.login.action] [ui.userfront.login.action]
submit = "로그인" submit = "로그인"
@@ -513,9 +493,12 @@ login_id = "이메일 또는 휴대폰 번호"
password = "비밀번호" password = "비밀번호"
[ui.userfront.login.link] [ui.userfront.login.link]
action_label = "ui.userfront.login.link.action_label"
code_only = "코드만 받기({time})" code_only = "코드만 받기({time})"
page_title = "ui.userfront.login.link.page_title"
resend_with_time = "재발송 ({time})" resend_with_time = "재발송 ({time})"
send = "로그인 링크 전송" send = "로그인 링크 전송"
title = "ui.userfront.login.link.title"
[ui.userfront.login.qr] [ui.userfront.login.qr]
expired = "QR 코드 만료됨" expired = "QR 코드 만료됨"
@@ -542,9 +525,21 @@ action_label = "확인"
page_title = "로그인 승인" page_title = "로그인 승인"
title = "승인 완료" title = "승인 완료"
[ui.userfront.consent.cancel] [ui.userfront.login_success]
confirm_button = "예, 취소합니다" later = "나중에 하기 (대시보드로 이동)"
title = "동의 취소" qr = "QR 인증 (카메라 켜기)"
title = "로그인 완료"
[ui.userfront.nav]
dashboard = "대시보드"
logout = "로그아웃"
profile = "내 정보"
qr_scan = "QR 스캔"
[ui.userfront.profile]
department_empty = "소속 정보 없음"
manage = "프로필 관리"
user_fallback = "사용자"
[ui.userfront.profile.field] [ui.userfront.profile.field]
affiliation = "구분" affiliation = "구분"
@@ -572,6 +567,33 @@ basic = "기본 정보"
organization = "조직 정보" organization = "조직 정보"
security = "보안" security = "보안"
[ui.userfront.qr]
request_permission = "ui.userfront.qr.request_permission"
rescan = "다시 스캔"
result_failure = "ui.userfront.qr.result_failure"
result_success = "승인 완료"
title = "Scan QR Code"
[ui.userfront.reset]
confirm_password = "새 비밀번호 확인"
new_password = "새 비밀번호"
submit = "비밀번호 변경"
subtitle = "새로운 비밀번호 설정"
title = "새 비밀번호 설정"
[ui.userfront.sections]
apps = "나의 App 현황"
audit = "접속이력"
[ui.userfront.session]
active = "세션 활성"
unknown = "알 수 없음"
[ui.userfront.signup]
complete = "가입 완료"
next_step = "다음 단계"
title = "회원가입"
[ui.userfront.signup.agreement] [ui.userfront.signup.agreement]
all = "모두 동의합니다" all = "모두 동의합니다"
privacy_title = "개인정보 수집 및 이용 동의 (필수)" privacy_title = "개인정보 수집 및 이용 동의 (필수)"
@@ -582,6 +604,10 @@ tos_title = "바론 소프트웨어 이용약관 (필수)"
code_label = "인증코드 6자리" code_label = "인증코드 6자리"
request_code = "인증요청" request_code = "인증요청"
[ui.userfront.signup.auth.email]
label = "이메일 주소"
title = "이메일 인증"
[ui.userfront.signup.password] [ui.userfront.signup.password]
confirm_label = "비밀번호 확인" confirm_label = "비밀번호 확인"
label = "비밀번호" label = "비밀번호"
@@ -605,16 +631,3 @@ verify = "본인인증"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "로그인하기" action = "로그인하기"
[msg.userfront.signup.password.rule]
lowercase = "소문자"
min_length = "{count}자 이상"
min_types = "문자 유형 {count}가지 이상"
number = "숫자"
symbol = "특수문자"
uppercase = "대문자"
[ui.userfront.signup.auth.email]
label = "이메일 주소"
title = "이메일 인증"

View File

@@ -1,3 +1,5 @@
[domain]
[domain.affiliation] [domain.affiliation]
affiliate = "" affiliate = ""
general = "" general = ""
@@ -16,66 +18,7 @@ company_group = ""
personal = "" personal = ""
user_group = "" user_group = ""
[msg.userfront] [err.userfront]
greeting = ""
[ui.common]
add = ""
all = ""
admin_only = ""
assign = ""
back = ""
back_to_login = ""
cancel = ""
change_file = ""
clear_search = ""
close = ""
collapse = ""
confirm = ""
copy = ""
create = ""
delete = ""
details = ""
edit = ""
enabled = ""
export = ""
fail = ""
go_home = ""
view = ""
hyphen = ""
manage = ""
na = ""
never = ""
next = ""
none = ""
page_of = ""
prev = ""
previous = ""
qr = ""
reset = ""
read_only = ""
refresh = ""
remove = ""
resend = ""
retry = ""
save = ""
search = ""
select = ""
select_file = ""
select_placeholder = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
success = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
unknown = ""
generate = ""
[ui.userfront]
app_title = ""
[err.userfront.auth_proxy] [err.userfront.auth_proxy]
consent_accept = "" consent_accept = ""
@@ -97,6 +40,9 @@ verify_code_failed = ""
[err.userfront.session] [err.userfront.session]
missing = "" missing = ""
[msg.userfront]
greeting = ""
[msg.userfront.audit] [msg.userfront.audit]
date = "" date = ""
device = "" device = ""
@@ -107,12 +53,32 @@ result = ""
session_id = "" session_id = ""
status = "" status = ""
[msg.userfront.consent]
accept_error = ""
client_id = ""
client_unknown = ""
description = ""
load_error = ""
missing_redirect = ""
redirect_notice = ""
scope_count = ""
[msg.userfront.consent.cancel]
confirm = ""
error = ""
[msg.userfront.consent.scope]
email = ""
offline_access = ""
openid = ""
phone = ""
profile = ""
[msg.userfront.dashboard] [msg.userfront.dashboard]
approved_device = "" approved_device = ""
approved_ip = "" approved_ip = ""
audit_empty = "" audit_empty = ""
audit_load_error = "" audit_load_error = ""
render_error = ""
auth_method = "" auth_method = ""
client_id = "" client_id = ""
client_id_missing = "" client_id_missing = ""
@@ -120,8 +86,30 @@ current_status = ""
last_auth = "" last_auth = ""
link_missing = "" link_missing = ""
link_open_error = "" link_open_error = ""
render_error = ""
session_id_copied = "" session_id_copied = ""
[msg.userfront.dashboard.activities]
empty = ""
empty_detail = ""
error = ""
[msg.userfront.dashboard.approved_session]
copy_click = ""
copy_tap = ""
none = ""
[msg.userfront.dashboard.revoke]
confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.scopes]
empty = ""
[msg.userfront.dashboard.timeline]
load_error = ""
[msg.userfront.error] [msg.userfront.error]
detail_contact = "" detail_contact = ""
detail_generic = "" detail_generic = ""
@@ -132,6 +120,34 @@ title_generic = ""
title_with_code = "" title_with_code = ""
type = "" type = ""
[msg.userfront.error.ory]
"$normalizedCode" = ""
access_denied = ""
consent_required = ""
interaction_required = ""
invalid_client = ""
invalid_grant = ""
invalid_request = ""
invalid_scope = ""
login_required = ""
request_forbidden = ""
server_error = ""
temporarily_unavailable = ""
unauthorized_client = ""
unsupported_response_type = ""
[msg.userfront.error.whitelist]
"$normalizedCode" = ""
bad_request = ""
invalid_session = ""
not_found = ""
password_or_email_mismatch = ""
rate_limited = ""
recovery_expired = ""
recovery_invalid = ""
settings_disabled = ""
verification_required = ""
[msg.userfront.forgot] [msg.userfront.forgot]
description = "" description = ""
dry_send = "" dry_send = ""
@@ -155,200 +171,8 @@ qr_login_required = ""
token_missing = "" token_missing = ""
verification_failed = "" verification_failed = ""
[msg.userfront.login_success]
subtitle = ""
[msg.userfront.consent]
accept_error = ""
client_id = ""
client_unknown = ""
description = ""
load_error = ""
missing_redirect = ""
redirect_notice = ""
scope_count = ""
[msg.userfront.profile]
department_missing = ""
department_required = ""
email_missing = ""
greeting = ""
load_failed = ""
name_missing = ""
name_required = ""
phone_required = ""
phone_verify_required = ""
update_failed = ""
update_success = ""
[msg.userfront.qr]
camera_error = ""
permission_error = ""
permission_required = ""
[msg.userfront.reset]
invalid_body = ""
invalid_link = ""
invalid_title = ""
policy_loading = ""
success = ""
[msg.userfront.sections]
apps_subtitle = ""
audit_subtitle = ""
[msg.userfront.settings]
disabled = ""
[msg.userfront.signup]
failed = ""
privacy_full = ""
tos_full = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.status]
active = ""
blocked = ""
failure = ""
inactive = ""
ok = ""
pending = ""
success = ""
[ui.userfront.app_label]
admin_console = ""
baron = ""
dev_console = ""
[ui.userfront.auth_method]
ory = ""
session = ""
[ui.userfront.dashboard]
last_auth_label = ""
status_history = ""
[ui.userfront.device]
android = ""
ios = ""
linux = ""
macos = ""
windows = ""
[ui.userfront.error]
go_home = ""
go_login = ""
[ui.userfront.forgot]
heading = ""
input_label = ""
submit = ""
title = ""
[ui.userfront.login]
forgot_password = ""
signup = ""
[ui.userfront.login_success]
later = ""
qr = ""
title = ""
[ui.userfront.consent]
accept = ""
requested_scopes = ""
title = ""
[ui.userfront.nav]
dashboard = ""
logout = ""
profile = ""
qr_scan = ""
[ui.userfront.profile]
department_empty = ""
manage = ""
user_fallback = ""
[ui.userfront.qr]
rescan = ""
result_success = ""
title = ""
[ui.userfront.reset]
confirm_password = ""
new_password = ""
submit = ""
subtitle = ""
title = ""
[ui.userfront.sections]
apps = ""
audit = ""
[ui.userfront.session]
active = ""
unknown = ""
[ui.userfront.signup]
complete = ""
next_step = ""
title = ""
[msg.userfront.dashboard.activities]
empty = ""
empty_detail = ""
error = ""
[msg.userfront.dashboard.approved_session]
copy_click = ""
copy_tap = ""
none = ""
[msg.userfront.dashboard.revoke]
confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.scopes]
empty = ""
[msg.userfront.dashboard.timeline]
load_error = ""
[msg.userfront.error.whitelist]
"$normalizedCode" = ""
settings_disabled = ""
invalid_session = ""
verification_required = ""
recovery_expired = ""
recovery_invalid = ""
rate_limited = ""
not_found = ""
bad_request = ""
password_or_email_mismatch = ""
[msg.userfront.error.ory]
"$normalizedCode" = ""
access_denied = ""
consent_required = ""
interaction_required = ""
invalid_client = ""
invalid_grant = ""
invalid_request = ""
invalid_scope = ""
login_required = ""
request_forbidden = ""
server_error = ""
temporarily_unavailable = ""
unauthorized_client = ""
unsupported_response_type = ""
[msg.userfront.login.link] [msg.userfront.login.link]
approved = ""
helper = "" helper = ""
missing_login_id = "" missing_login_id = ""
missing_phone = "" missing_phone = ""
@@ -374,16 +198,21 @@ approved = ""
approved_local = "" approved_local = ""
success = "" success = ""
[msg.userfront.consent.cancel] [msg.userfront.login_success]
confirm = "" subtitle = ""
error = ""
[msg.userfront.consent.scope] [msg.userfront.profile]
email = "" department_missing = ""
offline_access = "" department_required = ""
openid = "" email_missing = ""
phone = "" greeting = ""
profile = "" load_failed = ""
name_missing = ""
name_required = ""
phone_required = ""
phone_verify_required = ""
update_failed = ""
update_success = ""
[msg.userfront.profile.password] [msg.userfront.profile.password]
change_failed = "" change_failed = ""
@@ -405,6 +234,20 @@ basic = ""
organization = "" organization = ""
security = "" security = ""
[msg.userfront.qr]
approve_error = ""
approve_success = ""
camera_error = ""
permission_error = ""
permission_required = ""
[msg.userfront.reset]
invalid_body = ""
invalid_link = ""
invalid_title = ""
policy_loading = ""
success = ""
[msg.userfront.reset.error] [msg.userfront.reset.error]
empty_password = "" empty_password = ""
generic = "" generic = ""
@@ -424,13 +267,25 @@ number = ""
symbol = "" symbol = ""
uppercase = "" uppercase = ""
[msg.userfront.sections]
apps_subtitle = ""
audit_subtitle = ""
[msg.userfront.settings]
disabled = ""
[msg.userfront.signup]
failed = ""
privacy_full = ""
tos_full = ""
[msg.userfront.signup.agreement] [msg.userfront.signup.agreement]
all_hint = "" all_hint = ""
description = "" description = ""
privacy_summary = "" privacy_summary = ""
progress = "" progress = ""
tos_summary = ""
title = "" title = ""
tos_summary = ""
[msg.userfront.signup.auth] [msg.userfront.signup.auth]
affiliate_notice = "" affiliate_notice = ""
@@ -453,6 +308,14 @@ symbol_required = ""
title = "" title = ""
uppercase_required = "" uppercase_required = ""
[msg.userfront.signup.password.rule]
lowercase = ""
min_length = ""
min_types = ""
number = ""
symbol = ""
uppercase = ""
[msg.userfront.signup.phone] [msg.userfront.signup.phone]
code_mismatch = "" code_mismatch = ""
send_failed = "" send_failed = ""
@@ -477,6 +340,85 @@ title = ""
body = "" body = ""
title = "" title = ""
[ui.common]
add = ""
admin_only = ""
all = ""
assign = ""
back = ""
back_to_login = ""
cancel = ""
change_file = ""
clear_search = ""
close = ""
collapse = ""
confirm = ""
copy = ""
create = ""
delete = ""
details = ""
edit = ""
enabled = ""
export = ""
fail = ""
generate = ""
go_home = ""
hyphen = ""
language = ""
language_en = ""
language_ko = ""
manage = ""
na = ""
never = ""
next = ""
none = ""
page_of = ""
prev = ""
previous = ""
qr = ""
read_only = ""
refresh = ""
remove = ""
resend = ""
reset = ""
retry = ""
save = ""
search = ""
select = ""
select_file = ""
select_placeholder = ""
show_more = ""
success = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
unknown = ""
view = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.status]
active = ""
blocked = ""
failure = ""
inactive = ""
ok = ""
pending = ""
success = ""
[ui.userfront]
app_title = ""
[ui.userfront.app_label]
admin_console = ""
baron = ""
dev_console = ""
[ui.userfront.audit]
[ui.userfront.audit.table] [ui.userfront.audit.table]
app = "" app = ""
auth_method = "" auth_method = ""
@@ -488,6 +430,23 @@ result = ""
session_id = "" session_id = ""
status = "" status = ""
[ui.userfront.auth_method]
ory = ""
session = ""
[ui.userfront.consent]
accept = ""
requested_scopes = ""
title = ""
[ui.userfront.consent.cancel]
confirm_button = ""
title = ""
[ui.userfront.dashboard]
last_auth_label = ""
status_history = ""
[ui.userfront.dashboard.activity] [ui.userfront.dashboard.activity]
linked = "" linked = ""
@@ -505,6 +464,27 @@ title = ""
[ui.userfront.dashboard.status] [ui.userfront.dashboard.status]
revoked = "" revoked = ""
[ui.userfront.device]
android = ""
ios = ""
linux = ""
macos = ""
windows = ""
[ui.userfront.error]
go_home = ""
go_login = ""
[ui.userfront.forgot]
heading = ""
input_label = ""
submit = ""
title = ""
[ui.userfront.login]
forgot_password = ""
signup = ""
[ui.userfront.login.action] [ui.userfront.login.action]
submit = "" submit = ""
@@ -513,14 +493,17 @@ login_id = ""
password = "" password = ""
[ui.userfront.login.link] [ui.userfront.login.link]
action_label = ""
code_only = "" code_only = ""
page_title = ""
resend_with_time = "" resend_with_time = ""
send = "" send = ""
title = ""
[ui.userfront.login.qr] [ui.userfront.login.qr]
expired = "" expired = ""
refresh = "" refresh = ""
remaining = "Remaining: {time}" remaining = ""
[ui.userfront.login.short_code] [ui.userfront.login.short_code]
digits = "" digits = ""
@@ -542,10 +525,22 @@ action_label = ""
page_title = "" page_title = ""
title = "" title = ""
[ui.userfront.consent.cancel] [ui.userfront.login_success]
confirm_button = "" later = ""
qr = ""
title = "" title = ""
[ui.userfront.nav]
dashboard = ""
logout = ""
profile = ""
qr_scan = ""
[ui.userfront.profile]
department_empty = ""
manage = ""
user_fallback = ""
[ui.userfront.profile.field] [ui.userfront.profile.field]
affiliation = "" affiliation = ""
company_code = "" company_code = ""
@@ -572,6 +567,33 @@ basic = ""
organization = "" organization = ""
security = "" security = ""
[ui.userfront.qr]
request_permission = ""
rescan = ""
result_failure = ""
result_success = ""
title = ""
[ui.userfront.reset]
confirm_password = ""
new_password = ""
submit = ""
subtitle = ""
title = ""
[ui.userfront.sections]
apps = ""
audit = ""
[ui.userfront.session]
active = ""
unknown = ""
[ui.userfront.signup]
complete = ""
next_step = ""
title = ""
[ui.userfront.signup.agreement] [ui.userfront.signup.agreement]
all = "" all = ""
privacy_title = "" privacy_title = ""
@@ -582,6 +604,10 @@ tos_title = ""
code_label = "" code_label = ""
request_code = "" request_code = ""
[ui.userfront.signup.auth.email]
label = ""
title = ""
[ui.userfront.signup.password] [ui.userfront.signup.password]
confirm_label = "" confirm_label = ""
label = "" label = ""
@@ -605,16 +631,3 @@ verify = ""
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "" action = ""
[msg.userfront.signup.password.rule]
lowercase = ""
min_length = ""
min_types = ""
number = ""
symbol = ""
uppercase = ""
[ui.userfront.signup.auth.email]
label = ""
title = ""

View File

@@ -964,7 +964,6 @@ class AuthProxyService {
static Future<void> signup({ static Future<void> signup({
required String email, required String email,
String? loginId,
required String password, required String password,
required String name, required String name,
required String phone, required String phone,
@@ -980,12 +979,11 @@ class AuthProxyService {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({
'email': email, 'email': email,
if (loginId != null && loginId.isNotEmpty) 'loginId': loginId,
'password': password, 'password': password,
'name': name, 'name': name,
'phone': phone, 'phone': phone,
'affiliationType': affiliationType, 'affiliationType': affiliationType,
'companyCode': ?companyCode, 'companyCode': companyCode,
'department': department, 'department': department,
'termsAccepted': termsAccepted, 'termsAccepted': termsAccepted,
}), }),

View File

@@ -32,13 +32,12 @@ class _SignupScreenState extends State<SignupScreen> {
// Controllers // Controllers
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _loginIdController = TextEditingController(); final _emailCodeController = TextEditingController(); // [Restore]
final _emailCodeController = TextEditingController();
final _phoneController = TextEditingController();
final _phoneCodeController = TextEditingController();
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController(); final _confirmPasswordController = TextEditingController();
final _phoneController = TextEditingController();
final _phoneCodeController = TextEditingController(); // [Restore]
final _deptController = TextEditingController(); final _deptController = TextEditingController();
// State // State
@@ -60,8 +59,6 @@ class _SignupScreenState extends State<SignupScreen> {
String? _phoneError; String? _phoneError;
String? _passwordError; String? _passwordError;
String? _confirmPasswordError; String? _confirmPasswordError;
String? _loginIdError;
String? _loginIdSuccess;
// Timers // Timers
Timer? _emailTimer; Timer? _emailTimer;
@@ -102,7 +99,6 @@ class _SignupScreenState extends State<SignupScreen> {
_emailTimer?.cancel(); _emailTimer?.cancel();
_phoneTimer?.cancel(); _phoneTimer?.cancel();
_emailController.dispose(); _emailController.dispose();
_loginIdController.dispose();
_emailCodeController.dispose(); _emailCodeController.dispose();
_phoneController.dispose(); _phoneController.dispose();
_phoneCodeController.dispose(); _phoneCodeController.dispose();
@@ -316,7 +312,6 @@ class _SignupScreenState extends State<SignupScreen> {
try { try {
await AuthProxyService.signup( await AuthProxyService.signup(
email: _emailController.text.trim(), email: _emailController.text.trim(),
loginId: _loginIdController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
name: _nameController.text.trim(), name: _nameController.text.trim(),
phone: _phoneController.text.trim(), phone: _phoneController.text.trim(),
@@ -1437,95 +1432,6 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
_buildProfileFieldGroup(
title: '로그인 ID (선택)',
description: '이메일/전화번호 외에 별도의 식별자로 로그인할 때 사용합니다.',
isDesktop: isDesktop,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _loginIdController,
onChanged: (val) {
setState(() {
_loginIdError = null;
_loginIdSuccess = null;
});
},
decoration: InputDecoration(
labelText: '사번 또는 아이디',
border: const OutlineInputBorder(),
errorText: _loginIdError,
suffixIcon: TextButton(
onPressed: _isLoading
? null
: () async {
final loginId = _loginIdController.text
.trim();
if (loginId.isEmpty) {
setState(
() => _loginIdError = 'ID를 입력해주세요.',
);
return;
}
setState(() {
_isLoading = true;
_loginIdError = null;
_loginIdSuccess = null;
});
try {
final result =
await AuthProxyService.checkLoginIDAvailability(
loginId,
companyCode:
_affiliationType ==
'AFFILIATE'
? _companyCode
: null,
);
setState(() {
if (result['available'] == true) {
_loginIdSuccess = '사용 가능한 ID입니다.';
} else {
_loginIdError =
result['message'] ??
'사용할 수 없는 ID입니다.';
}
});
} catch (e) {
setState(
() => _loginIdError = e
.toString()
.replaceAll('Exception: ', ''),
);
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
},
child: const Text('중복 확인'),
),
),
),
if (_loginIdSuccess != null)
Padding(
padding: const EdgeInsets.only(
top: 8.0,
left: 12.0,
),
child: Text(
_loginIdSuccess!,
style: const TextStyle(
color: Colors.green,
fontSize: 12,
),
),
),
],
),
),
const SizedBox(height: 18),
_buildProfileFieldGroup( _buildProfileFieldGroup(
title: tr('ui.userfront.signup.profile.affiliation_type'), title: tr('ui.userfront.signup.profile.affiliation_type'),
description: _isAffiliateEmail description: _isAffiliateEmail