diff --git a/adminfront/playwright.config.ts b/adminfront/playwright.config.ts index d1dc80d0..970a7ad9 100644 --- a/adminfront/playwright.config.ts +++ b/adminfront/playwright.config.ts @@ -3,6 +3,10 @@ import { defineConfig, devices } from "@playwright/test"; const configuredWorkers = process.env.PLAYWRIGHT_WORKERS ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) : undefined; +const port = Number.parseInt(process.env.PORT ?? "5173", 10); +const defaultBaseUrl = `http://127.0.0.1:${port}`; +const baseURL = process.env.BASE_URL ?? defaultBaseUrl; +const reuseExistingServer = !process.env.CI && !process.env.PORT; /** * Read environment variables from file. @@ -34,7 +38,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: "http://localhost:5173", + baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "retain-on-failure", @@ -60,12 +64,14 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - webServer: { - command: process.env.CI - ? "npm run build && npm run preview -- --port 5173" - : "npm run dev", - url: "http://localhost:5173", - reuseExistingServer: !process.env.CI, - timeout: 120 * 1000, - }, + webServer: process.env.BASE_URL + ? undefined + : { + command: process.env.CI + ? `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}` + : `npm run dev -- --host 127.0.0.1 --port ${port}`, + url: defaultBaseUrl, + reuseExistingServer, + timeout: 120 * 1000, + }, }); diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index a64b0c38..e6c90858 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -134,27 +134,23 @@ const SidebarNode: React.FC<{ ? "bg-primary text-primary-foreground font-semibold" : "hover:bg-muted/60 text-muted-foreground hover:text-foreground" } ${isMatching ? "ring-1 ring-primary/30 bg-primary/5" : ""}`} - onClick={() => onSelect(node.id)} + onClick={() => { + onSelect(node.id); + if (hasChildren) setIsExpanded(!isExpanded); + }} >
{/* Indent & Expander */}
{hasChildren ? ( - + ) : ( level > 0 &&
)} @@ -457,7 +453,7 @@ function TenantUserGroupsTab() { {selectedNode.slug}
- +
{selectedNode.recursiveMemberCount}{" "} {t("ui.admin.tenants.table.members", "명")} @@ -469,7 +465,7 @@ function TenantUserGroupsTab() { selectedNode.type, )} - +
diff --git a/adminfront/tests/bulk_actions.spec.ts b/adminfront/tests/bulk_actions.spec.ts index afef3fef..8b0b5e45 100644 --- a/adminfront/tests/bulk_actions.spec.ts +++ b/adminfront/tests/bulk_actions.spec.ts @@ -174,7 +174,7 @@ test.describe("Bulk Actions and Tree Search", () => { await searchInput.fill("Eng"); const engNode = page - .locator("button") + .locator('button, [role="button"]') .filter({ hasText: "Engineering" }) .first(); await expect(engNode).toBeVisible(); diff --git a/backend/cmd/fix_kratos_roles.go b/backend/cmd/fix_kratos_roles.go index 6a72f72e..51226a85 100644 --- a/backend/cmd/fix_kratos_roles.go +++ b/backend/cmd/fix_kratos_roles.go @@ -1,18 +1,17 @@ package main import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" "context" "fmt" "log" - - "baron-sso-backend/internal/domain" - "baron-sso-backend/internal/service" ) func main() { kratosAdmin := service.NewKratosAdminService() ctx := context.Background() - + identities, err := kratosAdmin.ListIdentities(ctx) if err != nil { log.Fatalf("Failed to list identities: %v", err) @@ -22,7 +21,7 @@ func main() { for _, id := range identities { traits := id.Traits changed := false - + if r, ok := traits["role"].(string); ok { norm := domain.NormalizeRole(r) if norm != r && norm == domain.RoleUser { diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index fad84419..647bea2c 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -42,6 +42,6 @@ func migrateSchemas(db *gorm.DB) error { &domain.ClientConsent{}, &domain.KetoOutbox{}, &domain.SharedLink{}, - // &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto - ) - } + // &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto + ) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 903bd720..f7aed790 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -5413,7 +5413,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe profile.ManageableTenants = manageable } } - + joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID) if err == nil { profile.JoinedTenants = joined diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 213d26c7..52db7050 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1519,7 +1519,7 @@ func TestRevokeHeadlessJWKSCache_DeletesCachedState(t *testing.T) { assert.Nil(t, stored) } -func TestListAuditLogs_TenantMemberForbidden(t *testing.T) { +func TestListAuditLogs_TenantMemberWithoutAuditPermissionReturnsEmpty(t *testing.T) { h := &DevHandler{ Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"}, AuditRepo: &mockAuditRepo{}, @@ -1540,7 +1540,11 @@ func TestListAuditLogs_TenantMemberForbidden(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs", nil) resp, _ := app.Test(req, -1) - assert.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result devAuditListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Empty(t, result.Items) } func TestListAuditLogs_RPAdminScope(t *testing.T) { @@ -1915,6 +1919,20 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) { } func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-1", + "client_name": "App One", + "metadata": map[string]any{ + "tenant_id": "tenant-1", + "status": "active", + }, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + mockKratos := new(devMockKratosAdmin) mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ { @@ -1938,6 +1956,10 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) { }, nil) h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, KratosAdmin: mockKratos, } @@ -1951,21 +1973,25 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) { ManageableTenants: []domain.Tenant{ {ID: "tenant-1", Slug: "tenant-one"}, }, + Metadata: map[string]any{ + "managed_client_ids": []any{"client-1"}, + }, }) return c.Next() }) app.Get("/api/v1/dev/users", h.SearchUsers) - req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?search=alice", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=alice", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result devUserListResponse _ = json.NewDecoder(resp.Body).Decode(&result) - assert.Len(t, result.Items, 1) - assert.Equal(t, "user-1", result.Items[0].ID) - assert.Equal(t, "Alice Kim", result.Items[0].Name) - assert.Equal(t, "alice@example.com", result.Items[0].Email) + if assert.Len(t, result.Items, 1) { + assert.Equal(t, "user-1", result.Items[0].ID) + assert.Equal(t, "Alice Kim", result.Items[0].Name) + assert.Equal(t, "alice@example.com", result.Items[0].Email) + } mockKratos.AssertExpectations(t) } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 16e7641f..7060627a 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -868,7 +868,6 @@ func normalizeTenantType(value string) string { } } - func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error { tenantID := c.Params("id") var req struct { @@ -932,7 +931,9 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { curr := id for { p, exists := parentMap[curr] - if !exists || p == "" { break } + if !exists || p == "" { + break + } curr = p } return curr @@ -967,10 +968,14 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { var usersByID []domain.User h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID) for _, u := range usersByID { - if u.Status != "active" || seen[u.ID] { continue } + if u.Status != "active" || seen[u.ID] { + continue + } seen[u.ID] = true cc := u.CompanyCode - if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug } + if cc == "" && u.Tenant != nil { + cc = u.Tenant.Slug + } publicUsers = append(publicUsers, publicUserSummary{ ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status, }) @@ -980,10 +985,14 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { var usersBySlug []domain.User h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug) for _, u := range usersBySlug { - if u.Status != "active" || seen[u.ID] { continue } + if u.Status != "active" || seen[u.ID] { + continue + } seen[u.ID] = true cc := u.CompanyCode - if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug } + if cc == "" && u.Tenant != nil { + cc = u.Tenant.Slug + } publicUsers = append(publicUsers, publicUserSummary{ ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status, }) @@ -995,8 +1004,8 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ - "tenants": tenantSummaries, - "users": publicUsers, + "tenants": tenantSummaries, + "users": publicUsers, "sharedWith": link.Name, }) } diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 1372cda0..a3739e25 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -204,24 +204,24 @@ func TestTenantHandler_ListTenants(t *testing.T) { } app.Use(func(c *fiber.Ctx) error { - c.Locals("user_profile", &domain.UserProfileResponse{ - Role: "super_admin", - }) - return c.Next() + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: "super_admin", + }) + return c.Next() }) app.Get("/tenants", h.ListTenants) tenants := []domain.Tenant{ - {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, - {ID: "t2", Name: "Tenant B", Slug: "slug-b"}, + {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, + {ID: "t2", Name: "Tenant B", Slug: "slug-b"}, } // Mocking for the new allTenants check in ListTenants mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe() mockUserRepo.On("CountByCompanyCodes", mock.Anything, mock.Anything). - Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe() + Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe() mockUserRepo.On("CountByTenantIDs", mock.Anything, mock.Anything). - Return(map[string]int64{}, nil).Maybe() + Return(map[string]int64{}, nil).Maybe() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) @@ -263,6 +263,7 @@ func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []s args := m.Called(ctx, tenantIDs) return args.Error(0) } + func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { args := m.Called(ctx, userID) if args.Get(0) != nil { diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index f2c2a2da..4c0f0757 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -133,7 +133,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { parentMap[t.ID] = *t.ParentID } } - + // Function to find the root of any given tenant findRoot := func(id string) string { curr := id @@ -331,17 +331,17 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { } var req struct { - Email string `json:"email"` - LoginID string `json:"loginId"` - Password string `json:"password"` - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role"` - CompanyCode string `json:"companyCode"` - Department string `json:"department"` - Position string `json:"position"` - JobTitle string `json:"jobTitle"` - Metadata map[string]any `json:"metadata"` + Email string `json:"email"` + LoginID string `json:"loginId"` + Password string `json:"password"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + CompanyCode string `json:"companyCode"` + Department string `json:"department"` + Position string `json:"position"` + JobTitle string `json:"jobTitle"` + Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") @@ -1305,7 +1305,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { traits["tenant_id"] = tenant.ID } } - + // Add to existingCodes if not present found := false for _, existing := range existingCodes { diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 4ccaffad..7e562367 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -61,7 +61,7 @@ func (r *userRepository) Update(ctx context.Context, user *domain.User) error { } } - // 2. Perform Upsert based on ID. + // 2. Perform Upsert based on ID. // In GORM v2, true upsert requires Create() with OnConflict on the primary key. return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, diff --git a/backend/internal/service/mock_common_test.go b/backend/internal/service/mock_common_test.go index bb9c7692..d80e6c70 100644 --- a/backend/internal/service/mock_common_test.go +++ b/backend/internal/service/mock_common_test.go @@ -94,6 +94,7 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity } return args.Get(0).(*KratosIdentity), args.Error(1) } + func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) { args := m.Called(ctx, identityID, traits, state) if args.Get(0) == nil { @@ -120,9 +121,11 @@ func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *dom func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { return nil, nil } + func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { return nil, nil } + func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error { return nil } diff --git a/backend/internal/service/org_chart_service.go b/backend/internal/service/org_chart_service.go index 68a11ca4..0a80c5b9 100644 --- a/backend/internal/service/org_chart_service.go +++ b/backend/internal/service/org_chart_service.go @@ -19,8 +19,10 @@ import ( "github.com/xuri/excelize/v2" ) -var whitespaceRegex = regexp.MustCompile(`\s+`) -var nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`) +var ( + whitespaceRegex = regexp.MustCompile(`\s+`) + nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`) +) type ProgressData struct { Current int `json:"current"` @@ -30,12 +32,12 @@ type ProgressData struct { var ImportProgressCache sync.Map type ImportResult struct { - TotalRows int `json:"totalRows"` - Processed int `json:"processed"` - UserCreated int `json:"userCreated"` - UserUpdated int `json:"userUpdated"` - TenantCreated int `json:"tenantCreated"` - Errors []string `json:"errors"` + TotalRows int `json:"totalRows"` + Processed int `json:"processed"` + UserCreated int `json:"userCreated"` + UserUpdated int `json:"userUpdated"` + TenantCreated int `json:"tenantCreated"` + Errors []string `json:"errors"` } type OrgChartService interface { @@ -86,13 +88,13 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r } fieldMapping := map[string][]string{ - "email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"}, - "name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"}, - "position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"}, - "jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"}, - "phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"}, - "company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"}, - "is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"}, + "email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"}, + "name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"}, + "position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"}, + "jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"}, + "phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"}, + "company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"}, + "is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"}, } var dataRows [][]string @@ -102,11 +104,15 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r for sheetIdx, records := range allSheetsRecords { for i, row := range records { - if len(row) < 2 { continue } + if len(row) < 2 { + continue + } tempMap := make(map[string]int) for j, cell := range row { clean := s.cleanHeader(cell) - if clean != "" { tempMap[clean] = j } + if clean != "" { + tempMap[clean] = j + } } emailIdx := s.findBestMatch(tempMap, fieldMapping["email"]) nameIdx := s.findBestMatch(tempMap, fieldMapping["name"]) @@ -114,7 +120,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r for j, cell := range row { c := s.cleanHeader(cell) if strings.Contains(c, "mail") || strings.Contains(c, "계정") || strings.Contains(c, "id") || strings.Contains(c, "사번") { - emailIdx = j; break + emailIdx = j + break } } } @@ -124,13 +131,17 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r for key, aliases := range fieldMapping { actualMap[key] = s.findBestMatch(tempMap, aliases) } - if actualMap["email"] == -1 { actualMap["email"] = emailIdx } + if actualMap["email"] == -1 { + actualMap["email"] = emailIdx + } found = true slog.Info("Found header row", "sheet", sheetIdx, "row", i) break } } - if found { break } + if found { + break + } } if !found { @@ -173,19 +184,25 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r } for rowIdx, record := range dataRows { - if len(record) == 0 { continue } + if len(record) == 0 { + continue + } email := s.getVal(record, actualMap["email"]) name := s.getVal(record, actualMap["name"]) - if email == "" || name == "" { continue } + if email == "" || name == "" { + continue + } position := s.getVal(record, actualMap["position"]) jobTitle := s.getVal(record, actualMap["jobtitle"]) phone := s.normalizePhone(s.getVal(record, actualMap["phone"])) companyName := s.getVal(record, actualMap["company"]) - if companyName == "" { companyName = "Main" } + if companyName == "" { + companyName = "Main" + } companySlug := s.generateCompanySlug(companyName) - + companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err)) @@ -196,8 +213,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r var orgParts []string for _, idx := range hierarchyIdx { val := s.getVal(record, idx) - if val != "" && val != "-" { - orgParts = append(orgParts, val) + if val != "" && val != "-" { + orgParts = append(orgParts, val) } } orgPath := strings.Join(orgParts, " > ") @@ -217,9 +234,9 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r grade := "member" if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) { grade = strings.TrimSpace(record[idx]) - isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" || - strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") || - strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장") + isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" || + strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") || + strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장") } kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email) @@ -231,7 +248,7 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r brokerUser := &domain.BrokerUser{ Email: email, Name: name, PhoneNumber: phone, Attributes: map[string]interface{}{ - "affiliationType": "AFFILIATE", "companyCode": companySlug, + "affiliationType": "AFFILIATE", "companyCode": companySlug, "department": orgPath, "grade": grade, "position": position, "tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite }, @@ -244,7 +261,7 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r result.UserCreated++ } else { traits := map[string]interface{}{ - "name": name, "companyCode": companySlug, "department": orgPath, + "name": name, "companyCode": companySlug, "department": orgPath, "grade": grade, "position": position, "affiliationType": "AFFILIATE", "tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite } @@ -257,8 +274,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r err = s.userRepo.Update(ctx, &domain.User{ ID: kratosID, Email: email, Name: name, Phone: phone, Position: position, - JobTitle: jobTitle, Department: orgPath, - TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity + JobTitle: jobTitle, Department: orgPath, + TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity CompanyCode: companySlug, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), Role: domain.RoleUser, }) if err != nil { @@ -269,31 +286,31 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r if s.ketoOutboxRepo != nil { // 1. [Redundant Assignment] Always assign to the Legal Company Tenant _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: companyTenantID, - Relation: "members", - Subject: "User:" + kratosID, + Namespace: "Tenant", + Object: companyTenantID, + Relation: "members", + Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) // 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists) if leafID != companyTenantID { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: leafID, - Relation: "members", - Subject: "User:" + kratosID, + Namespace: "Tenant", + Object: leafID, + Relation: "members", + Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) } - + // 3. Assign ownership if leader if isOwner { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: leafID, - Relation: "owners", - Subject: "User:" + kratosID, + Namespace: "Tenant", + Object: leafID, + Relation: "owners", + Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) } @@ -315,26 +332,32 @@ func (s *orgChartService) cleanHeader(val string) string { func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int { for _, alias := range aliases { ca := s.cleanHeader(alias) - if idx, ok := tempMap[ca]; ok { return idx } + if idx, ok := tempMap[ca]; ok { + return idx + } } for cleaned, idx := range tempMap { for _, alias := range aliases { ca := s.cleanHeader(alias) - if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) { return idx } + if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) { + return idx + } } } return -1 } func (s *orgChartService) getVal(record []string, idx int) string { - if idx == -1 || idx >= len(record) { return "" } + if idx == -1 || idx >= len(record) { + return "" + } return strings.TrimSpace(record[idx]) } func (s *orgChartService) normalizePhone(phone string) string { normalized := strings.ReplaceAll(phone, "-", "") normalized = strings.ReplaceAll(normalized, " ", "") - + re := regexp.MustCompile(`[^0-9+]`) normalized = re.ReplaceAllString(normalized, "") @@ -354,13 +377,15 @@ func (s *orgChartService) normalizePhone(phone string) string { } return "+82" + normalized } - + return normalized } func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) { data, err := io.ReadAll(r) - if err != nil { return nil, err } + if err != nil { + return nil, err + } reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")))) reader.LazyQuotes = true reader.FieldsPerRecord = -1 @@ -369,11 +394,15 @@ func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) { func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) { f, err := excelize.OpenReader(r) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer f.Close() var allRecords [][][]string for _, sheet := range f.GetSheetList() { - if rows, err := f.GetRows(sheet); err == nil { allRecords = append(allRecords, rows) } + if rows, err := f.GetRows(sheet); err == nil { + allRecords = append(allRecords, rows) + } } return allRecords, nil } @@ -381,18 +410,22 @@ func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) { func (s *orgChartService) generateCompanySlug(name string) string { n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, "")) slugs := map[string]string{ - "한맥": "hanmac", "삼안": "saman", "장헌": "jangheon", + "한맥": "hanmac", "삼안": "saman", "장헌": "jangheon", "ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla", } for k, v := range slugs { - if strings.Contains(n, k) || strings.Contains(n, v) { return v } + if strings.Contains(n, k) || strings.Contains(n, v) { + return v + } } return utils.GenerateSlug(name) } func isAlphaNumeric(s string) bool { for _, r := range s { - if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return false } + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } } return true } @@ -411,8 +444,10 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, } cacheKey := "company:" + slug - if id, ok := cache[cacheKey]; ok { return id, nil } - + if id, ok := cache[cacheKey]; ok { + return id, nil + } + tenant, _ := s.tenantRepo.FindBySlug(ctx, slug) if tenant == nil { tenant, _ = s.tenantRepo.FindByName(ctx, name) @@ -420,17 +455,23 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, if tenant == nil { tenant = &domain.Tenant{ID: uuid.NewString(), Name: name, Slug: slug, Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, ParentID: &rootID} - if err := s.tenantRepo.Create(ctx, tenant); err != nil { return "", err } + if err := s.tenantRepo.Create(ctx, tenant); err != nil { + return "", err + } if s.ketoOutboxRepo != nil { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate}) } res.TenantCreated++ } - + domainPart := "" - if parts := strings.Split(email, "@"); len(parts) == 2 { domainPart = parts[1] } - if domainPart != "" { _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) } - + if parts := strings.Split(email, "@"); len(parts) == 2 { + domainPart = parts[1] + } + if domainPart != "" { + _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) + } + cache[cacheKey] = tenant.ID return tenant.ID, nil } @@ -440,12 +481,19 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string currentPath := "" for i, part := range parts { part = strings.TrimSpace(part) - if part == "" || part == "-" { continue } - if currentPath == "" { currentPath = part } else { currentPath += "/" + part } - + if part == "" || part == "-" { + continue + } + if currentPath == "" { + currentPath = part + } else { + currentPath += "/" + part + } + cacheKey := rootTenantID + ":" + currentPath if id, ok := cache[cacheKey]; ok { - currentParentID = id; continue + currentParentID = id + continue } var existingID string @@ -454,7 +502,8 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID) isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID) if g.Name == part && (isTopMatch || isSubMatch) { - existingID = g.ID; break + existingID = g.ID + break } } } @@ -464,16 +513,16 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string groupSlug := fmt.Sprintf("ug-%s", existingID[:13]) if err := s.tenantRepo.Create(ctx, &domain.Tenant{ - ID: existingID, - Type: domain.TenantTypeUserGroup, - ParentID: ¤tParentID, - Name: part, - Slug: groupSlug, + ID: existingID, + Type: domain.TenantTypeUserGroup, + ParentID: ¤tParentID, + Name: part, + Slug: groupSlug, Status: domain.TenantStatusActive, }); err != nil { return "", err } - + var ugParentID *string if currentParentID != rootTenantID { pid := currentParentID @@ -481,10 +530,10 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string } if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{ - ID: existingID, - TenantID: rootTenantID, - ParentID: ugParentID, - Name: part, + ID: existingID, + TenantID: rootTenantID, + ParentID: ugParentID, + Name: part, UnitType: s.guessUnitType(i, len(parts)), }); err != nil { return "", err @@ -501,7 +550,11 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string } func (s *orgChartService) guessUnitType(index, total int) string { - if total == 1 { return "Team" } - if index == 0 { return "Division" } + if total == 1 { + return "Team" + } + if index == 0 { + return "Division" + } return "Team" } diff --git a/backend/internal/service/org_chart_service_test.go b/backend/internal/service/org_chart_service_test.go index 32b685c5..17f31790 100644 --- a/backend/internal/service/org_chart_service_test.go +++ b/backend/internal/service/org_chart_service_test.go @@ -1,12 +1,12 @@ package service import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" "bytes" "context" "testing" - "baron-sso-backend/internal/domain" - "baron-sso-backend/internal/repository" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/xuri/excelize/v2" @@ -241,9 +241,11 @@ func TestImportOrgChart_MessyHeader(t *testing.T) { func (m *mockKratosService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { return nil, nil } + func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { return nil, nil } + func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error { return nil } diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 8ef748ee..8ba6a0a0 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -104,9 +104,15 @@ func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([ adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID) idMap := make(map[string]bool) - for _, id := range memberIDs { idMap[id] = true } - for _, id := range ownerIDs { idMap[id] = true } - for _, id := range adminIDs { idMap[id] = true } + for _, id := range memberIDs { + idMap[id] = true + } + for _, id := range ownerIDs { + idMap[id] = true + } + for _, id := range adminIDs { + idMap[id] = true + } allIDs := make([]string, 0, len(idMap)) for id := range idMap { diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts index d0ba28d0..1910fa4c 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -6,8 +6,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" || process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true"; -const baseURL = - process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174"; /** * Read environment variables from file. diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index fdd27597..18272285 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -34,7 +34,10 @@ export const router = createBrowserRouter( { path: "clients/:id", element: }, { path: "clients/:id/consents", element: }, { path: "clients/:id/settings", element: }, - { path: "clients/:id/relationships", element: }, + { + path: "clients/:id/relationships", + element: , + }, { path: "audit-logs", element: }, { path: "profile", element: }, ], diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 728b80a3..0de25b12 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -465,10 +465,18 @@ function ClientConsentsPage() { {filteredRows.length === 0 && !isLoading && !error ? ( - +
-

{t("msg.dev.clients.consents.empty", "No consents found.")}

+

+ {t( + "msg.dev.clients.consents.empty", + "No consents found.", + )} +

diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index 64967498..23be2dfb 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -110,7 +110,8 @@ function ClientRelationsPage() { if (isSuperAdmin) return true; if (!relationData?.items || !myUserId) return false; return relationData.items.some( - (item) => item.subject === `User:${myUserId}` && item.relation === "admins" + (item) => + item.subject === `User:${myUserId}` && item.relation === "admins", ); }, [relationData?.items, myUserId, isSuperAdmin]); @@ -664,7 +665,9 @@ function ClientRelationsPage() { variant="ghost" size="sm" className="gap-2 text-destructive hover:text-destructive" - disabled={removeMutation.isPending || !canManageRelations} + disabled={ + removeMutation.isPending || !canManageRelations + } onClick={() => handleRemove(item.relation, item.subject) } diff --git a/devfront/tests/devfront-relationships.spec.ts b/devfront/tests/devfront-relationships.spec.ts index 5ddeee3b..f1414d2a 100644 --- a/devfront/tests/devfront-relationships.spec.ts +++ b/devfront/tests/devfront-relationships.spec.ts @@ -36,6 +36,14 @@ test.describe("DevFront relationships", () => { ], relations: { "client-rel": [ + { + relation: "admins", + subject: "User:playwright-user", + subjectType: "User", + subjectId: "playwright-user", + userName: "Playwright User", + userEmail: "playwright@example.com", + }, { relation: "config_editor", subject: "User:user-1", @@ -67,13 +75,16 @@ test.describe("DevFront relationships", () => { await page.getByLabel(/동의 조회/).check(); await page.getByRole("button", { name: /^추가$/ }).click(); - await expect(page.getByText("User:user-2")).toBeVisible(); - await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(3); + await expect( + page.locator("tr").filter({ hasText: "User:user-2" }).first(), + ).toBeVisible(); + await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(4); await page .locator("tr") .filter({ hasText: "User:user-2" }) .getByRole("button", { name: /Delete|삭제/i }) + .first() .click(); await expect diff --git a/devfront/tests/devfront-role-switch-report.spec.ts b/devfront/tests/devfront-role-switch-report.spec.ts index 7b0066a3..073aa617 100644 --- a/devfront/tests/devfront-role-switch-report.spec.ts +++ b/devfront/tests/devfront-role-switch-report.spec.ts @@ -32,7 +32,9 @@ test.describe("DevFront role report", () => { page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i), ).toBeVisible(); await expect( - page.getByText(/연동 앱|Connected Application/i), + page.getByRole("heading", { + name: /^연동 앱$|^Connected Application$/i, + }), ).toBeVisible(); await captureEvidence(page, testInfo, "role-user-empty-rps"); }); diff --git a/locales/en.toml b/locales/en.toml index 5fb0d6fe..41a88adf 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -497,6 +497,9 @@ rp_admin = "RP administrators can only access resources for the apps they manage tenant_admin = "Tenant administrator permissions are not configured correctly or have expired." title = "Access Denied: {{resource}}" user = "Regular users cannot access the developer console." +user.audit = "Viewing audit logs for this App (RP) is only available when granted 'RP Admin' or 'Audit View' relationships. If you need access, please request it from an administrator." +user.clients = "General user accounts can only use this feature if they have been granted operational or management relationships for the relevant RP (App). If you need access, please request it from an administrator." +user.consents = "Viewing consent history for this App (RP) is only available when granted 'RP Admin', 'Consent View', or 'Consent Revoke' relationships. If you need access, please request it from an administrator." [msg.dev.sidebar] notice = "Developer Console" diff --git a/locales/ko.toml b/locales/ko.toml index 55bcbd9f..ca1e67d1 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -899,6 +899,9 @@ rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니 tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다." title = "{{resource}} 접근 권한 없음" user = "일반 사용자는 관리자 화면에 접근할 수 없습니다." +user.audit = "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요." +user.clients = "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요." +user.consents = "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요." [msg.dev.sidebar] notice = "개발자 전용 콘솔입니다." diff --git a/locales/template.toml b/locales/template.toml index 93634bfe..2ca4f152 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -774,6 +774,9 @@ rp_admin = "" tenant_admin = "" title = "" user = "" +user.audit = "" +user.clients = "" +user.consents = "" [msg.dev.sidebar] notice = "" diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh index 17a89245..fe56cfc3 100755 --- a/scripts/run_adminfront_ci_tests.sh +++ b/scripts/run_adminfront_ci_tests.sh @@ -70,9 +70,11 @@ if [ "$provision_exit_code" -ne 0 ]; then fi set +e +port="$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")" +echo "==> adminfront using PORT=$port" ( cd adminfront - npm test + PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" npm test ) 2>&1 | tee reports/adminfront-test.log test_exit_code=${PIPESTATUS[0]} set -e