@@ -127,7 +146,11 @@ function OrphanLoginIDTable({
onToggle(item.id)}
className="h-4 w-4 rounded border-input"
@@ -204,7 +227,11 @@ function DataIntegrityContent() {
return;
}
const confirmed = window.confirm(
- `선택한 ${selectedOrphanIds.length}개의 유령 로그인 ID를 삭제하시겠습니까?`,
+ t(
+ "msg.admin.integrity.orphan_login_ids.delete_confirm",
+ "선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?",
+ { count: selectedOrphanIds.length },
+ ),
);
if (confirmed) {
deleteMutation.mutate(selectedOrphanIds);
@@ -225,9 +252,11 @@ function DataIntegrityContent() {
-
System
+
+ {t("ui.admin.integrity.kicker", "System")}
+
- 데이터 정합성 검증
+ {t("ui.admin.integrity.title", "데이터 정합성 검증")}
@@ -238,7 +267,9 @@ function DataIntegrityContent() {
disabled={isLoading || isFetching || isManualRechecking}
>
- {isManualRechecking ? "검사 중" : "다시 검사"}
+ {isManualRechecking
+ ? t("ui.admin.integrity.recheck.running", "검사 중")
+ : t("ui.admin.integrity.recheck.run", "다시 검사")}
{recheckMessage ? (
- {(error as Error)?.message || "정합성 리포트를 불러오지 못했습니다."}
+ {(error as Error)?.message ||
+ t(
+ "msg.admin.integrity.report.load_error",
+ "정합성 리포트를 불러오지 못했습니다.",
+ )}
) : null}
@@ -264,10 +299,17 @@ function DataIntegrityContent() {
-
Read model integrity
+
+ {t(
+ "ui.admin.integrity.read_model.title",
+ "Read model integrity",
+ )}
+
- Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만
- 확인합니다.
+ {t(
+ "msg.admin.integrity.read_model.description",
+ "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
+ )}
@@ -279,29 +321,39 @@ function DataIntegrityContent() {
{isLoading ? (
- 불러오는 중
+
+ {t("ui.admin.integrity.loading", "불러오는 중")}
+
) : (
-
검사 항목
+
+ {t("ui.admin.integrity.summary.total_checks", "검사 항목")}
+
{data?.summary.totalChecks ?? 0}
-
정상
+
+ {t("ui.admin.integrity.summary.passed", "정상")}
+
{data?.summary.passed ?? 0}
-
실패 건수
+
+ {t("ui.admin.integrity.summary.failures", "실패 건수")}
+
{data?.summary.failures ?? 0}
-
검사 시각
+
+ {t("ui.admin.integrity.summary.checked_at", "검사 시각")}
+
{formatDateTime(data?.checkedAt)}
@@ -355,10 +407,17 @@ function DataIntegrityContent() {
-
유령 로그인 ID 정리
+
+ {t(
+ "ui.admin.integrity.orphan_login_ids.title",
+ "유령 로그인 ID 정리",
+ )}
+
- 삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를
- 확인한 뒤 선택 삭제합니다.
+ {t(
+ "msg.admin.integrity.orphan_login_ids.description",
+ "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
+ )}
- 선택 삭제
+ {t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
{orphanLoginIDsQuery.isError ? (
- 유령 로그인 ID 대상을 불러오지 못했습니다.
+ {t(
+ "msg.admin.integrity.orphan_login_ids.load_error",
+ "유령 로그인 ID 대상을 불러오지 못했습니다.",
+ )}
) : null}
{deleteMutation.data ? (
- {deleteMutation.data.deletedCount}개의 유령 로그인 ID를
- 삭제했습니다.
+ {t(
+ "msg.admin.integrity.orphan_login_ids.delete_success",
+ "{{count}}개의 유령 로그인 ID를 삭제했습니다.",
+ { count: deleteMutation.data.deletedCount },
+ )}
) : null}
- 접근 권한이 없습니다
+
+ {t("ui.admin.integrity.forbidden.title", "접근 권한이 없습니다")}
+
- 이 화면은 super_admin 권한으로만 접근할 수 있습니다.
+ {t(
+ "msg.admin.integrity.forbidden.description",
+ "이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
+ )}
diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml
index 45dbbd91..21b604e3 100644
--- a/adminfront/src/locales/en.toml
+++ b/adminfront/src/locales/en.toml
@@ -141,6 +141,27 @@ remove_confirm = "Remove Confirm"
remove_success = "Remove Success"
title = "Title"
+[msg.admin.integrity.forbidden]
+description = "This screen is available only to super_admin."
+
+[msg.admin.integrity.orphan_login_ids]
+delete_confirm = "Delete {{count}} selected orphan login IDs?"
+delete_success = "Deleted {{count}} orphan login IDs."
+description = "Review login IDs that reference deleted or missing users/tenants, then delete selected rows."
+empty = "No orphan login IDs to delete."
+load_error = "Failed to load orphan login ID targets."
+
+[msg.admin.integrity.read_model]
+description = "Checks anomalies in the backend DB read model without overwriting the Ory SoT."
+
+[msg.admin.integrity.recheck]
+error = "Check failed."
+running = "Running integrity check."
+success = "Check completed."
+
+[msg.admin.integrity.report]
+load_error = "Failed to load the integrity report."
+
[msg.admin.groups.prompt]
user_id = "User Id"
@@ -837,6 +858,51 @@ name = "NAME"
plane = "ADMIN PLANE"
subtitle = "Manage your organization"
+[ui.admin.integrity]
+kicker = "System"
+loading = "Loading"
+title = "Data Integrity Check"
+
+[ui.admin.integrity.forbidden]
+title = "Access denied"
+
+[ui.admin.integrity.orphan_login_ids]
+delete = "Delete selected"
+title = "Orphan Login ID Cleanup"
+
+[ui.admin.integrity.read_model]
+title = "Read model integrity"
+
+[ui.admin.integrity.reason]
+deleted_tenant = "Deleted tenant"
+deleted_user = "Deleted user"
+missing_tenant = "Missing tenant"
+missing_user = "Missing user"
+
+[ui.admin.integrity.recheck]
+run = "Run again"
+running = "Checking"
+
+[ui.admin.integrity.status]
+fail = "Failed"
+pass = "Passed"
+warning = "Warning"
+
+[ui.admin.integrity.summary]
+checked_at = "Checked at"
+failures = "Failures"
+passed = "Passed"
+total_checks = "Checks"
+
+[ui.admin.integrity.table]
+field = "Field"
+login_id = "Login ID"
+reason = "Reason"
+select = "Select"
+select_item = "Select {{loginId}}"
+tenant = "Tenant"
+user = "User"
+
[ui.admin.nav]
org_chart = "Org Chart"
api_keys = "API Keys"
diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml
index 9fd06477..068b13c5 100644
--- a/adminfront/src/locales/ko.toml
+++ b/adminfront/src/locales/ko.toml
@@ -141,6 +141,27 @@ remove_confirm = "제거하시겠습니까?"
remove_success = "구성원이 제외되었습니다."
title = "[{{name}}] 멤버 관리"
+[msg.admin.integrity.forbidden]
+description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
+
+[msg.admin.integrity.orphan_login_ids]
+delete_confirm = "선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?"
+delete_success = "{{count}}개의 유령 로그인 ID를 삭제했습니다."
+description = "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다."
+empty = "삭제할 유령 로그인 ID가 없습니다."
+load_error = "유령 로그인 ID 대상을 불러오지 못했습니다."
+
+[msg.admin.integrity.read_model]
+description = "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다."
+
+[msg.admin.integrity.recheck]
+error = "검사에 실패했습니다."
+running = "정합성 검사를 실행 중입니다."
+success = "검사가 완료되었습니다."
+
+[msg.admin.integrity.report]
+load_error = "정합성 리포트를 불러오지 못했습니다."
+
[msg.admin.groups.prompt]
user_id = "추가할 사용자의 UUID를 입력하세요:"
@@ -839,6 +860,51 @@ name = "NAME"
plane = "ADMIN PLANE"
subtitle = "Manage your organization"
+[ui.admin.integrity]
+kicker = "System"
+loading = "불러오는 중"
+title = "데이터 정합성 검증"
+
+[ui.admin.integrity.forbidden]
+title = "접근 권한이 없습니다"
+
+[ui.admin.integrity.orphan_login_ids]
+delete = "선택 삭제"
+title = "유령 로그인 ID 정리"
+
+[ui.admin.integrity.read_model]
+title = "Read model integrity"
+
+[ui.admin.integrity.reason]
+deleted_tenant = "삭제된 테넌트"
+deleted_user = "삭제된 사용자"
+missing_tenant = "테넌트 없음"
+missing_user = "사용자 없음"
+
+[ui.admin.integrity.recheck]
+run = "다시 검사"
+running = "검사 중"
+
+[ui.admin.integrity.status]
+fail = "실패"
+pass = "정상"
+warning = "주의"
+
+[ui.admin.integrity.summary]
+checked_at = "검사 시각"
+failures = "실패 건수"
+passed = "정상"
+total_checks = "검사 항목"
+
+[ui.admin.integrity.table]
+field = "Field"
+login_id = "Login ID"
+reason = "사유"
+select = "선택"
+select_item = "{{loginId}} 선택"
+tenant = "Tenant"
+user = "User"
+
[ui.admin.nav]
org_chart = "조직도"
api_keys = "API 키"
diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml
index 5eb2cd84..f0d6aedb 100644
--- a/adminfront/src/locales/template.toml
+++ b/adminfront/src/locales/template.toml
@@ -146,6 +146,27 @@ remove_confirm = ""
remove_success = ""
title = ""
+[msg.admin.integrity.forbidden]
+description = ""
+
+[msg.admin.integrity.orphan_login_ids]
+delete_confirm = ""
+delete_success = ""
+description = ""
+empty = ""
+load_error = ""
+
+[msg.admin.integrity.read_model]
+description = ""
+
+[msg.admin.integrity.recheck]
+error = ""
+running = ""
+success = ""
+
+[msg.admin.integrity.report]
+load_error = ""
+
[msg.admin.groups.prompt]
user_id = ""
@@ -852,6 +873,51 @@ name = ""
plane = ""
subtitle = ""
+[ui.admin.integrity]
+kicker = ""
+loading = ""
+title = ""
+
+[ui.admin.integrity.forbidden]
+title = ""
+
+[ui.admin.integrity.orphan_login_ids]
+delete = ""
+title = ""
+
+[ui.admin.integrity.read_model]
+title = ""
+
+[ui.admin.integrity.reason]
+deleted_tenant = ""
+deleted_user = ""
+missing_tenant = ""
+missing_user = ""
+
+[ui.admin.integrity.recheck]
+run = ""
+running = ""
+
+[ui.admin.integrity.status]
+fail = ""
+pass = ""
+warning = ""
+
+[ui.admin.integrity.summary]
+checked_at = ""
+failures = ""
+passed = ""
+total_checks = ""
+
+[ui.admin.integrity.table]
+field = ""
+login_id = ""
+reason = ""
+select = ""
+select_item = ""
+tenant = ""
+user = ""
+
[ui.admin.nav]
org_chart = ""
api_keys = ""
diff --git a/backend/internal/repository/data_integrity_repository_test.go b/backend/internal/repository/data_integrity_repository_test.go
index f530e7ef..174dd506 100644
--- a/backend/internal/repository/data_integrity_repository_test.go
+++ b/backend/internal/repository/data_integrity_repository_test.go
@@ -3,11 +3,15 @@ package repository
import (
"baron-sso-backend/internal/domain"
"context"
+ "errors"
+ "fmt"
"testing"
"time"
"github.com/google/uuid"
+ "github.com/lib/pq"
"github.com/stretchr/testify/require"
+ "gorm.io/gorm"
)
func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
@@ -60,7 +64,18 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
+ deletedLoginUser := domain.User{
+ ID: uuid.NewString(),
+ Email: "deleted-login-user-" + suffix + "@example.com",
+ Name: "Deleted Login User",
+ Role: domain.RoleUser,
+ TenantID: &child.ID,
+ Status: domain.UserStatusActive,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
require.NoError(t, testDB.Create(&orphanUser).Error)
+ require.NoError(t, testDB.Create(&deletedLoginUser).Error)
require.NoError(t, testDB.Create(&domain.UserLoginID{
ID: uuid.NewString(),
UserID: orphanUser.ID,
@@ -70,11 +85,12 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
}).Error)
require.NoError(t, testDB.Create(&domain.UserLoginID{
ID: uuid.NewString(),
- UserID: uuid.NewString(),
+ UserID: deletedLoginUser.ID,
TenantID: child.ID,
FieldKey: "emp_id",
LoginID: "MISSING-" + suffix,
}).Error)
+ require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", deletedLoginUser.ID).Error)
report, err := CheckDataIntegrity(ctx, testDB)
require.NoError(t, err)
@@ -88,6 +104,68 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1)
}
+func TestCheckDataIntegrityDetectsHardOrphanUserLoginIDRows(t *testing.T) {
+ ctx := context.Background()
+ suffix := uuid.NewString()
+ rollback := errors.New("rollback hard orphan fixture")
+
+ err := testDB.Transaction(func(tx *gorm.DB) error {
+ var constraintNames []string
+ if err := tx.Raw(`
+SELECT conname
+FROM pg_constraint
+WHERE conrelid = 'user_login_ids'::regclass
+ AND contype = 'f'
+`).Scan(&constraintNames).Error; err != nil {
+ return err
+ }
+
+ for _, constraintName := range constraintNames {
+ statement := fmt.Sprintf("ALTER TABLE user_login_ids DROP CONSTRAINT %s", pq.QuoteIdentifier(constraintName))
+ if err := tx.Exec(statement).Error; err != nil {
+ return err
+ }
+ }
+
+ before, err := CheckDataIntegrity(ctx, tx)
+ if err != nil {
+ return err
+ }
+ beforeTenantCount, err := integrityCheckCount(before, "user_integrity", "orphan_user_login_id_tenants")
+ if err != nil {
+ return err
+ }
+ beforeUserCount, err := integrityCheckCount(before, "user_integrity", "orphan_user_login_id_users")
+ if err != nil {
+ return err
+ }
+
+ if err := tx.Create(&domain.UserLoginID{
+ ID: uuid.NewString(),
+ UserID: uuid.NewString(),
+ TenantID: uuid.NewString(),
+ FieldKey: "emp_id",
+ LoginID: "HARD-ORPHAN-" + suffix,
+ }).Error; err != nil {
+ return err
+ }
+
+ report, err := CheckDataIntegrity(ctx, tx)
+ if err != nil {
+ return err
+ }
+ if err := expectIntegrityCheck(report, "user_integrity", "orphan_user_login_id_tenants", domain.DataIntegrityStatusFail, beforeTenantCount+1); err != nil {
+ return err
+ }
+ if err := expectIntegrityCheck(report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, beforeUserCount+1); err != nil {
+ return err
+ }
+
+ return rollback
+ })
+ require.ErrorIs(t, err, rollback)
+}
+
func TestListAndDeleteOrphanUserLoginIDsOnlyDeletesRevalidatedTargets(t *testing.T) {
ctx := context.Background()
suffix := uuid.NewString()
@@ -194,17 +272,41 @@ func TestListAndDeleteOrphanUserLoginIDsOnlyDeletesRevalidatedTargets(t *testing
func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) {
t.Helper()
+ require.NoError(t, expectIntegrityCheck(report, sectionKey, checkKey, status, count))
+}
+
+func expectIntegrityCheck(report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) error {
+ check, ok := findIntegrityCheck(report, sectionKey, checkKey)
+ if !ok {
+ return fmt.Errorf("integrity check %s/%s not found", sectionKey, checkKey)
+ }
+ if check.Status != status {
+ return fmt.Errorf("integrity check %s/%s status = %s, want %s", sectionKey, checkKey, check.Status, status)
+ }
+ if check.Count != count {
+ return fmt.Errorf("integrity check %s/%s count = %d, want %d", sectionKey, checkKey, check.Count, count)
+ }
+ return nil
+}
+
+func integrityCheckCount(report domain.DataIntegrityReport, sectionKey, checkKey string) (int64, error) {
+ check, ok := findIntegrityCheck(report, sectionKey, checkKey)
+ if !ok {
+ return 0, fmt.Errorf("integrity check %s/%s not found", sectionKey, checkKey)
+ }
+ return check.Count, nil
+}
+
+func findIntegrityCheck(report domain.DataIntegrityReport, sectionKey, checkKey string) (domain.DataIntegrityCheck, bool) {
for _, section := range report.Sections {
if section.Key != sectionKey {
continue
}
for _, check := range section.Checks {
if check.Key == checkKey {
- require.Equal(t, status, check.Status)
- require.Equal(t, count, check.Count)
- return
+ return check, true
}
}
}
- t.Fatalf("integrity check %s/%s not found", sectionKey, checkKey)
+ return domain.DataIntegrityCheck{}, false
}
diff --git a/backend/internal/repository/user_membership_maintenance_test.go b/backend/internal/repository/user_membership_maintenance_test.go
index d71589a3..64b80b9c 100644
--- a/backend/internal/repository/user_membership_maintenance_test.go
+++ b/backend/internal/repository/user_membership_maintenance_test.go
@@ -26,20 +26,16 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
activeUser := &domain.User{
- Email: "active-membership@example.com",
- Name: "Active Membership",
- Role: "user",
- TenantID: &activeTenant.ID,
- CompanyCode: activeTenant.Slug,
- CompanyCodes: []string{activeTenant.Slug},
+ Email: "active-membership@example.com",
+ Name: "Active Membership",
+ Role: "user",
+ TenantID: &activeTenant.ID,
}
orphanUser := &domain.User{
- Email: "orphan-membership@example.com",
- Name: "Orphan Membership",
- Role: "user",
- TenantID: &deletedTenant.ID,
- CompanyCode: deletedTenant.Slug,
- CompanyCodes: []string{deletedTenant.Slug},
+ Email: "orphan-membership@example.com",
+ Name: "Orphan Membership",
+ Role: "user",
+ TenantID: &deletedTenant.ID,
}
require.NoError(t, repo.Create(ctx, activeUser))
require.NoError(t, repo.Create(ctx, orphanUser))
@@ -56,14 +52,10 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, foundActive.TenantID)
assert.Equal(t, activeTenant.ID, *foundActive.TenantID)
- assert.Equal(t, activeTenant.Slug, foundActive.CompanyCode)
- assert.Equal(t, []string{activeTenant.Slug}, []string(foundActive.CompanyCodes))
foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email)
require.NoError(t, err)
assert.Nil(t, foundOrphan.TenantID)
- assert.Empty(t, foundOrphan.CompanyCode)
- assert.Empty(t, foundOrphan.CompanyCodes)
count, err = CountOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
diff --git a/backend/internal/repository/user_projection_repository_test.go b/backend/internal/repository/user_projection_repository_test.go
index c0f92041..9930d31f 100644
--- a/backend/internal/repository/user_projection_repository_test.go
+++ b/backend/internal/repository/user_projection_repository_test.go
@@ -47,12 +47,12 @@ func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyAndRemovesStaleU
UpdatedAt: time.Now(),
},
{
- ID: "00000000-0000-0000-0000-000000000102",
- Email: "two@example.com",
- Name: "Two",
- CompanyCodes: []string{tenantSlug},
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
+ ID: "00000000-0000-0000-0000-000000000102",
+ Email: "two@example.com",
+ Name: "Two",
+ TenantID: &tenantID,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
},
}
diff --git a/backend/internal/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go
index 0313c94d..2454f3b4 100644
--- a/backend/internal/repository/user_repository_test.go
+++ b/backend/internal/repository/user_repository_test.go
@@ -5,7 +5,9 @@ import (
"context"
"testing"
+ "github.com/google/uuid"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestUserRepository(t *testing.T) {
@@ -76,13 +78,17 @@ func TestUserRepository(t *testing.T) {
t.Run("CountByCompanyCodes", func(t *testing.T) {
// Clean start for this subtest
+ testDB.Exec("DELETE FROM user_login_ids")
testDB.Exec("DELETE FROM users")
+ testDB.Exec("DELETE FROM tenant_domains")
+ tenantA := createUserRepositoryTestTenant(t, "tenant-a")
+ tenantB := createUserRepositoryTestTenant(t, "tenant-b")
users := []domain.User{
- {Email: "u1@a.com", Name: "U1", CompanyCode: "tenant-a"},
- {Email: "u2@a.com", Name: "U2", CompanyCode: "tenant-a"},
- {Email: "u3@b.com", Name: "U3", CompanyCode: "tenant-b"},
- {Email: "u4@none.com", Name: "U4", CompanyCode: ""},
+ {Email: "u1@a.com", Name: "U1", TenantID: &tenantA.ID},
+ {Email: "u2@a.com", Name: "U2", TenantID: &tenantA.ID},
+ {Email: "u3@b.com", Name: "U3", TenantID: &tenantB.ID},
+ {Email: "u4@none.com", Name: "U4"},
}
for _, u := range users {
_ = repo.Create(ctx, &u)
@@ -96,17 +102,20 @@ func TestUserRepository(t *testing.T) {
})
t.Run("CountByCompanyCodes excludes soft deleted cache rows", func(t *testing.T) {
+ testDB.Exec("DELETE FROM user_login_ids")
testDB.Exec("DELETE FROM users")
+ testDB.Exec("DELETE FROM tenant_domains")
+ tenantA := createUserRepositoryTestTenant(t, "tenant-a")
- active := &domain.User{Email: "active@a.com", Name: "Active", CompanyCode: "tenant-a"}
- deleted := &domain.User{Email: "deleted@a.com", Name: "Deleted", CompanyCode: "tenant-a"}
- arrayDeleted := &domain.User{Email: "array-deleted@a.com", Name: "Array Deleted", CompanyCodes: []string{"tenant-a"}}
+ active := &domain.User{Email: "active@a.com", Name: "Active", TenantID: &tenantA.ID}
+ deleted := &domain.User{Email: "deleted@a.com", Name: "Deleted", TenantID: &tenantA.ID}
+ secondDeleted := &domain.User{Email: "second-deleted@a.com", Name: "Second Deleted", TenantID: &tenantA.ID}
assert.NoError(t, repo.Create(ctx, active))
assert.NoError(t, repo.Create(ctx, deleted))
- assert.NoError(t, repo.Create(ctx, arrayDeleted))
+ assert.NoError(t, repo.Create(ctx, secondDeleted))
assert.NoError(t, repo.Delete(ctx, deleted.ID))
- assert.NoError(t, repo.Delete(ctx, arrayDeleted.ID))
+ assert.NoError(t, repo.Delete(ctx, secondDeleted.ID))
counts, err := repo.CountByCompanyCodes(ctx, []string{"tenant-a"})
@@ -164,3 +173,17 @@ func TestUserRepository(t *testing.T) {
assert.Equal(t, "E002", saved[0].LoginID)
})
}
+
+func createUserRepositoryTestTenant(t *testing.T, slug string) domain.Tenant {
+ t.Helper()
+ require.NoError(t, testDB.Unscoped().Where("slug = ?", slug).Delete(&domain.Tenant{}).Error)
+ tenant := domain.Tenant{
+ ID: uuid.NewString(),
+ Name: "Tenant " + slug,
+ Slug: slug,
+ Type: domain.TenantTypeCompany,
+ Status: domain.TenantStatusActive,
+ }
+ require.NoError(t, testDB.Create(&tenant).Error)
+ return tenant
+}
diff --git a/common/locales/en.toml b/common/locales/en.toml
index e27eff98..55082e0c 100644
--- a/common/locales/en.toml
+++ b/common/locales/en.toml
@@ -15,6 +15,7 @@ actions = "Actions"
add = "Add"
all = "All"
admin_only = "Admin Only"
+apply = "Apply"
approve = "Approve"
assign = "Assign"
back = "Back"
diff --git a/common/locales/ko.toml b/common/locales/ko.toml
index 7e86dd7b..7e1acee5 100644
--- a/common/locales/ko.toml
+++ b/common/locales/ko.toml
@@ -15,6 +15,7 @@ actions = "액션"
add = "추가"
all = "전체"
admin_only = "관리자 전용"
+apply = "적용"
approve = "승인"
assign = "할당"
back = "돌아가기"
diff --git a/common/locales/template.toml b/common/locales/template.toml
index 3c16a2b3..e1a8b0dc 100644
--- a/common/locales/template.toml
+++ b/common/locales/template.toml
@@ -15,6 +15,7 @@ actions = ""
add = ""
all = ""
admin_only = ""
+apply = ""
approve = ""
assign = ""
back = ""
diff --git a/locales/en.toml b/locales/en.toml
index d5dd34d8..f2439997 100644
--- a/locales/en.toml
+++ b/locales/en.toml
@@ -2606,3 +2606,99 @@ toggle_label = "Show active sessions only"
[msg.userfront.audit.filter]
description = "Toggle to view only active sessions."
+
+[msg.admin.integrity.forbidden]
+description = "This screen is available only to super_admin."
+
+[msg.admin.integrity.orphan_login_ids]
+delete_confirm = "Delete {{count}} selected orphan login IDs?"
+delete_success = "Deleted {{count}} orphan login IDs."
+description = "Review login IDs that reference deleted or missing users/tenants, then delete selected rows."
+empty = "No orphan login IDs to delete."
+load_error = "Failed to load orphan login ID targets."
+
+[msg.admin.integrity.read_model]
+description = "Checks anomalies in the backend DB read model without overwriting the Ory SoT."
+
+[msg.admin.integrity.recheck]
+error = "Check failed."
+running = "Running integrity check."
+success = "Check completed."
+
+[msg.admin.integrity.report]
+load_error = "Failed to load the integrity report."
+
+[ui.admin.integrity]
+kicker = "System"
+loading = "Loading"
+title = "Data Integrity Check"
+
+[ui.admin.integrity.forbidden]
+title = "Access denied"
+
+[ui.admin.integrity.orphan_login_ids]
+delete = "Delete selected"
+title = "Orphan Login ID Cleanup"
+
+[ui.admin.integrity.read_model]
+title = "Read model integrity"
+
+[ui.admin.integrity.reason]
+deleted_tenant = "Deleted tenant"
+deleted_user = "Deleted user"
+missing_tenant = "Missing tenant"
+missing_user = "Missing user"
+
+[ui.admin.integrity.recheck]
+run = "Run again"
+running = "Checking"
+
+[ui.admin.integrity.status]
+fail = "Failed"
+pass = "Passed"
+warning = "Warning"
+
+[ui.admin.integrity.summary]
+checked_at = "Checked at"
+failures = "Failures"
+passed = "Passed"
+total_checks = "Checks"
+
+[ui.admin.integrity.table]
+field = "Field"
+login_id = "Login ID"
+reason = "Reason"
+select = "Select"
+select_item = "Select {{loginId}}"
+tenant = "Tenant"
+user = "User"
+
+[msg.admin.api_keys.list]
+edit_scopes_desc = "Edit the scopes granted to this API key."
+rotate_confirm = "Rotate the secret for this API key?"
+rotate_secret_notice = "The new secret is shown only once."
+
+[msg.admin.tenants]
+export_error = "Failed to export tenants."
+
+[ui.admin.api_keys.list]
+edit_scopes = "Edit scopes"
+rotate_secret = "Rotate secret"
+rotate_secret_done = "Secret rotated"
+save_scopes = "Save scopes"
+
+[ui.admin.overview.summary]
+total_users = "Total Users"
+
+[ui.admin.tenants.sub]
+export = "Export"
+
+[ui.admin.users.bulk]
+permission_placeholder = "Select permission"
+status_placeholder = "Select status"
+
+[ui.dev.profile.org]
+tenant_slug = "Tenant slug"
+
+[ui.userfront.profile.field]
+tenant_slug = "Tenant slug"
diff --git a/locales/ko.toml b/locales/ko.toml
index 440b0d02..0571d494 100644
--- a/locales/ko.toml
+++ b/locales/ko.toml
@@ -3029,3 +3029,99 @@ toggle_label = "활성 세션만 보기"
[msg.userfront.audit.filter]
description = "활성화된 세션만 보려면 토글을 켜주세요."
+
+[msg.admin.integrity.forbidden]
+description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
+
+[msg.admin.integrity.orphan_login_ids]
+delete_confirm = "선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?"
+delete_success = "{{count}}개의 유령 로그인 ID를 삭제했습니다."
+description = "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다."
+empty = "삭제할 유령 로그인 ID가 없습니다."
+load_error = "유령 로그인 ID 대상을 불러오지 못했습니다."
+
+[msg.admin.integrity.read_model]
+description = "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다."
+
+[msg.admin.integrity.recheck]
+error = "검사에 실패했습니다."
+running = "정합성 검사를 실행 중입니다."
+success = "검사가 완료되었습니다."
+
+[msg.admin.integrity.report]
+load_error = "정합성 리포트를 불러오지 못했습니다."
+
+[ui.admin.integrity]
+kicker = "System"
+loading = "불러오는 중"
+title = "데이터 정합성 검증"
+
+[ui.admin.integrity.forbidden]
+title = "접근 권한이 없습니다"
+
+[ui.admin.integrity.orphan_login_ids]
+delete = "선택 삭제"
+title = "유령 로그인 ID 정리"
+
+[ui.admin.integrity.read_model]
+title = "Read model integrity"
+
+[ui.admin.integrity.reason]
+deleted_tenant = "삭제된 테넌트"
+deleted_user = "삭제된 사용자"
+missing_tenant = "테넌트 없음"
+missing_user = "사용자 없음"
+
+[ui.admin.integrity.recheck]
+run = "다시 검사"
+running = "검사 중"
+
+[ui.admin.integrity.status]
+fail = "실패"
+pass = "정상"
+warning = "주의"
+
+[ui.admin.integrity.summary]
+checked_at = "검사 시각"
+failures = "실패 건수"
+passed = "정상"
+total_checks = "검사 항목"
+
+[ui.admin.integrity.table]
+field = "Field"
+login_id = "Login ID"
+reason = "사유"
+select = "선택"
+select_item = "{{loginId}} 선택"
+tenant = "Tenant"
+user = "User"
+
+[msg.admin.api_keys.list]
+edit_scopes_desc = "API 키에 부여할 권한 범위를 수정합니다."
+rotate_confirm = "이 API 키의 Secret을 재발급할까요?"
+rotate_secret_notice = "새 Secret은 지금 한 번만 표시됩니다."
+
+[msg.admin.tenants]
+export_error = "테넌트 내보내기에 실패했습니다."
+
+[ui.admin.api_keys.list]
+edit_scopes = "권한 수정"
+rotate_secret = "Secret 재발급"
+rotate_secret_done = "Secret 재발급 완료"
+save_scopes = "권한 저장"
+
+[ui.admin.overview.summary]
+total_users = "전체 사용자 수"
+
+[ui.admin.tenants.sub]
+export = "내보내기"
+
+[ui.admin.users.bulk]
+permission_placeholder = "권한 선택"
+status_placeholder = "상태 선택"
+
+[ui.dev.profile.org]
+tenant_slug = "테넌트 slug"
+
+[ui.userfront.profile.field]
+tenant_slug = "테넌트 slug"
diff --git a/locales/template.toml b/locales/template.toml
index 0d25ebd8..09c7dc04 100644
--- a/locales/template.toml
+++ b/locales/template.toml
@@ -2908,3 +2908,99 @@ toggle_label = ""
[msg.userfront.audit.filter]
description = ""
+
+[msg.admin.integrity.forbidden]
+description = ""
+
+[msg.admin.integrity.orphan_login_ids]
+delete_confirm = ""
+delete_success = ""
+description = ""
+empty = ""
+load_error = ""
+
+[msg.admin.integrity.read_model]
+description = ""
+
+[msg.admin.integrity.recheck]
+error = ""
+running = ""
+success = ""
+
+[msg.admin.integrity.report]
+load_error = ""
+
+[ui.admin.integrity]
+kicker = ""
+loading = ""
+title = ""
+
+[ui.admin.integrity.forbidden]
+title = ""
+
+[ui.admin.integrity.orphan_login_ids]
+delete = ""
+title = ""
+
+[ui.admin.integrity.read_model]
+title = ""
+
+[ui.admin.integrity.reason]
+deleted_tenant = ""
+deleted_user = ""
+missing_tenant = ""
+missing_user = ""
+
+[ui.admin.integrity.recheck]
+run = ""
+running = ""
+
+[ui.admin.integrity.status]
+fail = ""
+pass = ""
+warning = ""
+
+[ui.admin.integrity.summary]
+checked_at = ""
+failures = ""
+passed = ""
+total_checks = ""
+
+[ui.admin.integrity.table]
+field = ""
+login_id = ""
+reason = ""
+select = ""
+select_item = ""
+tenant = ""
+user = ""
+
+[msg.admin.api_keys.list]
+edit_scopes_desc = ""
+rotate_confirm = ""
+rotate_secret_notice = ""
+
+[msg.admin.tenants]
+export_error = ""
+
+[ui.admin.api_keys.list]
+edit_scopes = ""
+rotate_secret = ""
+rotate_secret_done = ""
+save_scopes = ""
+
+[ui.admin.overview.summary]
+total_users = ""
+
+[ui.admin.tenants.sub]
+export = ""
+
+[ui.admin.users.bulk]
+permission_placeholder = ""
+status_placeholder = ""
+
+[ui.dev.profile.org]
+tenant_slug = ""
+
+[ui.userfront.profile.field]
+tenant_slug = ""
diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
index b5d7a05a..8d39d14a 100644
--- a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
+++ b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
type OrgNode,
buildOrgSelectionOptions,
+ buildUsersMap,
clampScale,
getOrgNodeHeaderFill,
getSemanticZoomMode,
@@ -385,4 +386,24 @@ describe("org chart layout", () => {
buildOrgSelectionOptions(familyRoot).map((option) => option.label),
).toEqual(["총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹"]);
});
+
+ it("maps legacy companyCode users to matching tenant slugs", () => {
+ const usersMap = buildUsersMap(
+ [
+ {
+ ...member("engineering-user"),
+ companyCode: "engineering",
+ tenantSlug: undefined,
+ tenant: undefined,
+ joinedTenants: undefined,
+ },
+ ],
+ [tenantNode("engineering", "ORGANIZATION", "Engineering", "engineering")],
+ { activeOnly: true },
+ );
+
+ expect(usersMap.get("engineering")?.map((user) => user.id)).toEqual([
+ "engineering-user",
+ ]);
+ });
});
diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
index 68d8c1ce..8f6e4781 100644
--- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
+++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
@@ -1132,7 +1132,7 @@ function getLeafMembershipSlugs(
});
}
-function buildUsersMap(
+export function buildUsersMap(
users: UserSummary[],
rootNodes: TenantNode[],
options: { activeOnly: boolean },
@@ -1146,6 +1146,7 @@ function buildUsersMap(
const slugs = new Set();
const primarySlug = user.tenantSlug?.toLowerCase() || "";
+ const legacyCompanySlug = user.companyCode?.toLowerCase() || "";
if (
primarySlug &&
!isSystemGlobalTenant({
@@ -1157,6 +1158,17 @@ function buildUsersMap(
) {
slugs.add(primarySlug);
}
+ if (
+ legacyCompanySlug &&
+ !isSystemGlobalTenant({
+ id: legacyCompanySlug,
+ slug: legacyCompanySlug,
+ type: legacyCompanySlug,
+ name: legacyCompanySlug,
+ })
+ ) {
+ slugs.add(legacyCompanySlug);
+ }
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
slugs.add(user.tenant.slug.toLowerCase());
}