diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index 170637e3..50284603 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -17,15 +17,16 @@ import { fetchDataIntegrityReport, fetchOrphanUserLoginIDs, } from "../../lib/adminApi"; +import { t } from "../../lib/i18n"; function statusLabel(status: DataIntegrityStatus) { switch (status) { case "pass": - return "정상"; + return t("ui.admin.integrity.status.pass", "정상"); case "warning": - return "주의"; + return t("ui.admin.integrity.status.warning", "주의"); case "fail": - return "실패"; + return t("ui.admin.integrity.status.fail", "실패"); default: return status; } @@ -65,13 +66,13 @@ function CheckIcon({ check }: { check: DataIntegrityCheck }) { function reasonLabel(reason: string) { switch (reason) { case "missing_user": - return "사용자 없음"; + return t("ui.admin.integrity.reason.missing_user", "사용자 없음"); case "deleted_user": - return "삭제된 사용자"; + return t("ui.admin.integrity.reason.deleted_user", "삭제된 사용자"); case "missing_tenant": - return "테넌트 없음"; + return t("ui.admin.integrity.reason.missing_tenant", "테넌트 없음"); case "deleted_tenant": - return "삭제된 테넌트"; + return t("ui.admin.integrity.reason.deleted_tenant", "삭제된 테넌트"); default: return reason; } @@ -80,11 +81,14 @@ function reasonLabel(reason: string) { function recheckStatusText(status: "idle" | "running" | "success" | "error") { switch (status) { case "running": - return "정합성 검사를 실행 중입니다."; + return t( + "msg.admin.integrity.recheck.running", + "정합성 검사를 실행 중입니다.", + ); case "success": - return "검사가 완료되었습니다."; + return t("msg.admin.integrity.recheck.success", "검사가 완료되었습니다."); case "error": - return "검사에 실패했습니다."; + return t("msg.admin.integrity.recheck.error", "검사에 실패했습니다."); default: return ""; } @@ -102,7 +106,10 @@ function OrphanLoginIDTable({ if (items.length === 0) { return (
- 삭제할 유령 로그인 ID가 없습니다. + {t( + "msg.admin.integrity.orphan_login_ids.empty", + "삭제할 유령 로그인 ID가 없습니다.", + )}
); } @@ -113,12 +120,24 @@ function OrphanLoginIDTable({ - - - - - - + + + + + + @@ -127,7 +146,11 @@ function OrphanLoginIDTable({
선택Login IDFieldUserTenant사유 + {t("ui.admin.integrity.table.select", "선택")} + + {t("ui.admin.integrity.table.login_id", "Login ID")} + + {t("ui.admin.integrity.table.field", "Field")} + + {t("ui.admin.integrity.table.user", "User")} + + {t("ui.admin.integrity.table.tenant", "Tenant")} + + {t("ui.admin.integrity.table.reason", "사유")} +
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를 확인한 뒤 선택 삭제합니다.", + )}

{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()); }