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 const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined; : 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. * 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. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* 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 */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "retain-on-failure", trace: "retain-on-failure",
@@ -60,12 +64,14 @@ export default defineConfig({
], ],
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: process.env.BASE_URL
command: process.env.CI ? undefined
? "npm run build && npm run preview -- --port 5173" : {
: "npm run dev", command: process.env.CI
url: "http://localhost:5173", ? `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`
reuseExistingServer: !process.env.CI, : `npm run dev -- --host 127.0.0.1 --port ${port}`,
timeout: 120 * 1000, url: defaultBaseUrl,
}, reuseExistingServer,
timeout: 120 * 1000,
},
}); });

View File

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

View File

@@ -174,7 +174,7 @@ test.describe("Bulk Actions and Tree Search", () => {
await searchInput.fill("Eng"); await searchInput.fill("Eng");
const engNode = page const engNode = page
.locator("button") .locator('button, [role="button"]')
.filter({ hasText: "Engineering" }) .filter({ hasText: "Engineering" })
.first(); .first();
await expect(engNode).toBeVisible(); await expect(engNode).toBeVisible();

View File

@@ -1,18 +1,17 @@
package main package main
import ( import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"context" "context"
"fmt" "fmt"
"log" "log"
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
) )
func main() { func main() {
kratosAdmin := service.NewKratosAdminService() kratosAdmin := service.NewKratosAdminService()
ctx := context.Background() ctx := context.Background()
identities, err := kratosAdmin.ListIdentities(ctx) identities, err := kratosAdmin.ListIdentities(ctx)
if err != nil { if err != nil {
log.Fatalf("Failed to list identities: %v", err) log.Fatalf("Failed to list identities: %v", err)
@@ -22,7 +21,7 @@ func main() {
for _, id := range identities { for _, id := range identities {
traits := id.Traits traits := id.Traits
changed := false changed := false
if r, ok := traits["role"].(string); ok { if r, ok := traits["role"].(string); ok {
norm := domain.NormalizeRole(r) norm := domain.NormalizeRole(r)
if norm != r && norm == domain.RoleUser { if norm != r && norm == domain.RoleUser {

View File

@@ -42,6 +42,6 @@ func migrateSchemas(db *gorm.DB) error {
&domain.ClientConsent{}, &domain.ClientConsent{},
&domain.KetoOutbox{}, &domain.KetoOutbox{},
&domain.SharedLink{}, &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 profile.ManageableTenants = manageable
} }
} }
joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID) joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID)
if err == nil { if err == nil {
profile.JoinedTenants = joined profile.JoinedTenants = joined

View File

@@ -1519,7 +1519,7 @@ func TestRevokeHeadlessJWKSCache_DeletesCachedState(t *testing.T) {
assert.Nil(t, stored) assert.Nil(t, stored)
} }
func TestListAuditLogs_TenantMemberForbidden(t *testing.T) { func TestListAuditLogs_TenantMemberWithoutAuditPermissionReturnsEmpty(t *testing.T) {
h := &DevHandler{ h := &DevHandler{
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"}, Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
AuditRepo: &mockAuditRepo{}, AuditRepo: &mockAuditRepo{},
@@ -1540,7 +1540,11 @@ func TestListAuditLogs_TenantMemberForbidden(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs", nil)
resp, _ := app.Test(req, -1) 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) { func TestListAuditLogs_RPAdminScope(t *testing.T) {
@@ -1915,6 +1919,20 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
} }
func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(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 := new(devMockKratosAdmin)
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
{ {
@@ -1938,6 +1956,10 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
}, nil) }, nil)
h := &DevHandler{ h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
KratosAdmin: mockKratos, KratosAdmin: mockKratos,
} }
@@ -1951,21 +1973,25 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
ManageableTenants: []domain.Tenant{ ManageableTenants: []domain.Tenant{
{ID: "tenant-1", Slug: "tenant-one"}, {ID: "tenant-1", Slug: "tenant-one"},
}, },
Metadata: map[string]any{
"managed_client_ids": []any{"client-1"},
},
}) })
return c.Next() return c.Next()
}) })
app.Get("/api/v1/dev/users", h.SearchUsers) 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) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
var result devUserListResponse var result devUserListResponse
_ = json.NewDecoder(resp.Body).Decode(&result) _ = json.NewDecoder(resp.Body).Decode(&result)
assert.Len(t, result.Items, 1) if assert.Len(t, result.Items, 1) {
assert.Equal(t, "user-1", result.Items[0].ID) assert.Equal(t, "user-1", result.Items[0].ID)
assert.Equal(t, "Alice Kim", result.Items[0].Name) assert.Equal(t, "Alice Kim", result.Items[0].Name)
assert.Equal(t, "alice@example.com", result.Items[0].Email) assert.Equal(t, "alice@example.com", result.Items[0].Email)
}
mockKratos.AssertExpectations(t) mockKratos.AssertExpectations(t)
} }

