1
0
forked from baron/baron-sso

dev 브런치 반영 code-check 오류 수정

This commit is contained in:
2026-04-20 16:34:04 +09:00
parent 1f464b60a4
commit 141c8e0ab5
25 changed files with 303 additions and 165 deletions

View File

@@ -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,
},
});

View File

@@ -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);
}}
>
<div className="flex items-center flex-1 min-w-0">
{/* Indent & Expander */}
<div style={{ width: `${level * 1.2}rem` }} className="shrink-0" />
<div className="w-5 h-5 flex items-center justify-center mr-1">
{hasChildren ? (
<button
type="button"
className="hover:bg-primary/20 rounded p-0.5"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
<span className="rounded p-0.5">
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
</span>
) : (
level > 0 && <div className="w-1 h-1 rounded-full bg-border" />
)}
@@ -457,7 +453,7 @@ function TenantUserGroupsTab() {
{selectedNode.slug}
</Badge>
</div>
<CardDescription className="flex items-center gap-2 mt-0.5">
<div className="text-sm text-muted-foreground flex items-center gap-2 mt-0.5">
<span className="flex items-center gap-1">
<Users size={12} /> {selectedNode.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
@@ -469,7 +465,7 @@ function TenantUserGroupsTab() {
selectedNode.type,
)}
</Badge>
</CardDescription>
</div>
</div>
</div>

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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
)
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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,
})
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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"}},

View File

@@ -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
}

View File

@@ -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: &currentParentID,
Name: part,
Slug: groupSlug,
ID: existingID,
Type: domain.TenantTypeUserGroup,
ParentID: &currentParentID,
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"
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -34,7 +34,10 @@ export const router = createBrowserRouter(
{ path: "clients/:id", element: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
{ path: "clients/:id/relationships", element: <ClientRelationsPage /> },
{
path: "clients/:id/relationships",
element: <ClientRelationsPage />,
},
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "profile", element: <ProfilePage /> },
],

View File

@@ -465,10 +465,18 @@ function ClientConsentsPage() {
<TableBody>
{filteredRows.length === 0 && !isLoading && !error ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
<TableCell
colSpan={7}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p>{t("msg.dev.clients.consents.empty", "No consents found.")}</p>
<p>
{t(
"msg.dev.clients.consents.empty",
"No consents found.",
)}
</p>
</div>
</TableCell>
</TableRow>

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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");
});

View File

@@ -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"

View File

@@ -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 = "개발자 전용 콘솔입니다."

View File

@@ -774,6 +774,9 @@ rp_admin = ""
tenant_admin = ""
title = ""
user = ""
user.audit = ""
user.clients = ""
user.consents = ""
[msg.dev.sidebar]
notice = ""

View File

@@ -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