diff --git a/adminfront/src/features/coverage/adminLargePages.test.tsx b/adminfront/src/features/coverage/adminLargePages.test.tsx index ea9ef5de..6444e415 100644 --- a/adminfront/src/features/coverage/adminLargePages.test.tsx +++ b/adminfront/src/features/coverage/adminLargePages.test.tsx @@ -143,6 +143,7 @@ vi.mock("../../lib/adminApi", () => ({ login_count: 3, }, ]), + fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({ items: [] })), fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12, lowercase: true, @@ -196,6 +197,7 @@ vi.mock("../../lib/adminApi", () => ({ worksmobileId: "works-user-1", worksmobileName: "Engineer User", worksmobileEmail: "engineer@example.com", + worksmobileDomainId: 1001, worksmobilePrimaryOrgId: "works-org-1", worksmobilePrimaryOrgName: "기술연구팀", status: "matched", @@ -380,17 +382,19 @@ describe("adminfront large page coverage smoke", () => { fireEvent.click( screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }), ); + fireEvent.change(screen.getByLabelText("초기 비밀번호"), { + target: { value: "InitialPassword!1" }, + }); + fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" })); await waitFor(() => expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith( "tenant-company", "user-2", - expect.any(String), + undefined, + "InitialPassword!1", ), ); - const credentialBatchId = vi.mocked( - adminApi.enqueueWorksmobileUserSync, - ).mock.calls[0][2]; expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled(); }); @@ -416,6 +420,10 @@ describe("adminfront large page coverage smoke", () => { fireEvent.click( screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }), ); + fireEvent.change(screen.getByLabelText("초기 비밀번호"), { + target: { value: "InitialPassword!1" }, + }); + fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" })); await waitFor(() => expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2), @@ -424,21 +432,20 @@ describe("adminfront large page coverage smoke", () => { 1, "tenant-company", "user-2", - expect.any(String), + undefined, + "InitialPassword!1", ); expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith( 2, "tenant-company", "user-3", - expect.any(String), + undefined, + "InitialPassword!1", ); expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled(); }); - it("downloads or deletes Worksmobile credential batches from history", async () => { - vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test"); - vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {}); - vi.spyOn(window, "confirm").mockReturnValue(true); + it("renders and retries Worksmobile jobs from history", async () => { renderWithProviders( { ); fireEvent.click(screen.getByRole("tab", { name: "이력" })); - await screen.findByText("credential-batch-1"); - expect( - screen.getByRole("button", { - name: "credential-batch-pending 비밀번호 CSV 다운로드", - }), - ).toBeDisabled(); - fireEvent.click( - screen.getByRole("button", { - name: "credential-batch-1 비밀번호 CSV 다운로드", - }), - ); - await waitFor(() => - expect( - adminApi.downloadWorksmobileInitialPasswordsCSV, - ).toHaveBeenCalledWith("tenant-company", "credential-batch-1"), - ); + expect((await screen.findAllByText("user-1")).length).toBeGreaterThan(0); + expect(screen.getByText("failed")).toBeInTheDocument(); - fireEvent.click( - screen.getByRole("button", { - name: "credential-batch-1 비밀번호 값 삭제", - }), - ); + fireEvent.click(screen.getAllByRole("button", { name: "" })[0]); await waitFor(() => - expect( - adminApi.deleteWorksmobileCredentialBatchPasswords, - ).toHaveBeenCalledWith("tenant-company", "credential-batch-1"), + expect(adminApi.retryWorksmobileJob).toHaveBeenCalledWith( + "tenant-company", + "job-1", + ), ); - - fireEvent.click( - screen.getByRole("button", { - name: "credential-batch-1 실패 사유 보기", - }), - ); - expect(await screen.findByText("failed-user@samaneng.com")).toBeInTheDocument(); - expect(screen.getByText("worksmobile api failed")).toBeInTheDocument(); }); - it("enqueues Worksmobile password reset as a credential batch", async () => { - vi.spyOn(window, "confirm").mockReturnValue(true); + it("opens Worksmobile password management for matched users", async () => { + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); renderWithProviders( { await screen.findAllByText("Engineer User"); fireEvent.click( screen.getByRole("button", { - name: "Engineer User 비밀번호 재설정", + name: "Engineer User 비밀번호 관리", }), ); - await waitFor(() => - expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith( - "tenant-company", - "user-1", - expect.any(String), + expect(openSpy).toHaveBeenCalledWith( + expect.stringContaining( + "https://auth.worksmobile.com/integrate/password/manage", ), + "_blank", + "noopener,noreferrer", ); - expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled(); + const [url] = openSpy.mock.calls[0] ?? []; + const parsed = new URL(String(url)); + expect(parsed.searchParams.get("targetUserTenantId")).toBe("works-admin"); + expect(parsed.searchParams.get("targetUserDomainId")).toBe("1001"); + expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1"); }); }); diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index 19d9628a..e7f1e56c 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -149,9 +149,13 @@ describe("DataIntegrityPage", () => { it("renders Ory SSOT cache management inside data integrity", async () => { renderPage(); - fireEvent.click(await screen.findByRole("tab", { name: "Ory SSOT 시스템" })); + fireEvent.click( + await screen.findByRole("tab", { name: "Ory SSOT 시스템" }), + ); - expect((await screen.findAllByText("Ory SSOT 시스템")).length).toBeGreaterThan(0); + expect( + (await screen.findAllByText("Ory SSOT 시스템")).length, + ).toBeGreaterThan(0); expect(await screen.findByText("Redis identity cache")).toBeInTheDocument(); expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0); expect(screen.getByText("152")).toBeInTheDocument(); diff --git a/adminfront/src/features/projections/UserProjectionPage.test.tsx b/adminfront/src/features/projections/UserProjectionPage.test.tsx index 1c95ebbe..febccde2 100644 --- a/adminfront/src/features/projections/UserProjectionPage.test.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.test.tsx @@ -84,7 +84,9 @@ describe("UserProjectionPage", () => { await screen.findByText("Ory SSOT 시스템"); expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull(); - expect(screen.queryByRole("button", { name: /초기화 후 재구축/ })).toBeNull(); + expect( + screen.queryByRole("button", { name: /초기화 후 재구축/ }), + ).toBeNull(); fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ })); await waitFor(() => { diff --git a/adminfront/src/features/users/GlobalCustomClaimsPage.tsx b/adminfront/src/features/users/GlobalCustomClaimsPage.tsx index fb097a53..227e9747 100644 --- a/adminfront/src/features/users/GlobalCustomClaimsPage.tsx +++ b/adminfront/src/features/users/GlobalCustomClaimsPage.tsx @@ -209,6 +209,7 @@ export default function GlobalCustomClaimsPage() { > updateClaim(claim.id, { @@ -253,6 +256,7 @@ export default function GlobalCustomClaimsPage() { "읽기 권한", )} value={claim.readPermission} + name={`global-claim-definition-read-permission-${claim.id}`} className="h-10 rounded-md border border-input bg-background px-3 text-sm" data-testid={`global-claim-definition-read-permission-${claim.key || claim.id}`} onChange={(event) => @@ -274,6 +278,7 @@ export default function GlobalCustomClaimsPage() { "쓰기 권한", )} value={claim.writePermission} + name={`global-claim-definition-write-permission-${claim.id}`} className="h-10 rounded-md border border-input bg-background px-3 text-sm" data-testid={`global-claim-definition-write-permission-${claim.key || claim.id}`} onChange={(event) => @@ -291,6 +296,7 @@ export default function GlobalCustomClaimsPage() { { expect(valueInput).toHaveAttribute("type", "date"); fireEvent.change(valueInput, { target: { value: "2026-07-01" } }); - fireEvent.click(screen.getByRole("button", { name: /사용자 Claim 값 저장/ })); + fireEvent.click( + screen.getByRole("button", { name: /사용자 Claim 값 저장/ }), + ); await waitFor(() => expect(updateUserMock).toHaveBeenCalled()); expect(updateUserMock).toHaveBeenCalledWith( diff --git a/adminfront/src/lib/tenantTree.test.ts b/adminfront/src/lib/tenantTree.test.ts index d9f528e7..9e4585a2 100644 --- a/adminfront/src/lib/tenantTree.test.ts +++ b/adminfront/src/lib/tenantTree.test.ts @@ -86,9 +86,7 @@ describe("tenantTree utility", () => { expect(currentBase?.recursiveMemberCount).toBe(17); expect(currentBase?.children[0]?.recursiveMemberCount).toBe(7); - expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe( - 2, - ); + expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe(2); }); it("keeps total member counts when descendants are not loaded on the current page", () => { diff --git a/adminfront/src/test/formFieldDiagnostics.test.ts b/adminfront/src/test/formFieldDiagnostics.test.ts index 63f1ab14..9c0f8a98 100644 --- a/adminfront/src/test/formFieldDiagnostics.test.ts +++ b/adminfront/src/test/formFieldDiagnostics.test.ts @@ -26,8 +26,11 @@ describe("adminfront form field diagnostics", () => { for (const file of sourceFiles("src")) { const source = readFileSync(file, "utf8"); - let match: RegExpExecArray | null; - while ((match = formFieldTagPattern.exec(source))) { + for ( + let match = formFieldTagPattern.exec(source); + match !== null; + match = formFieldTagPattern.exec(source) + ) { const tag = match[0]; if (/\b(id|name)\s*=/.test(tag)) continue; if (/\{\.\.\s*[^}]+\}/.test(tag)) continue; diff --git a/adminfront/src/test/formFieldDiagnostics.ts b/adminfront/src/test/formFieldDiagnostics.ts index ce99f5d5..a009821b 100644 --- a/adminfront/src/test/formFieldDiagnostics.ts +++ b/adminfront/src/test/formFieldDiagnostics.ts @@ -1,10 +1,11 @@ import { expect } from "vitest"; export function anonymousFormFields(container: ParentNode) { - return Array.from(container.querySelectorAll("input, select, textarea")).filter( + return Array.from( + container.querySelectorAll("input, select, textarea"), + ).filter( (field) => - !field.getAttribute("id")?.trim() && - !field.getAttribute("name")?.trim(), + !field.getAttribute("id")?.trim() && !field.getAttribute("name")?.trim(), ); } diff --git a/adminfront/src/test/i18nMock.ts b/adminfront/src/test/i18nMock.ts index 4f36e5b4..b6954015 100644 --- a/adminfront/src/test/i18nMock.ts +++ b/adminfront/src/test/i18nMock.ts @@ -67,12 +67,14 @@ const translations: Record<"ko" | "en", Record> = { "ui.admin.ory_ssot.title": "Ory SSOT 시스템", "msg.admin.ory_ssot.flush_confirm": "Redis identity cache 키만 비우시겠습니까?", - "msg.admin.ory_ssot.flush_error": "Redis identity cache flush에 실패했습니다.", + "msg.admin.ory_ssot.flush_error": + "Redis identity cache flush에 실패했습니다.", "msg.admin.ory_ssot.flush_success": "Redis identity cache key {{count}}개를 비웠습니다.", "msg.admin.ory_ssot.forbidden.description": "이 화면은 super_admin 권한으로만 접근할 수 있습니다.", - "msg.admin.ory_ssot.load_error": "Ory SSOT 시스템 상태를 불러오지 못했습니다.", + "msg.admin.ory_ssot.load_error": + "Ory SSOT 시스템 상태를 불러오지 못했습니다.", "msg.admin.ory_ssot.subtitle": "Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.", "msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.", @@ -156,8 +158,7 @@ const translations: Record<"ko" | "en", Record> = { "ui.admin.ory_ssot.summary.status": "Status", "ui.admin.ory_ssot.summary.updated_at": "Updated at", "ui.admin.ory_ssot.title": "Ory SSOT System", - "msg.admin.ory_ssot.flush_confirm": - "Flush only Redis identity cache keys?", + "msg.admin.ory_ssot.flush_confirm": "Flush only Redis identity cache keys?", "msg.admin.ory_ssot.flush_error": "Redis identity cache flush failed.", "msg.admin.ory_ssot.flush_success": "Flushed {{count}} Redis identity cache keys.", diff --git a/adminfront/tests/security_roles.spec.ts b/adminfront/tests/security_roles.spec.ts index f314d357..3e1cda3a 100644 --- a/adminfront/tests/security_roles.spec.ts +++ b/adminfront/tests/security_roles.spec.ts @@ -179,9 +179,7 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자 await expect(page.locator('a[href="/tenants"]')).toBeVisible(); await expect(page.locator('a[href="/api-keys"]')).toBeVisible(); await expect(page.locator('a[href="/audit-logs"]')).toBeVisible(); - await expect( - page.locator('a[href="/system/ory-ssot"]'), - ).toBeVisible(); + await expect(page.locator('a[href="/system/ory-ssot"]')).toBeVisible(); await expect( page.locator('a[href="/system/data-integrity"]'), ).toBeVisible(); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 18aaaf86..9894e5cd 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -202,6 +202,7 @@ test.describe("Tenants Management", () => { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": 'attachment; filename="tenant-users.csv"', + "access-control-expose-headers": "content-disposition", }, body: "email,name\nmember@example.com,Member User\n", }); diff --git a/adminfront/vitest.config.ts b/adminfront/vitest.config.ts index 2e395e43..7b21ced5 100644 --- a/adminfront/vitest.config.ts +++ b/adminfront/vitest.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ "../common/**/node_modules/**", "../common/.pnpm-store/**", `${commonRoot}/theme/**`, + `${commonRoot}/core/components/audit/AuditLogTable.tsx`, `${commonRoot}/core/pagination/*.worker.ts`, `${commonRoot}/core/query/queryClient.ts`, ], diff --git a/backend/cmd/adminctl/main_test.go b/backend/cmd/adminctl/main_test.go index 1fdc582e..271a61c4 100644 --- a/backend/cmd/adminctl/main_test.go +++ b/backend/cmd/adminctl/main_test.go @@ -100,7 +100,6 @@ func TestAuditWorksmobileDuplicatePhoneCountryCodesReportsAndFixes(t *testing.T) output := &strings.Builder{} count, err := auditWorksmobileDuplicatePhoneCountryCodes(context.Background(), output, true, client) - if err != nil { t.Fatalf("auditWorksmobileDuplicatePhoneCountryCodes returned error: %v", err) } diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 6fcafb7d..1f12384e 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -39,7 +39,7 @@ type DevHandler struct { KetoOutbox repository.KetoOutboxRepository RPSvc service.RelyingPartyService TenantSvc service.TenantService - DeveloperSvc *service.DeveloperService + DeveloperSvc developerRequestService RPUserMetadataRepo repository.RPUserMetadataRepository RPUsageQueries domain.RPUsageQueryRepository Auth interface { @@ -47,6 +47,16 @@ type DevHandler struct { } } +type developerRequestService interface { + RequestAccess(ctx context.Context, req domain.DeveloperRequest) error + GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) + GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) + ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) + ApproveRequest(ctx context.Context, id uint, adminNotes string) error + RejectRequest(ctx context.Context, id uint, adminNotes string) error + CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error +} + func NewDevHandler( redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, @@ -426,7 +436,28 @@ func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domai return false } allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", tenantID, "grant_dev_permissions") - return err == nil && allowed + if err == nil && allowed { + return true + } + return h.hasApprovedDeveloperRequest(c, profile, tenantID) +} + +func (h *DevHandler) hasApprovedDeveloperRequest(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool { + if h.DeveloperSvc == nil || profile == nil { + return false + } + userID := strings.TrimSpace(profile.ID) + tenantID = strings.TrimSpace(tenantID) + if userID == "" || tenantID == "" { + return false + } + status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), userID, tenantID) + if err != nil || status == nil { + return false + } + return status.Status == domain.DeveloperRequestStatusApproved && + strings.TrimSpace(status.UserID) == userID && + strings.TrimSpace(status.TenantID) == tenantID } func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index d37b5d64..bdfb70e8 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -62,6 +62,54 @@ func (m *devMockKetoService) ListObjects(ctx context.Context, ns, rel, sub strin return args.Get(0).([]string), args.Error(1) } +type devMockDeveloperService struct { + mock.Mock +} + +func (m *devMockDeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error { + args := m.Called(ctx, req) + return args.Error(0) +} + +func (m *devMockDeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) { + args := m.Called(ctx, userID, tenantID) + if req, ok := args.Get(0).(*domain.DeveloperRequest); ok { + return req, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *devMockDeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) { + args := m.Called(ctx, id) + if req, ok := args.Get(0).(*domain.DeveloperRequest); ok { + return req, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *devMockDeveloperService) ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) { + args := m.Called(ctx, userID, status) + if requests, ok := args.Get(0).([]domain.DeveloperRequest); ok { + return requests, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *devMockDeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error { + args := m.Called(ctx, id, adminNotes) + return args.Error(0) +} + +func (m *devMockDeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error { + args := m.Called(ctx, id, adminNotes) + return args.Error(0) +} + +func (m *devMockDeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error { + args := m.Called(ctx, id, adminNotes) + return args.Error(0) +} + type devMockRedisRepo struct { data map[string]string } @@ -1521,6 +1569,66 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) { mockKeto.AssertExpectations(t) } +func TestCreateClient_ApprovedDeveloperRequestAllowsCreateWhenTenantGrantNotVisible(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPost && r.URL.Path == "/clients" { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + body["client_secret"] = "generated-secret" + return httpJSONAny(r, http.StatusCreated, body), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(false, nil).Maybe() + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "System", "global", "manage_all").Return(false, nil).Maybe() + + developerSvc := new(devMockDeveloperService) + developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperRequest{ + UserID: "user-1", + TenantID: "tenant-a", + Status: domain.DeveloperRequestStatusApproved, + }, nil).Maybe() + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + SecretRepo: &mockSecretRepo{secrets: make(map[string]string)}, + Redis: &devMockRedisRepo{data: make(map[string]string)}, + Keto: mockKeto, + DeveloperSvc: developerSvc, + } + + app := fiber.New() + tenantID := "tenant-a" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleUser, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Post("/api/v1/dev/clients", h.CreateClient) + + body, _ := json.Marshal(map[string]any{ + "id": "client-1", + "name": "App One", + "type": "private", + "redirectUris": []string{"http://localhost/cb"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + mockKeto.AssertExpectations(t) + developerSvc.AssertExpectations(t) +} + func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) { mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe() diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index c0c90990..9c6c7d67 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "io" + "log/slog" "maps" "os" "reflect" @@ -22,6 +23,7 @@ import ( "strings" "time" + "github.com/go-redis/redis/v8" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) @@ -315,26 +317,14 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { } } - findRoot := func(id string) string { - curr := id - for { - p, exists := parentMap[curr] - if !exists || p == "" { - break - } - curr = p - } - return curr - } - roots := make(map[string]bool) for _, id := range baseTenantIDs { - roots[findRoot(id)] = true + roots[findTenantRootID(parentMap, id)] = true } // Filter tenants that belong to the same tree family for _, t := range allTenants { - if roots[findRoot(t.ID)] { + if roots[findTenantRootID(parentMap, t.ID)] { tenants = append(tenants, t) } } @@ -2774,6 +2764,14 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error { cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache"))) cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID")) ttl := orgChartSnapshotCacheTTL() + role, userID, profileTenantID := orgChartProfileLogValues(profile) + slog.Info("orgchart snapshot request started", + "user_id", userID, + "role", role, + "profile_tenant_id", profileTenantID, + "tenant_header", c.Get("X-Tenant-ID"), + "cache_mode", cacheMode, + ) if cacheMode == "redis" && h.OrgChartCache != nil { if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" { @@ -2785,13 +2783,43 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error { TTLSeconds: int(ttl.Seconds()), } c.Set("X-Orgfront-Cache", "HIT") + slog.Info("orgchart snapshot cache hit", + "user_id", userID, + "role", role, + "profile_tenant_id", profileTenantID, + "tenant_header", c.Get("X-Tenant-ID"), + "tenant_count", len(cached.Tenants), + "user_count", len(cached.Users), + ) return c.JSON(cached) } + slog.Warn("orgchart snapshot cache payload ignored", + "user_id", userID, + "role", role, + "profile_tenant_id", profileTenantID, + "tenant_header", c.Get("X-Tenant-ID"), + "error", err, + ) + } else if err != nil && err != redis.Nil { + slog.Warn("orgchart snapshot cache read failed", + "user_id", userID, + "role", role, + "profile_tenant_id", profileTenantID, + "tenant_header", c.Get("X-Tenant-ID"), + "error", err, + ) } } snapshot, err := h.buildOrgChartSnapshot(c.Context(), profile) if err != nil { + slog.Error("orgchart snapshot build failed", + "user_id", userID, + "role", role, + "profile_tenant_id", profileTenantID, + "tenant_header", c.Get("X-Tenant-ID"), + "error", err, + ) return errorJSON(c, fiber.StatusServiceUnavailable, err.Error()) } snapshot.Cache = orgChartSnapshotCacheInfo{ @@ -2802,13 +2830,31 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error { if cacheMode == "redis" && h.OrgChartCache != nil { if raw, err := json.Marshal(snapshot); err == nil { - _ = h.OrgChartCache.Set(cacheKey, string(raw), ttl) + if err := h.OrgChartCache.Set(cacheKey, string(raw), ttl); err != nil { + slog.Warn("orgchart snapshot cache write failed", + "user_id", userID, + "role", role, + "profile_tenant_id", profileTenantID, + "tenant_header", c.Get("X-Tenant-ID"), + "error", err, + ) + } } c.Set("X-Orgfront-Cache", "MISS") } else { c.Set("X-Orgfront-Cache", "BYPASS") } + slog.Info("orgchart snapshot request completed", + "user_id", userID, + "role", role, + "profile_tenant_id", profileTenantID, + "tenant_header", c.Get("X-Tenant-ID"), + "cache_mode", cacheMode, + "cache_result", c.GetRespHeader("X-Orgfront-Cache"), + "tenant_count", len(snapshot.Tenants), + "user_count", len(snapshot.Users), + ) return c.JSON(snapshot) } @@ -2880,27 +2926,16 @@ func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profi parentMap[tenant.ID] = *tenant.ParentID } } - findRoot := func(id string) string { - curr := id - for { - parentID, exists := parentMap[curr] - if !exists || parentID == "" { - return curr - } - curr = parentID - } - } - roots := make(map[string]bool) for _, id := range baseTenantIDs { if strings.TrimSpace(id) != "" { - roots[findRoot(id)] = true + roots[findTenantRootID(parentMap, id)] = true } } tenants := make([]domain.Tenant, 0, len(allTenants)) for _, tenant := range allTenants { - if roots[findRoot(tenant.ID)] { + if roots[findTenantRootID(parentMap, tenant.ID)] { tenants = append(tenants, tenant) } } @@ -2980,6 +3015,36 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID) } +func orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) { + if profile == nil { + return "anonymous", "anonymous", "" + } + tenantID := "" + if profile.TenantID != nil { + tenantID = strings.TrimSpace(*profile.TenantID) + } + return domain.NormalizeRole(profile.Role), strings.TrimSpace(profile.ID), tenantID +} + +func findTenantRootID(parentMap map[string]string, tenantID string) string { + curr := strings.TrimSpace(tenantID) + if curr == "" { + return "" + } + visited := map[string]struct{}{} + for { + parentID := strings.TrimSpace(parentMap[curr]) + if parentID == "" || parentID == curr { + return curr + } + if _, exists := visited[parentID]; exists { + return parentID + } + visited[curr] = struct{}{} + curr = parentID + } +} + func orgChartSnapshotCacheTTL() time.Duration { const defaultTTL = 5 * time.Minute raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS")) @@ -2996,16 +3061,26 @@ func orgChartSnapshotCacheTTL() time.Duration { func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { token := c.Query("token") if token == "" { + slog.Warn("public orgchart rejected missing token") return errorJSON(c, fiber.StatusUnauthorized, "share token is required") } link, err := h.SharedLink.ValidateToken(c.Context(), token) if err != nil { + slog.Warn("public orgchart token validation failed", + "token_length", len(token), + "error", err, + ) return errorJSON(c, fiber.StatusUnauthorized, err.Error()) } allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "") if err != nil { + slog.Error("public orgchart tenant list failed", + "link_id", link.ID, + "tenant_id", link.TenantID, + "error", err, + ) return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } @@ -3016,24 +3091,12 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { } } - findRoot := func(id string) string { - curr := id - for { - p, exists := parentMap[curr] - if !exists || p == "" { - break - } - curr = p - } - return curr - } - - sharedRootID := findRoot(link.TenantID) + sharedRootID := findTenantRootID(parentMap, link.TenantID) var filteredTenants []domain.Tenant var tenantIDs []string for _, t := range allTenants { - if findRoot(t.ID) == sharedRootID { + if findTenantRootID(parentMap, t.ID) == sharedRootID { filteredTenants = append(filteredTenants, t) } } @@ -3076,6 +3139,13 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { tenantSummaries = append(tenantSummaries, mapTenantSummary(t)) } + slog.Info("public orgchart request completed", + "link_id", link.ID, + "tenant_id", link.TenantID, + "shared_root_id", sharedRootID, + "tenant_count", len(tenantSummaries), + "user_count", len(publicUsers), + ) return c.JSON(fiber.Map{ "tenants": tenantSummaries, "users": publicUsers, diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 04840f30..2119d099 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -405,6 +405,68 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) { mockUsers.AssertExpectations(t) } +func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockProjection := new(MockUserProjectionRepoForHandler) + mockUsers := new(MockUserRepoForHandler) + now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC) + parent := func(id string) *string { return &id } + familyID := "hanmac-family-id" + samanID := "saman-id" + teamID := "saman-platform-id" + tenants := []domain.Tenant{ + {ID: familyID, Type: domain.TenantTypeCompanyGroup, ParentID: parent(familyID), Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + {ID: samanID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "삼안", Slug: "saman", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + {ID: teamID, Type: domain.TenantTypeUserGroup, ParentID: parent(samanID), Name: "플랫폼팀", Slug: "saman-platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + } + users := []domain.User{ + {ID: "user-1", Email: "user@samaneng.com", Name: "Saman User", Role: domain.RoleUser, Status: domain.UserStatusActive, TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now}, + } + + h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection} + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleUser, + TenantID: &samanID, + JoinedTenants: []domain.Tenant{ + tenants[1], + }, + }) + return c.Next() + }) + app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot) + + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once() + mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() + mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { + return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform") + })).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once() + mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { + return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform") + })).Return(map[string]int64{familyID: 1, samanID: 1, teamID: 0}, nil).Once() + mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once() + mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once() + + req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil) + resp, err := app.Test(req, 1000) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + var body struct { + Tenants []tenantSummary `json:"tenants"` + Users []userSummary `json:"users"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Len(t, body.Tenants, 3) + require.True(t, tenantSummarySlugsMatch(body.Tenants, "hanmac-family", "saman", "saman-platform")) + require.Len(t, body.Users, 1) + mockSvc.AssertExpectations(t) + mockProjection.AssertExpectations(t) + mockUsers.AssertExpectations(t) +} + func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) @@ -740,6 +802,25 @@ func tenantSlugsMatch(got []domain.Tenant, want ...string) bool { return true } +func tenantSummarySlugsMatch(got []tenantSummary, want ...string) bool { + if len(got) != len(want) { + return false + } + counts := make(map[string]int, len(want)) + for _, slug := range want { + counts[slug]++ + } + for _, tenant := range got { + counts[tenant.Slug]-- + } + for _, count := range counts { + if count != 0 { + return false + } + } + return true +} + func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) diff --git a/common/locales/en.toml b/common/locales/en.toml index 033d3b9b..995518c1 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -197,3 +197,10 @@ pending = "Pending" success = "Success" unchanged = "Unchanged" updated = "Updated" + +[ui.common] +searching = "Searching..." + +[ui.common.custom_claim_permission] +admin_only = "Admin only" +user_and_admin = "User and admin" diff --git a/common/locales/ko.toml b/common/locales/ko.toml index df1181b5..42b716d2 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -197,3 +197,10 @@ pending = "준비 중" success = "성공" unchanged = "동일" updated = "수정" + +[ui.common] +searching = "검색 중..." + +[ui.common.custom_claim_permission] +admin_only = "관리자만 가능" +user_and_admin = "사용자와 관리자" diff --git a/common/locales/template.toml b/common/locales/template.toml index 2c805c39..7b3a6de3 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -197,3 +197,10 @@ pending = "" success = "" unchanged = "" updated = "" + +[ui.common] +searching = "" + +[ui.common.custom_claim_permission] +admin_only = "" +user_and_admin = "" diff --git a/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png b/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png index 45d94273..5672cf71 100644 Binary files a/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png and b/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png differ diff --git a/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png b/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png index 5632b0e3..e8766b16 100644 Binary files a/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png and b/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png differ diff --git a/devfront/src/features/clients/ClientDetailTabs.tsx b/devfront/src/features/clients/ClientDetailTabs.tsx index bd81bbeb..ebb174d3 100644 --- a/devfront/src/features/clients/ClientDetailTabs.tsx +++ b/devfront/src/features/clients/ClientDetailTabs.tsx @@ -35,7 +35,8 @@ export function ClientDetailTabs({
{tabOrder.map((tab) => { const isActive = tab.key === activeTab; - const labelKey = tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`; + const labelKey = + tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`; return isActive ? ( { }); test("adds and removes allowed tenants with UUID copy evidence", async ({ - context, page, }, testInfo) => { - await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.addInitScript(() => { + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: true, + }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: async (value: string) => { + window.localStorage.setItem("__e2e_copied_text", value); + }, + }, + }); + }); const state = { clients: [ @@ -82,7 +94,9 @@ test.describe("DevFront client tenant access settings", () => { ).toContainText(existingTenantId); await page.getByTestId(`allowed-tenant-copy-${existingTenantId}`).click(); await expect - .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .poll(() => + page.evaluate(() => window.localStorage.getItem("__e2e_copied_text")), + ) .toBe(existingTenantId); await page diff --git a/locales/en.toml b/locales/en.toml index 9958b574..000444dc 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -2961,3 +2961,79 @@ tenant_slug = "Tenant slug" "ui.admin.tenants.data_mgmt" = "temp" "ui.admin.tenants.toggle_status" = "temp" "ui.admin.users.data_mgmt" = "temp" + +[msg.admin.ory_ssot] +flush_confirm = "Flush only Redis identity cache keys?" +flush_error = "Redis identity cache flush failed." +flush_success = "Flushed {{count}} Redis identity cache keys." +load_error = "Failed to load Ory SSOT system status." +subtitle = "Review Kratos source-of-truth and Redis identity cache status separately." + +[msg.admin.ory_ssot.forbidden] +description = "This screen is only available to super_admin users." + +[msg.admin.tenants.members] +add_error = "Failed to add members" +add_success = "Added {{count}} members." + +[msg.admin.users.global_custom_claims] +description = "Manage user claim definitions shared by all RPs and default read/write permissions." +empty = "No global claims are defined." +registry = "Only defined claim keys are available for global claim value management on user details." + +[ui.admin.integrity] +tab_ory_ssot = "Ory SSOT System" + +[ui.admin.ory_ssot] +loading = "Loading Ory SSOT status..." +title = "Ory SSOT System" + +[ui.admin.ory_ssot.actions] +flush_identity_cache = "Redis cache flush" + +[ui.admin.ory_ssot.cache_card] +description = "Redis mirror/cache status for Kratos identity list and lookup operations." +title = "Redis identity cache" + +[ui.admin.ory_ssot.forbidden] +title = "Access denied" + +[ui.admin.ory_ssot.projection_card] +description = "PostgreSQL read model status used by admin search and statistics." +title = "Backend user read model" + +[ui.admin.ory_ssot.status] +failed = "failed" +not_ready = "not ready" +ready = "ready" + +[ui.admin.ory_ssot.summary] +cache_keys = "Cache keys" +last_refreshed = "Last refreshed" +last_synced = "Last read-model refresh" +local_users = "Local users" +observed_identities = "Observed identities" +status = "Status" +updated_at = "Updated at" + +[ui.admin.tenants] +search_match_badge = "Search match" + +[ui.admin.tenants.members] +add_existing_description = "Select search results into an add queue, then assign them in one operation." +add_queued = "Add selected members" +export = "Selected organization users CSV" +queue_empty = "Select members to add." +queue_remove = "Remove from add queue" +search_min_length = "Enter at least two characters." +search_placeholder = "Search by name or email" + +[ui.admin.users.global_custom_claims] +description_placeholder = "Optional claim description" +label_placeholder = "Display name" +manage_definitions = "Manage Global Definitions" +read_permission = "Read permission" +registry = "Global Claim Registry" +title = "Global Claim Settings" +value_type = "Claim type" +write_permission = "Write permission" diff --git a/locales/ko.toml b/locales/ko.toml index a84bd354..b6a4240d 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -3387,3 +3387,79 @@ tenant_slug = "테넌트 slug" "ui.admin.tenants.data_mgmt" = "temp" "ui.admin.tenants.toggle_status" = "temp" "ui.admin.users.data_mgmt" = "temp" + +[msg.admin.ory_ssot] +flush_confirm = "Redis identity cache 키만 비우시겠습니까?" +flush_error = "Redis identity cache flush에 실패했습니다." +flush_success = "Redis identity cache key {{count}}개를 비웠습니다." +load_error = "Ory SSOT 시스템 상태를 불러오지 못했습니다." +subtitle = "Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다." + +[msg.admin.ory_ssot.forbidden] +description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다." + +[msg.admin.tenants.members] +add_error = "구성원 추가 실패" +add_success = "{{count}}명의 구성원이 추가되었습니다." + +[msg.admin.users.global_custom_claims] +description = "모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다." +empty = "정의된 전역 claim이 없습니다." +registry = "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다." + +[ui.admin.integrity] +tab_ory_ssot = "Ory SSOT 시스템" + +[ui.admin.ory_ssot] +loading = "불러오는 중" +title = "Ory SSOT 시스템" + +[ui.admin.ory_ssot.actions] +flush_identity_cache = "Redis cache flush" + +[ui.admin.ory_ssot.cache_card] +description = "Kratos identity 목록 및 조회 작업을 위한 Redis mirror/cache 상태입니다." +title = "Redis identity cache" + +[ui.admin.ory_ssot.forbidden] +title = "접근 권한이 없습니다" + +[ui.admin.ory_ssot.projection_card] +description = "관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다." +title = "Backend 사용자 read model" + +[ui.admin.ory_ssot.status] +failed = "실패" +not_ready = "준비되지 않음" +ready = "준비됨" + +[ui.admin.ory_ssot.summary] +cache_keys = "Cache keys" +last_refreshed = "마지막 refresh" +last_synced = "마지막 read-model refresh" +local_users = "Local users" +observed_identities = "관측 identity" +status = "상태" +updated_at = "상태 갱신" + +[ui.admin.tenants] +search_match_badge = "검색 일치" + +[ui.admin.tenants.members] +add_existing_description = "검색 결과를 선택해 추가 명단에 담은 뒤 한 번에 배정합니다." +add_queued = "선택 구성원 추가" +export = "선택 조직 사용자 CSV" +queue_empty = "추가할 구성원을 선택하세요." +queue_remove = "추가 명단에서 제거" +search_min_length = "두 글자 이상 입력하세요." +search_placeholder = "이름 또는 이메일 검색" + +[ui.admin.users.global_custom_claims] +description_placeholder = "설명" +label_placeholder = "표시 이름" +manage_definitions = "전역 정의 관리" +read_permission = "읽기 권한" +registry = "Global Claim Registry" +title = "전역 Claim 설정" +value_type = "Claim 타입" +write_permission = "쓰기 권한" diff --git a/locales/template.toml b/locales/template.toml index 8a237ca4..95971af7 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -3284,3 +3284,79 @@ tenant_slug = "" "ui.admin.tenants.data_mgmt" = "temp" "ui.admin.tenants.toggle_status" = "temp" "ui.admin.users.data_mgmt" = "temp" + +[msg.admin.ory_ssot] +flush_confirm = "" +flush_error = "" +flush_success = "" +load_error = "" +subtitle = "" + +[msg.admin.ory_ssot.forbidden] +description = "" + +[msg.admin.tenants.members] +add_error = "" +add_success = "" + +[msg.admin.users.global_custom_claims] +description = "" +empty = "" +registry = "" + +[ui.admin.integrity] +tab_ory_ssot = "" + +[ui.admin.ory_ssot] +loading = "" +title = "" + +[ui.admin.ory_ssot.actions] +flush_identity_cache = "" + +[ui.admin.ory_ssot.cache_card] +description = "" +title = "" + +[ui.admin.ory_ssot.forbidden] +title = "" + +[ui.admin.ory_ssot.projection_card] +description = "" +title = "" + +[ui.admin.ory_ssot.status] +failed = "" +not_ready = "" +ready = "" + +[ui.admin.ory_ssot.summary] +cache_keys = "" +last_refreshed = "" +last_synced = "" +local_users = "" +observed_identities = "" +status = "" +updated_at = "" + +[ui.admin.tenants] +search_match_badge = "" + +[ui.admin.tenants.members] +add_existing_description = "" +add_queued = "" +export = "" +queue_empty = "" +queue_remove = "" +search_min_length = "" +search_placeholder = "" + +[ui.admin.users.global_custom_claims] +description_placeholder = "" +label_placeholder = "" +manage_definitions = "" +read_permission = "" +registry = "" +title = "" +value_type = "" +write_permission = "" diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx index 0b9d1765..65ec787b 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx @@ -40,6 +40,15 @@ type ViewBox = { height: number; }; +type OrgChartLoadErrorDiagnostics = { + cacheMode: "redis" | "public"; + code: string; + message: string; + route: string; + status: number | null; + tenantId: string; +}; + type OrgSelectionDescendantOption = { depth: 1 | 2; id: string; @@ -1215,6 +1224,36 @@ function normalizeOrgSlug(value: unknown) { return typeof value === "string" ? value.trim().toLowerCase() : ""; } +function readErrorText(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function getOrgChartLoadErrorDiagnostics( + error: unknown, + options: { cacheMode: "redis" | "public"; route: string; tenantId: string }, +): OrgChartLoadErrorDiagnostics { + const maybeError = error as { + config?: { url?: string }; + message?: string; + response?: { + data?: { code?: unknown; error?: unknown; message?: unknown }; + status?: number; + }; + }; + const responseData = maybeError.response?.data; + return { + cacheMode: options.cacheMode, + code: readErrorText(responseData?.code), + message: + readErrorText(responseData?.error) || + readErrorText(responseData?.message) || + readErrorText(maybeError.message), + route: maybeError.config?.url || options.route, + status: maybeError.response?.status ?? null, + tenantId: options.tenantId, + }; +} + function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] { const rawAppointments = user.metadata?.additionalAppointments; if (!Array.isArray(rawAppointments)) return []; @@ -1443,7 +1482,10 @@ export function TenantOrgChartPage() { } const rootNodes = buildTenantFullTree( - filterSystemGlobalTenants(orgChartSnapshotQuery.data.tenants, visibilityMode), + filterSystemGlobalTenants( + orgChartSnapshotQuery.data.tenants, + visibilityMode, + ), ).subTree; const membershipRootNodes = buildTenantFullTree( filterOrgChartMembershipTenants(orgChartSnapshotQuery.data.tenants), @@ -1601,6 +1643,24 @@ export function TenantOrgChartPage() { const isError = shareToken ? publicQuery.isError : orgChartSnapshotQuery.isError; + const currentLoadError = shareToken + ? publicQuery.error + : orgChartSnapshotQuery.error; + + React.useEffect(() => { + if (!currentLoadError) return; + console.error( + "[orgfront] Org chart load failed", + getOrgChartLoadErrorDiagnostics(currentLoadError, { + cacheMode: shareToken ? "public" : "redis", + route: shareToken + ? "/v1/public/orgchart" + : "/v1/admin/orgchart/snapshot", + tenantId: + tenantId ?? window.localStorage.getItem("dev_tenant_id") ?? "", + }), + ); + }, [currentLoadError, shareToken, tenantId]); const totalUsers = React.useMemo(() => { const ids = new Set(); @@ -1619,9 +1679,12 @@ export function TenantOrgChartPage() { } if (isError) { + const errorMessage = shareToken + ? "조직도를 불러올 수 없거나 만료된 링크입니다." + : "조직도를 불러올 수 없습니다. 로그인 상태와 조직 권한을 확인해 주세요."; return (
- 조직도를 불러올 수 없거나 만료된 링크입니다. + {errorMessage}
); } diff --git a/orgfront/src/lib/tenantTree.test.ts b/orgfront/src/lib/tenantTree.test.ts new file mode 100644 index 00000000..125f3e32 --- /dev/null +++ b/orgfront/src/lib/tenantTree.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import type { TenantSummary } from "./adminApi"; +import { buildTenantFullTree } from "./tenantTree"; + +function tenant( + id: string, + slug: string, + parentId?: string, + type = "USER_GROUP", +): TenantSummary { + return { + id, + type, + name: slug, + slug, + description: "", + status: "active", + parentId, + memberCount: 0, + totalMemberCount: 0, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + }; +} + +describe("buildTenantFullTree", () => { + it("treats a self-parent hanmac-family tenant as a root", () => { + const result = buildTenantFullTree([ + tenant( + "hanmac-family-id", + "hanmac-family", + "hanmac-family-id", + "COMPANY_GROUP", + ), + tenant("saman-id", "saman", "hanmac-family-id", "COMPANY"), + tenant("platform-id", "platform", "saman-id"), + ]); + + expect(result.subTree).toHaveLength(1); + expect(result.subTree[0]?.id).toBe("hanmac-family-id"); + expect(result.subTree[0]?.children[0]?.id).toBe("saman-id"); + expect(result.subTree[0]?.children[0]?.children[0]?.id).toBe( + "platform-id", + ); + }); +}); diff --git a/orgfront/src/lib/tenantTree.ts b/orgfront/src/lib/tenantTree.ts index 279d0d8e..710d3f22 100644 --- a/orgfront/src/lib/tenantTree.ts +++ b/orgfront/src/lib/tenantTree.ts @@ -60,9 +60,9 @@ export function buildTenantFullTree( return total; }; - // Calculate for all top-level nodes (those without parent) + // Calculate for all top-level nodes (those without parent or with self-parent) for (const node of tenantMap.values()) { - if (!node.parentId) { + if (!node.parentId || node.parentId === node.id) { visitedForCalc.clear(); calculateRecursive(node); } @@ -81,6 +81,8 @@ export function buildTenantFullTree( } // If no rootId, return all top-level roots as subTree - const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId); + const roots = Array.from(tenantMap.values()).filter( + (n) => !n.parentId || n.parentId === n.id, + ); return { currentBase: null, subTree: roots }; } diff --git a/orgfront/tests/orgchart-pan-zoom.spec.ts b/orgfront/tests/orgchart-pan-zoom.spec.ts index d8a77d19..ffb39a54 100644 --- a/orgfront/tests/orgchart-pan-zoom.spec.ts +++ b/orgfront/tests/orgchart-pan-zoom.spec.ts @@ -308,7 +308,9 @@ test("org chart defaults to hanmac family when public sector group is listed fir await page.goto("/chart"); const svg = page.locator('[data-testid="orgchart-vector-svg"]'); - await expect(page.getByRole("button", { name: "조직: 한맥가족" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "조직: 한맥가족" }), + ).toBeVisible(); await expect(svg.getByText("한맥가족", { exact: true })).toBeVisible(); await expect(svg.getByText("삼안", { exact: true })).toBeVisible(); await expect(svg.getByText("공공기관", { exact: true })).toHaveCount(0); diff --git a/orgfront/tests/orgchart-vector-render.spec.ts b/orgfront/tests/orgchart-vector-render.spec.ts index 45053ec7..2bbc81bd 100644 --- a/orgfront/tests/orgchart-vector-render.spec.ts +++ b/orgfront/tests/orgchart-vector-render.spec.ts @@ -515,10 +515,13 @@ test("org chart allows a user in a hanmac-family descendant tenant", async ({ contentType: "application/json", body: JSON.stringify({ tenants: [ - { - ...tenant("hanmac-family-id", "한맥가족", "hanmac-family"), - type: "COMPANY_GROUP", - }, + tenant( + "hanmac-family-id", + "한맥가족", + "hanmac-family", + "hanmac-family-id", + "COMPANY_GROUP", + ), tenant("saman-id", "삼안", "saman", "hanmac-family-id", "COMPANY"), tenant("saman-platform-id", "플랫폼팀", "saman-platform", "saman-id"), ], @@ -562,6 +565,57 @@ test("org chart allows a user in a hanmac-family descendant tenant", async ({ await expect(svg.getByText(/Saman Descendant User/)).toBeVisible(); }); +test("org chart logs authenticated snapshot failures with actionable diagnostics", async ({ + page, +}) => { + const consoleMessages: string[] = []; + page.on("console", async (message) => { + if (message.type() !== "error") return; + const values = await Promise.all( + message.args().map((arg) => arg.jsonValue().catch(() => "")), + ); + consoleMessages.push(JSON.stringify(values)); + }); + + await page.addInitScript(() => { + window.localStorage.setItem("playwright_auth_bypass", "1"); + window.localStorage.setItem("dev_tenant_id", "saman-id"); + }); + + await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => { + await route.fulfill({ + contentType: "application/json", + status: 503, + body: JSON.stringify({ + code: "service_unavailable", + error: "tenant root traversal failed", + }), + }); + }); + + await page.goto("/chart"); + + await expect( + page.getByText( + "조직도를 불러올 수 없습니다. 로그인 상태와 조직 권한을 확인해 주세요.", + ), + ).toBeVisible(); + await expect( + page.getByText("조직도를 불러올 수 없거나 만료된 링크입니다."), + ).toHaveCount(0); + await expect + .poll(() => + consoleMessages.some( + (message) => + message.includes("[orgfront] Org chart load failed") && + message.includes("service_unavailable") && + message.includes("saman-id") && + message.includes("/v1/admin/orgchart/snapshot"), + ), + ) + .toBe(true); +}); + test("org chart places GPDTDC representative users on visible leaf appointments", async ({ page, }) => {