View File

@@ -868,7 +868,6 @@ func normalizeTenantType(value string) string {
} }
} }
func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error { func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error {
tenantID := c.Params("id") tenantID := c.Params("id")
var req struct { var req struct {
@@ -932,7 +931,9 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
curr := id curr := id
for { for {
p, exists := parentMap[curr] p, exists := parentMap[curr]
if !exists || p == "" { break } if !exists || p == "" {
break
}
curr = p curr = p
} }
return curr return curr
@@ -967,10 +968,14 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
var usersByID []domain.User var usersByID []domain.User
h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID) h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID)
for _, u := range 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 seen[u.ID] = true
cc := u.CompanyCode 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{ publicUsers = append(publicUsers, publicUserSummary{
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status, 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 var usersBySlug []domain.User
h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug) h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug)
for _, u := range 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 seen[u.ID] = true
cc := u.CompanyCode 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{ publicUsers = append(publicUsers, publicUserSummary{
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status, 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{ return c.JSON(fiber.Map{
"tenants": tenantSummaries, "tenants": tenantSummaries,
"users": publicUsers, "users": publicUsers,
"sharedWith": link.Name, "sharedWith": link.Name,
}) })
} }

View File

@@ -204,24 +204,24 @@ func TestTenantHandler_ListTenants(t *testing.T) {
} }
app.Use(func(c *fiber.Ctx) error { app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin", Role: "super_admin",
}) })
return c.Next() return c.Next()
}) })
app.Get("/tenants", h.ListTenants) app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{ tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant A", Slug: "slug-a"}, {ID: "t1", Name: "Tenant A", Slug: "slug-a"},
{ID: "t2", Name: "Tenant B", Slug: "slug-b"}, {ID: "t2", Name: "Tenant B", Slug: "slug-b"},
} }
// Mocking for the new allTenants check in ListTenants // 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() mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
mockUserRepo.On("CountByCompanyCodes", mock.Anything, mock.Anything). 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). 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) req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)
@@ -263,6 +263,7 @@ func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []s
args := m.Called(ctx, tenantIDs) args := m.Called(ctx, tenantIDs)
return args.Error(0) return args.Error(0)
} }
func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID) args := m.Called(ctx, userID)
if args.Get(0) != nil { if args.Get(0) != nil {

View File

@@ -133,7 +133,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
parentMap[t.ID] = *t.ParentID parentMap[t.ID] = *t.ParentID
} }
} }
// Function to find the root of any given tenant // Function to find the root of any given tenant
findRoot := func(id string) string { findRoot := func(id string) string {
curr := id curr := id
@@ -331,17 +331,17 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
} }
var req struct { var req struct {
Email string `json:"email"` Email string `json:"email"`
LoginID string `json:"loginId"` LoginID string `json:"loginId"`
Password string `json:"password"` Password string `json:"password"`
Name string `json:"name"` Name string `json:"name"`
Phone string `json:"phone"` Phone string `json:"phone"`
Role string `json:"role"` Role string `json:"role"`
CompanyCode string `json:"companyCode"` CompanyCode string `json:"companyCode"`
Department string `json:"department"` Department string `json:"department"`
Position string `json:"position"` Position string `json:"position"`
JobTitle string `json:"jobTitle"` JobTitle string `json:"jobTitle"`
Metadata map[string]any `json:"metadata"` Metadata map[string]any `json:"metadata"`
} }
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body") 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 traits["tenant_id"] = tenant.ID
} }
} }
// Add to existingCodes if not present // Add to existingCodes if not present
found := false found := false
for _, existing := range existingCodes { 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. // In GORM v2, true upsert requires Create() with OnConflict on the primary key.
return tx.Clauses(clause.OnConflict{ return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}}, 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) 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) { func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
args := m.Called(ctx, identityID, traits, state) args := m.Called(ctx, identityID, traits, state)
if args.Get(0) == nil { 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) { func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
return nil, nil return nil, nil
} }
func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
return nil, nil return nil, nil
} }
func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error { func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error {
return nil return nil
} }

View File

@@ -19,8 +19,10 @@ import (
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
) )
var whitespaceRegex = regexp.MustCompile(`\s+`) var (
var nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`) whitespaceRegex = regexp.MustCompile(`\s+`)
nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
)
type ProgressData struct { type ProgressData struct {
Current int `json:"current"` Current int `json:"current"`
@@ -30,12 +32,12 @@ type ProgressData struct {
var ImportProgressCache sync.Map var ImportProgressCache sync.Map
type ImportResult struct { type ImportResult struct {
TotalRows int `json:"totalRows"` TotalRows int `json:"totalRows"`
Processed int `json:"processed"` Processed int `json:"processed"`
UserCreated int `json:"userCreated"` UserCreated int `json:"userCreated"`
UserUpdated int `json:"userUpdated"` UserUpdated int `json:"userUpdated"`
TenantCreated int `json:"tenantCreated"` TenantCreated int `json:"tenantCreated"`
Errors []string `json:"errors"` Errors []string `json:"errors"`
} }
type OrgChartService interface { type OrgChartService interface {
@@ -86,13 +88,13 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
} }
fieldMapping := map[string][]string{ fieldMapping := map[string][]string{
"email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"}, "email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"},
"name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"}, "name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"},
"position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"}, "position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"},
"jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"}, "jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"},
"phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"}, "phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"},
"company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"}, "company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"},
"is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"}, "is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"},
} }
var dataRows [][]string var dataRows [][]string
@@ -102,11 +104,15 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
for sheetIdx, records := range allSheetsRecords { for sheetIdx, records := range allSheetsRecords {
for i, row := range records { for i, row := range records {
if len(row) < 2 { continue } if len(row) < 2 {
continue
}
tempMap := make(map[string]int) tempMap := make(map[string]int)
for j, cell := range row { for j, cell := range row {
clean := s.cleanHeader(cell) clean := s.cleanHeader(cell)
if clean != "" { tempMap[clean] = j } if clean != "" {
tempMap[clean] = j
}
} }
emailIdx := s.findBestMatch(tempMap, fieldMapping["email"]) emailIdx := s.findBestMatch(tempMap, fieldMapping["email"])
nameIdx := s.findBestMatch(tempMap, fieldMapping["name"]) 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 { for j, cell := range row {
c := s.cleanHeader(cell) c := s.cleanHeader(cell)
if strings.Contains(c, "mail") || strings.Contains(c, "계정") || strings.Contains(c, "id") || strings.Contains(c, "사번") { 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 { for key, aliases := range fieldMapping {
actualMap[key] = s.findBestMatch(tempMap, aliases) actualMap[key] = s.findBestMatch(tempMap, aliases)
} }
if actualMap["email"] == -1 { actualMap["email"] = emailIdx } if actualMap["email"] == -1 {
actualMap["email"] = emailIdx
}
found = true found = true
slog.Info("Found header row", "sheet", sheetIdx, "row", i) slog.Info("Found header row", "sheet", sheetIdx, "row", i)
break break
} }
} }
if found { break } if found {
break
}
} }
if !found { if !found {
@@ -173,19 +184,25 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
} }
for rowIdx, record := range dataRows { for rowIdx, record := range dataRows {
if len(record) == 0 { continue } if len(record) == 0 {
continue
}
email := s.getVal(record, actualMap["email"]) email := s.getVal(record, actualMap["email"])
name := s.getVal(record, actualMap["name"]) name := s.getVal(record, actualMap["name"])
if email == "" || name == "" { continue } if email == "" || name == "" {
continue
}
position := s.getVal(record, actualMap["position"]) position := s.getVal(record, actualMap["position"])
jobTitle := s.getVal(record, actualMap["jobtitle"]) jobTitle := s.getVal(record, actualMap["jobtitle"])
phone := s.normalizePhone(s.getVal(record, actualMap["phone"])) phone := s.normalizePhone(s.getVal(record, actualMap["phone"]))
companyName := s.getVal(record, actualMap["company"]) companyName := s.getVal(record, actualMap["company"])
if companyName == "" { companyName = "Main" } if companyName == "" {
companyName = "Main"
}
companySlug := s.generateCompanySlug(companyName) companySlug := s.generateCompanySlug(companyName)
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result) companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result)
if err != nil { if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err)) 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 var orgParts []string
for _, idx := range hierarchyIdx { for _, idx := range hierarchyIdx {
val := s.getVal(record, idx) val := s.getVal(record, idx)
if val != "" && val != "-" { if val != "" && val != "-" {
orgParts = append(orgParts, val) orgParts = append(orgParts, val)
} }
} }
orgPath := strings.Join(orgParts, " > ") orgPath := strings.Join(orgParts, " > ")
@@ -217,9 +234,9 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
grade := "member" grade := "member"
if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) { if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) {
grade = strings.TrimSpace(record[idx]) grade = strings.TrimSpace(record[idx])
isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" || isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" ||
strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") || strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") ||
strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장") strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장")
} }
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email) kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
@@ -231,7 +248,7 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
brokerUser := &domain.BrokerUser{ brokerUser := &domain.BrokerUser{
Email: email, Name: name, PhoneNumber: phone, Email: email, Name: name, PhoneNumber: phone,
Attributes: map[string]interface{}{ Attributes: map[string]interface{}{
"affiliationType": "AFFILIATE", "companyCode": companySlug, "affiliationType": "AFFILIATE", "companyCode": companySlug,
"department": orgPath, "grade": grade, "position": position, "department": orgPath, "grade": grade, "position": position,
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite "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++ result.UserCreated++
} else { } else {
traits := map[string]interface{}{ traits := map[string]interface{}{
"name": name, "companyCode": companySlug, "department": orgPath, "name": name, "companyCode": companySlug, "department": orgPath,
"grade": grade, "position": position, "affiliationType": "AFFILIATE", "grade": grade, "position": position, "affiliationType": "AFFILIATE",
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite "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{ err = s.userRepo.Update(ctx, &domain.User{
ID: kratosID, Email: email, Name: name, Phone: phone, Position: position, ID: kratosID, Email: email, Name: name, Phone: phone, Position: position,
JobTitle: jobTitle, Department: orgPath, JobTitle: jobTitle, Department: orgPath,
TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity 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, CompanyCode: companySlug, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), Role: domain.RoleUser,
}) })
if err != nil { if err != nil {
@@ -269,31 +286,31 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
if s.ketoOutboxRepo != nil { if s.ketoOutboxRepo != nil {
// 1. [Redundant Assignment] Always assign to the Legal Company Tenant // 1. [Redundant Assignment] Always assign to the Legal Company Tenant
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",
Object: companyTenantID, Object: companyTenantID,
Relation: "members", Relation: "members",
Subject: "User:" + kratosID, Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
}) })
// 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists) // 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists)
if leafID != companyTenantID { if leafID != companyTenantID {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",
Object: leafID, Object: leafID,
Relation: "members", Relation: "members",
Subject: "User:" + kratosID, Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
}) })
} }
// 3. Assign ownership if leader // 3. Assign ownership if leader
if isOwner { if isOwner {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",
Object: leafID, Object: leafID,
Relation: "owners", Relation: "owners",
Subject: "User:" + kratosID, Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate, 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 { func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int {
for _, alias := range aliases { for _, alias := range aliases {
ca := s.cleanHeader(alias) 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 cleaned, idx := range tempMap {
for _, alias := range aliases { for _, alias := range aliases {
ca := s.cleanHeader(alias) 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 return -1
} }
func (s *orgChartService) getVal(record []string, idx int) string { 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]) return strings.TrimSpace(record[idx])
} }
func (s *orgChartService) normalizePhone(phone string) string { func (s *orgChartService) normalizePhone(phone string) string {
normalized := strings.ReplaceAll(phone, "-", "") normalized := strings.ReplaceAll(phone, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "") normalized = strings.ReplaceAll(normalized, " ", "")
re := regexp.MustCompile(`[^0-9+]`) re := regexp.MustCompile(`[^0-9+]`)
normalized = re.ReplaceAllString(normalized, "") normalized = re.ReplaceAllString(normalized, "")
@@ -354,13 +377,15 @@ func (s *orgChartService) normalizePhone(phone string) string {
} }
return "+82" + normalized return "+82" + normalized
} }
return normalized return normalized
} }
func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) { func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
data, err := io.ReadAll(r) 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 := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))))
reader.LazyQuotes = true reader.LazyQuotes = true
reader.FieldsPerRecord = -1 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) { func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
f, err := excelize.OpenReader(r) f, err := excelize.OpenReader(r)
if err != nil { return nil, err } if err != nil {
return nil, err
}
defer f.Close() defer f.Close()
var allRecords [][][]string var allRecords [][][]string
for _, sheet := range f.GetSheetList() { 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 return allRecords, nil
} }
@@ -381,18 +410,22 @@ func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
func (s *orgChartService) generateCompanySlug(name string) string { func (s *orgChartService) generateCompanySlug(name string) string {
n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, "")) n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, ""))
slugs := map[string]string{ slugs := map[string]string{
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon", "한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
"ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla", "ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla",
} }
for k, v := range slugs { 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) return utils.GenerateSlug(name)
} }
func isAlphaNumeric(s string) bool { func isAlphaNumeric(s string) bool {
for _, r := range s { 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 return true
} }
@@ -411,8 +444,10 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name,
} }
cacheKey := "company:" + slug 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) tenant, _ := s.tenantRepo.FindBySlug(ctx, slug)
if tenant == nil { if tenant == nil {
tenant, _ = s.tenantRepo.FindByName(ctx, name) tenant, _ = s.tenantRepo.FindByName(ctx, name)
@@ -420,17 +455,23 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name,
if tenant == nil { if tenant == nil {
tenant = &domain.Tenant{ID: uuid.NewString(), Name: name, Slug: slug, Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, ParentID: &rootID} 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 { if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate}) _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate})
} }
res.TenantCreated++ res.TenantCreated++
} }
domainPart := "" domainPart := ""
if parts := strings.Split(email, "@"); len(parts) == 2 { domainPart = parts[1] } if parts := strings.Split(email, "@"); len(parts) == 2 {
if domainPart != "" { _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) } domainPart = parts[1]
}
if domainPart != "" {
_ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true)
}
cache[cacheKey] = tenant.ID cache[cacheKey] = tenant.ID
return tenant.ID, nil return tenant.ID, nil
} }
@@ -440,12 +481,19 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
currentPath := "" currentPath := ""
for i, part := range parts { for i, part := range parts {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
if part == "" || part == "-" { continue } if part == "" || part == "-" {
if currentPath == "" { currentPath = part } else { currentPath += "/" + part } continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath += "/" + part
}
cacheKey := rootTenantID + ":" + currentPath cacheKey := rootTenantID + ":" + currentPath
if id, ok := cache[cacheKey]; ok { if id, ok := cache[cacheKey]; ok {
currentParentID = id; continue currentParentID = id
continue
} }
var existingID string var existingID string
@@ -454,7 +502,8 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID) isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID)
isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID) isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID)
if g.Name == part && (isTopMatch || isSubMatch) { 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]) groupSlug := fmt.Sprintf("ug-%s", existingID[:13])
if err := s.tenantRepo.Create(ctx, &domain.Tenant{ if err := s.tenantRepo.Create(ctx, &domain.Tenant{
ID: existingID, ID: existingID,
Type: domain.TenantTypeUserGroup, Type: domain.TenantTypeUserGroup,
ParentID: &currentParentID, ParentID: &currentParentID,
Name: part, Name: part,
Slug: groupSlug, Slug: groupSlug,
Status: domain.TenantStatusActive, Status: domain.TenantStatusActive,
}); err != nil { }); err != nil {
return "", err return "", err
} }
var ugParentID *string var ugParentID *string
if currentParentID != rootTenantID { if currentParentID != rootTenantID {
pid := currentParentID pid := currentParentID
@@ -481,10 +530,10 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
} }
if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{ if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{
ID: existingID, ID: existingID,
TenantID: rootTenantID, TenantID: rootTenantID,
ParentID: ugParentID, ParentID: ugParentID,
Name: part, Name: part,
UnitType: s.guessUnitType(i, len(parts)), UnitType: s.guessUnitType(i, len(parts)),
}); err != nil { }); err != nil {
return "", err return "", err
@@ -501,7 +550,11 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
} }
func (s *orgChartService) guessUnitType(index, total int) string { func (s *orgChartService) guessUnitType(index, total int) string {
if total == 1 { return "Team" } if total == 1 {
if index == 0 { return "Division" } return "Team"
}
if index == 0 {
return "Division"
}
return "Team" return "Team"
} }

View File

@@ -1,12 +1,12 @@
package service package service
import ( import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"bytes" "bytes"
"context" "context"
"testing" "testing"
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/xuri/excelize/v2" "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) { func (m *mockKratosService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
return nil, nil return nil, nil
} }
func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
return nil, nil return nil, nil
} }
func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error { func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error {
return nil 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) adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
idMap := make(map[string]bool) idMap := make(map[string]bool)
for _, id := range memberIDs { idMap[id] = true } for _, id := range memberIDs {
for _, id := range ownerIDs { idMap[id] = true } idMap[id] = true
for _, id := range adminIDs { idMap[id] = true } }
for _, id := range ownerIDs {
idMap[id] = true
}
for _, id := range adminIDs {
idMap[id] = true
}
allIDs := make([]string, 0, len(idMap)) allIDs := make([]string, 0, len(idMap))
for id := range idMap { for id := range idMap {

View File

@@ -6,8 +6,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
const skipWebServer = const skipWebServer =
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" || process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true"; process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
const baseURL = const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174";
process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174";
/** /**
* Read environment variables from file. * Read environment variables from file.

View File

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

View File

@@ -465,10 +465,18 @@ function ClientConsentsPage() {
<TableBody> <TableBody>
{filteredRows.length === 0 && !isLoading && !error ? ( {filteredRows.length === 0 && !isLoading && !error ? (
<TableRow> <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"> <div className="flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" /> <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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -110,7 +110,8 @@ function ClientRelationsPage() {
if (isSuperAdmin) return true; if (isSuperAdmin) return true;
if (!relationData?.items || !myUserId) return false; if (!relationData?.items || !myUserId) return false;
return relationData.items.some( return relationData.items.some(
(item) => item.subject === `User:${myUserId}` && item.relation === "admins" (item) =>
item.subject === `User:${myUserId}` && item.relation === "admins",
); );
}, [relationData?.items, myUserId, isSuperAdmin]); }, [relationData?.items, myUserId, isSuperAdmin]);
@@ -664,7 +665,9 @@ function ClientRelationsPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
className="gap-2 text-destructive hover:text-destructive" className="gap-2 text-destructive hover:text-destructive"
disabled={removeMutation.isPending || !canManageRelations} disabled={
removeMutation.isPending || !canManageRelations
}
onClick={() => onClick={() =>
handleRemove(item.relation, item.subject) handleRemove(item.relation, item.subject)
} }

View File

@@ -36,6 +36,14 @@ test.describe("DevFront relationships", () => {
], ],
relations: { relations: {
"client-rel": [ "client-rel": [
{
relation: "admins",
subject: "User:playwright-user",
subjectType: "User",
subjectId: "playwright-user",
userName: "Playwright User",
userEmail: "playwright@example.com",
},
{ {
relation: "config_editor", relation: "config_editor",
subject: "User:user-1", subject: "User:user-1",
@@ -67,13 +75,16 @@ test.describe("DevFront relationships", () => {
await page.getByLabel(/동의 조회/).check(); await page.getByLabel(/동의 조회/).check();
await page.getByRole("button", { name: /^추가$/ }).click(); await page.getByRole("button", { name: /^추가$/ }).click();
await expect(page.getByText("User:user-2")).toBeVisible(); await expect(
await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(3); page.locator("tr").filter({ hasText: "User:user-2" }).first(),
).toBeVisible();
await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(4);
await page await page
.locator("tr") .locator("tr")
.filter({ hasText: "User:user-2" }) .filter({ hasText: "User:user-2" })
.getByRole("button", { name: /Delete|삭제/i }) .getByRole("button", { name: /Delete|삭제/i })
.first()
.click(); .click();
await expect await expect

View File

@@ -32,7 +32,9 @@ test.describe("DevFront role report", () => {
page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i), page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
page.getByText(/연동 앱|Connected Application/i), page.getByRole("heading", {
name: /^연동 앱$|^Connected Application$/i,
}),
).toBeVisible(); ).toBeVisible();
await captureEvidence(page, testInfo, "role-user-empty-rps"); 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." tenant_admin = "Tenant administrator permissions are not configured correctly or have expired."
title = "Access Denied: {{resource}}" title = "Access Denied: {{resource}}"
user = "Regular users cannot access the developer console." 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] [msg.dev.sidebar]
notice = "Developer Console" notice = "Developer Console"

View File

@@ -899,6 +899,9 @@ rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니
tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다." tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다."
title = "{{resource}} 접근 권한 없음" title = "{{resource}} 접근 권한 없음"
user = "일반 사용자는 관리자 화면에 접근할 수 없습니다." user = "일반 사용자는 관리자 화면에 접근할 수 없습니다."
user.audit = "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
user.clients = "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
user.consents = "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요."
[msg.dev.sidebar] [msg.dev.sidebar]
notice = "개발자 전용 콘솔입니다." notice = "개발자 전용 콘솔입니다."

View File

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

View File

@@ -70,9 +70,11 @@ if [ "$provision_exit_code" -ne 0 ]; then
fi fi
set +e 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 cd adminfront
npm test PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" npm test
) 2>&1 | tee reports/adminfront-test.log ) 2>&1 | tee reports/adminfront-test.log
test_exit_code=${PIPESTATUS[0]} test_exit_code=${PIPESTATUS[0]}
set -e set -e