+
diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml
index 4e453160..22645e1d 100644
--- a/adminfront/src/locales/en.toml
+++ b/adminfront/src/locales/en.toml
@@ -202,6 +202,11 @@ subtitle = "Subtitle"
[msg.admin.tenants.import_preview]
description = "Rows without tenant_id are compared with existing tenant candidates, then imported as new tenants or updates."
+[msg.admin.tenants.parent]
+local_picker_description = "Select the tenant to use as the parent from the tenant list."
+local_picker_empty = "No selectable tenants are available."
+picker_description = "Select a tenant in org-chart to apply it as the parent tenant."
+
[msg.admin.tenants.admins]
add_success = "Add Success"
empty = "Empty"
@@ -217,6 +222,7 @@ remove_success = "Owner permission revoked."
subtitle = "List of owners with top-level permissions for this tenant."
[msg.admin.tenants.create]
+pick_parent_first = "Select the parent tenant first."
subtitle = "Subtitle"
[msg.admin.tenants.create.form]
@@ -908,9 +914,14 @@ title = "Domain conflict"
candidates = "Candidates"
confirm = "Run import"
create_new_reset = "Create new (reset ID/slug)"
+csv_parents = "CSV Parents"
external_id = "External ID"
match = "Match"
no_candidates = "No candidates"
+parent = "Parent"
+parent_companies = "Parent Companies"
+parent_company_groups = "Parent Company Groups"
+parent_organizations = "Parent Organizations"
parent_unresolved = "Parent needs review"
slug_exists = "slug conflict"
title = "Confirm CSV import"
@@ -957,11 +968,20 @@ domains_label = "Allowed Domains (Comma separated)"
domains_placeholder = "example.com, example.kr"
name = "Tenant name"
parent = "Parent"
+pick_hanmac_parent = "Pick from Hanmac Family"
+pick_other_parent = "Pick another tenant"
+root_tenant = "Create as top-level tenant"
slug = "Slug"
slug_placeholder = "tenant-slug"
status = "Status"
type = "Type"
+[ui.admin.tenants.create.parent_context]
+general = "General child tenant"
+hanmac = "Hanmac Family child tenant"
+pick_required = "Parent tenant selection required"
+root = "Top-level tenant"
+
[ui.admin.tenants.create.memo]
title = "Title"
@@ -1023,11 +1043,17 @@ allowed_domains_help = "Users with these email domains will be automatically ass
approve_button = "Approve Tenant"
description = "Description"
name = "Tenant Name"
+org_unit_type = "Organization detail type"
slug = "Slug"
status = "Status"
subtitle = "Slug and status changes are applied immediately."
title = "Tenant Profile"
type = "Type"
+visibility = "Visibility"
+
+[ui.admin.tenants.parent]
+local_search_placeholder = "Search tenant name or slug"
+pick_tenant = "Pick tenant"
[ui.admin.tenants.registry]
title = "Tenant registry"
diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml
index 7704aff6..b3c9ab3c 100644
--- a/adminfront/src/locales/ko.toml
+++ b/adminfront/src/locales/ko.toml
@@ -203,6 +203,11 @@ subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다
[msg.admin.tenants.import_preview]
description = "tenant_id가 없는 행은 기존 테넌트 후보와 비교한 뒤 신규 생성 또는 기존 테넌트 갱신으로 처리합니다."
+[msg.admin.tenants.parent]
+local_picker_description = "테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다."
+local_picker_empty = "선택할 수 있는 테넌트가 없습니다."
+picker_description = "org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다."
+
[msg.admin.tenants.admins]
add_success = "관리자가 추가되었습니다."
empty = "등록된 관리자가 없습니다."
@@ -218,6 +223,7 @@ remove_success = "소유자 권한이 회수되었습니다."
subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다."
[msg.admin.tenants.create]
+pick_parent_first = "상위 테넌트를 먼저 선택하세요."
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
[msg.admin.tenants.create.form]
@@ -910,9 +916,14 @@ title = "도메인 충돌"
candidates = "후보"
confirm = "가져오기 실행"
create_new_reset = "신규 생성 (ID/slug 재설정)"
+csv_parents = "CSV 상위 테넌트"
external_id = "외부 ID"
match = "매칭"
no_candidates = "후보 없음"
+parent = "상위"
+parent_companies = "상위 회사"
+parent_company_groups = "상위 그룹사"
+parent_organizations = "상위 조직"
parent_unresolved = "부모 확인 필요"
slug_exists = "slug 충돌"
title = "CSV 가져오기 확인"
@@ -959,11 +970,20 @@ domains_label = "Allowed Domains (Comma separated)"
domains_placeholder = "example.com, example.kr"
name = "테넌트 이름"
parent = "상위 테넌트"
+pick_hanmac_parent = "한맥가족에서 선택"
+pick_other_parent = "다른 테넌트 선택"
+root_tenant = "최상위 테넌트로 생성"
slug = "Slug"
slug_placeholder = "tenant-slug"
status = "상태"
type = "유형"
+[ui.admin.tenants.create.parent_context]
+general = "일반 하위 테넌트"
+hanmac = "한맥가족 하위 테넌트"
+pick_required = "상위 테넌트 선택 필요"
+root = "최상위 테넌트"
+
[ui.admin.tenants.create.memo]
title = "정책 메모"
@@ -1025,11 +1045,17 @@ allowed_domains_help = "이 도메인을 가진 이메일로 가입한 사용자
approve_button = "테넌트 승인"
description = "설명"
name = "테넌트 이름"
+org_unit_type = "조직 세부타입"
slug = "슬러그 (Slug)"
status = "상태"
subtitle = "슬러그 및 상태 변경은 즉시 적용됩니다."
title = "테넌트 프로필"
type = "테넌트 유형"
+visibility = "공개 범위"
+
+[ui.admin.tenants.parent]
+local_search_placeholder = "테넌트 이름 또는 슬러그 검색"
+pick_tenant = "테넌트 선택"
[ui.admin.tenants.registry]
title = "Tenant registry"
diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml
index 0fa99c9c..e85b29ba 100644
--- a/adminfront/src/locales/template.toml
+++ b/adminfront/src/locales/template.toml
@@ -208,6 +208,11 @@ subtitle = ""
[msg.admin.tenants.import_preview]
description = ""
+[msg.admin.tenants.parent]
+local_picker_description = ""
+local_picker_empty = ""
+picker_description = ""
+
[msg.admin.tenants.admins]
add_success = ""
empty = ""
@@ -223,6 +228,7 @@ remove_success = ""
subtitle = ""
[msg.admin.tenants.create]
+pick_parent_first = ""
subtitle = ""
[msg.admin.tenants.create.form]
@@ -924,9 +930,14 @@ title = ""
candidates = ""
confirm = ""
create_new_reset = ""
+csv_parents = ""
external_id = ""
match = ""
no_candidates = ""
+parent = ""
+parent_companies = ""
+parent_company_groups = ""
+parent_organizations = ""
parent_unresolved = ""
slug_exists = ""
title = ""
@@ -973,11 +984,20 @@ domains_label = ""
domains_placeholder = ""
name = ""
parent = ""
+pick_hanmac_parent = ""
+pick_other_parent = ""
+root_tenant = ""
slug = ""
slug_placeholder = ""
status = ""
type = ""
+[ui.admin.tenants.create.parent_context]
+general = ""
+hanmac = ""
+pick_required = ""
+root = ""
+
[ui.admin.tenants.create.memo]
title = ""
@@ -1044,11 +1064,17 @@ allowed_domains_help = ""
approve_button = ""
description = ""
name = ""
+org_unit_type = ""
slug = ""
status = ""
subtitle = ""
title = ""
type = ""
+visibility = ""
+
+[ui.admin.tenants.parent]
+local_search_placeholder = ""
+pick_tenant = ""
[ui.admin.tenants.registry]
title = ""
diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts
index 6fac2209..126eda1c 100644
--- a/adminfront/tests/tenants.spec.ts
+++ b/adminfront/tests/tenants.spec.ts
@@ -110,9 +110,7 @@ test.describe("Tenants Management", () => {
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
- await page
- .getByRole("button", { name: "최상위 테넌트로 생성" })
- .click();
+ await page.getByRole("button", { name: "최상위 테넌트로 생성" }).click();
const nameInput = page.locator('input[name="name"]').first();
await nameInput.fill("New Tenant");
@@ -213,9 +211,7 @@ test.describe("Tenants Management", () => {
{
type: "orgfront:picker:confirm",
payload: {
- selections: [
- { type: "tenant", id: "family-1", name: "한맥가족" },
- ],
+ selections: [{ type: "tenant", id: "family-1", name: "한맥가족" }],
},
},
window.location.origin,
@@ -260,7 +256,12 @@ test.describe("Tenants Management", () => {
const headers = { "Access-Control-Allow-Origin": "*" };
if (method === "GET") {
return route.fulfill({
- json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
+ json: {
+ items: tenants,
+ total: tenants.length,
+ limit: 1000,
+ offset: 0,
+ },
headers,
});
}
@@ -286,9 +287,7 @@ test.describe("Tenants Management", () => {
{
type: "orgfront:picker:confirm",
payload: {
- selections: [
- { type: "tenant", id: "family-1", name: "한맥가족" },
- ],
+ selections: [{ type: "tenant", id: "family-1", name: "한맥가족" }],
},
},
window.location.origin,
@@ -309,8 +308,8 @@ test.describe("Tenants Management", () => {
const visibilityWidth = await page
.getByTestId("tenant-visibility-slot")
.evaluate((element) => element.getBoundingClientRect().width);
- const columns = await layout.evaluate((element) =>
- window.getComputedStyle(element).gridTemplateColumns,
+ const columns = await layout.evaluate(
+ (element) => window.getComputedStyle(element).gridTemplateColumns,
);
expect(columns.split(" ").length).toBe(4);
expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7);
@@ -543,9 +542,7 @@ test.describe("Tenants Management", () => {
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
- await page
- .getByRole("button", { name: "최상위 테넌트로 생성" })
- .click();
+ await page.getByRole("button", { name: "최상위 테넌트로 생성" }).click();
const submitBtn = page.getByRole("button", { name: /^생성$/ });
await expect(submitBtn).toBeDisabled();
@@ -715,8 +712,8 @@ test.describe("Tenants Management", () => {
await expect(layout).toContainText("조직 세부타입");
await expect(layout).toContainText("공개 범위");
- const columns = await layout.evaluate((element) =>
- window.getComputedStyle(element).gridTemplateColumns,
+ const columns = await layout.evaluate(
+ (element) => window.getComputedStyle(element).gridTemplateColumns,
);
expect(columns.split(" ").length).toBe(4);
diff --git a/backend/cmd/server/headless_login_e2e_test.go b/backend/cmd/server/headless_login_e2e_test.go
index a8d1cdad..ded56581 100644
--- a/backend/cmd/server/headless_login_e2e_test.go
+++ b/backend/cmd/server/headless_login_e2e_test.go
@@ -419,6 +419,8 @@ func TestHeadlessPasswordLogin_E2E_ResponseIncludesDetailedCodeAndLogs(t *testin
}
func TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics(t *testing.T) {
+ t.Setenv("BACKEND_PUBLIC_URL", "")
+
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
clientAssertion := mustE2EHeadlessClientAssertion(
@@ -458,6 +460,8 @@ func TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics(t *testing.T) {
}
func TestHeadlessPasswordLogin_E2E_AcceptsForwardedHTTPSAudience(t *testing.T) {
+ t.Setenv("BACKEND_PUBLIC_URL", "")
+
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
clientAssertion := mustE2EHeadlessClientAssertion(
diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go
index 9a15d14e..8b1f141c 100644
--- a/backend/internal/handler/auth_handler_login_test.go
+++ b/backend/internal/handler/auth_handler_login_test.go
@@ -894,6 +894,8 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
}
func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) {
+ t.Setenv("BACKEND_PUBLIC_URL", "")
+
mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"},
diff --git a/backend/internal/handler/rp_manifest_handler_test.go b/backend/internal/handler/rp_manifest_handler_test.go
index 40364dc3..1d13b24c 100644
--- a/backend/internal/handler/rp_manifest_handler_test.go
+++ b/backend/internal/handler/rp_manifest_handler_test.go
@@ -11,6 +11,8 @@ import (
)
func TestRPManifestJSONIncludesIAMAndExternalKeyContract(t *testing.T) {
+ t.Setenv("BACKEND_PUBLIC_URL", "")
+
app := fiber.New()
h := NewRPManifestHandler()
app.Get("/.well-known/baron-rp-manifest.json", h.GetJSON)
diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go
index 289a5438..2b030380 100644
--- a/backend/internal/handler/tenant_handler_test.go
+++ b/backend/internal/handler/tenant_handler_test.go
@@ -112,6 +112,7 @@ func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
m.deletedIDs = append(m.deletedIDs, id)
return nil
}
+
func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil
}
diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go
index 8bb9907a..d722b27d 100644
--- a/backend/internal/service/user_group_service_test.go
+++ b/backend/internal/service/user_group_service_test.go
@@ -55,6 +55,7 @@ func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) erro
m.updatedUsers = append(m.updatedUsers, copied)
return nil
}
+
func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go
index 98c245ff..da0307c1 100644
--- a/backend/internal/service/worksmobile_mapper_test.go
+++ b/backend/internal/service/worksmobile_mapper_test.go
@@ -213,6 +213,8 @@ func TestResolveWorksmobileDomainIDFromTenantIgnoresRootDomainMappings(t *testin
}
func TestResolveWorksmobileDomainIDFromTenantRequiresFamilyDomainEnv(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "")
+
rootConfig := domain.JSONMap{
"worksmobile": map[string]any{
"domainMappings": map[string]any{
diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts
index c4ad1449..df9e9557 100644
--- a/devfront/playwright.config.ts
+++ b/devfront/playwright.config.ts
@@ -1,5 +1,12 @@
+import { createRequire } from "node:module";
import { defineConfig, devices } from "@playwright/test";
+const require = createRequire(import.meta.url);
+const { shouldIncludeWebKit } =
+ require("../scripts/playwrightHostDeps.cjs") as {
+ shouldIncludeWebKit: () => boolean;
+ };
+
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
@@ -52,10 +59,14 @@ export default defineConfig({
use: { ...devices["Desktop Firefox"] },
},
- {
- name: "webkit",
- use: { ...devices["Desktop Safari"] },
- },
+ ...(shouldIncludeWebKit()
+ ? [
+ {
+ name: "webkit",
+ use: { ...devices["Desktop Safari"] },
+ },
+ ]
+ : []),
],
/* Run your local dev server before starting the tests */
diff --git a/devfront/scripts/runtime-mode.sh b/devfront/scripts/runtime-mode.sh
index 0e9f72d5..83b47b41 100644
--- a/devfront/scripts/runtime-mode.sh
+++ b/devfront/scripts/runtime-mode.sh
@@ -35,6 +35,29 @@ if [ "${1:-}" = "--print-mode" ]; then
exit 0
fi
+ensure_frontend_dependencies() {
+ if [ ! -f package.json ] || [ ! -f package-lock.json ]; then
+ return 0
+ fi
+
+ if command -v sha256sum >/dev/null 2>&1; then
+ deps_hash="$(sha256sum package.json package-lock.json | sha256sum | awk '{print $1}')"
+ else
+ deps_hash="$(cksum package.json package-lock.json | cksum | awk '{print $1}')"
+ fi
+ deps_stamp="node_modules/.baron-deps-hash"
+ installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
+
+ if [ "$installed_hash" != "$deps_hash" ]; then
+ echo "Installing frontend dependencies from package-lock.json..."
+ npm ci
+ mkdir -p node_modules
+ printf '%s\n' "$deps_hash" > "$deps_stamp"
+ fi
+}
+
+ensure_frontend_dependencies
+
if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..."
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
diff --git a/locales/en.toml b/locales/en.toml
index 10e89f32..27d15078 100644
--- a/locales/en.toml
+++ b/locales/en.toml
@@ -208,6 +208,11 @@ subtitle = "Review registered tenants and manage their current status."
[msg.admin.tenants.import_preview]
description = "Rows without tenant_id are compared with existing tenant candidates, then imported as new tenants or updates."
+[msg.admin.tenants.parent]
+local_picker_description = "Select the tenant to use as the parent from the tenant list."
+local_picker_empty = "No selectable tenants are available."
+picker_description = "Select a tenant in org-chart to apply it as the parent tenant."
+
[msg.admin.tenants.admins]
add_success = "Tenant admin added successfully."
empty = "No tenant admins are assigned yet."
@@ -218,6 +223,7 @@ remove_success = "Tenant admin removed successfully."
subtitle = "Manage the administrators assigned to this tenant."
[msg.admin.tenants.create]
+pick_parent_first = "Select the parent tenant first."
subtitle = "Enter the minimum required information to create a tenant."
[msg.admin.tenants.create.form]
@@ -1124,9 +1130,14 @@ title = "Domain conflict"
candidates = "Candidates"
confirm = "Run import"
create_new_reset = "Create new (reset ID/slug)"
+csv_parents = "CSV Parents"
external_id = "External ID"
match = "Match"
no_candidates = "No candidates"
+parent = "Parent"
+parent_companies = "Parent Companies"
+parent_company_groups = "Parent Company Groups"
+parent_organizations = "Parent Organizations"
parent_unresolved = "Parent needs review"
slug_exists = "slug conflict"
title = "Confirm CSV import"
@@ -1163,11 +1174,20 @@ domains_placeholder = "example.com, example.kr"
name = "Tenant name"
name_placeholder = "Enter tenant name"
parent = "Parent"
+pick_hanmac_parent = "Pick from Hanmac Family"
+pick_other_parent = "Pick another tenant"
+root_tenant = "Create as top-level tenant"
slug = "Slug"
slug_placeholder = "tenant-slug"
status = "Status"
type = "Type"
+[ui.admin.tenants.create.parent_context]
+general = "General child tenant"
+hanmac = "Hanmac Family child tenant"
+pick_required = "Parent tenant selection required"
+root = "Top-level tenant"
+
[ui.admin.tenants.create.memo]
title = "Policy Memo"
@@ -1249,9 +1269,14 @@ view_profile = "View Profile"
candidates = "Candidates"
confirm = "Confirm Import"
create_new = "Create New"
+csv_parents = "CSV Parents"
fixed_id = "Fixed ID"
match = "Matched Tenant"
no_candidates = "No matching tenants found."
+parent = "Parent"
+parent_companies = "Parent Companies"
+parent_company_groups = "Parent Company Groups"
+parent_organizations = "Parent Organizations"
title = "Import Preview"
[ui.admin.tenants.members.table]
@@ -1278,16 +1303,22 @@ allowed_domains_help = "Users with these email domains will be automatically ass
approve_button = "Approve Tenant"
description = "Review and edit the tenant's basic profile information."
name = "Tenant Name"
+org_unit_type = "Organization detail type"
slug = "Slug"
status = "Status"
subtitle = "Slug and status changes are applied immediately."
title = "Tenant Profile"
type = "Type"
+visibility = "Visibility"
[ui.admin.tenants.profile.form]
parent = "Parent Tenant (Optional)"
parent_help = "Select a parent tenant if this is a subsidiary or sub-organization."
+[ui.admin.tenants.parent]
+local_search_placeholder = "Search tenant name or slug"
+pick_tenant = "Pick tenant"
+
[ui.admin.tenants.registry]
title = "Tenant registry"
diff --git a/locales/ko.toml b/locales/ko.toml
index 8a9f1f15..332c5ac6 100644
--- a/locales/ko.toml
+++ b/locales/ko.toml
@@ -120,6 +120,11 @@ subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다
[msg.admin.tenants.import_preview]
description = "tenant_id가 없는 행은 기존 테넌트 후보와 비교한 뒤 신규 생성 또는 기존 테넌트 갱신으로 처리합니다."
+[msg.admin.tenants.parent]
+local_picker_description = "테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다."
+local_picker_empty = "선택할 수 있는 테넌트가 없습니다."
+picker_description = "org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다."
+
[msg.dev.auth]
access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요."
access_denied_title = "접근 권한이 없습니다."
@@ -368,9 +373,14 @@ title = "도메인 충돌"
candidates = "후보"
confirm = "가져오기 실행"
create_new_reset = "신규 생성 (ID/slug 재설정)"
+csv_parents = "CSV 상위 테넌트"
external_id = "외부 ID"
match = "매칭"
no_candidates = "후보 없음"
+parent = "상위"
+parent_companies = "상위 회사"
+parent_company_groups = "상위 그룹사"
+parent_organizations = "상위 조직"
parent_unresolved = "부모 확인 필요"
slug_exists = "slug 충돌"
title = "CSV 가져오기 확인"
@@ -699,6 +709,7 @@ remove_success = "권한이 회수되었습니다."
subtitle = "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다."
[msg.admin.tenants.create]
+pick_parent_first = "상위 테넌트를 먼저 선택하세요."
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
[msg.admin.tenants.create.form]
@@ -1623,11 +1634,20 @@ domains_placeholder = "example.com, example.kr"
name = "테넌트 이름"
name_placeholder = "테넌트 이름을 입력하세요"
parent = "상위 테넌트"
+pick_hanmac_parent = "한맥가족에서 선택"
+pick_other_parent = "다른 테넌트 선택"
+root_tenant = "최상위 테넌트로 생성"
slug = "Slug"
slug_placeholder = "tenant-slug"
status = "상태"
type = "유형"
+[ui.admin.tenants.create.parent_context]
+general = "일반 하위 테넌트"
+hanmac = "한맥가족 하위 테넌트"
+pick_required = "상위 테넌트 선택 필요"
+root = "최상위 테넌트"
+
[ui.admin.tenants.create.memo]
title = "정책 메모"
@@ -1711,9 +1731,14 @@ view_profile = "상세 정보"
candidates = "후보"
confirm = "임포트 확정"
create_new = "새로 생성"
+csv_parents = "CSV 상위 테넌트"
fixed_id = "고정 ID"
match = "매칭된 테넌트"
no_candidates = "매칭 가능한 테넌트가 없습니다."
+parent = "상위"
+parent_companies = "상위 회사"
+parent_company_groups = "상위 그룹사"
+parent_organizations = "상위 조직"
title = "임포트 미리보기"
[ui.admin.tenants.members.table]
@@ -1740,16 +1765,22 @@ allowed_domains_help = "이 도메인을 가진 이메일로 가입한 사용자
approve_button = "테넌트 승인"
description = "설명"
name = "테넌트 이름"
+org_unit_type = "조직 세부타입"
slug = "슬러그 (Slug)"
status = "상태"
subtitle = "슬러그 및 상태 변경은 즉시 적용됩니다."
title = "테넌트 프로필"
type = "테넌트 유형"
+visibility = "공개 범위"
[ui.admin.tenants.profile.form]
parent = "상위 테넌트 (선택)"
parent_help = "가족사 테넌트나 하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요."
+[ui.admin.tenants.parent]
+local_search_placeholder = "테넌트 이름 또는 슬러그 검색"
+pick_tenant = "테넌트 선택"
+
[ui.admin.tenants.registry]
title = "Tenant registry"
diff --git a/locales/template.toml b/locales/template.toml
index 5c5df6b4..756aa52f 100644
--- a/locales/template.toml
+++ b/locales/template.toml
@@ -237,9 +237,14 @@ title = ""
candidates = ""
confirm = ""
create_new_reset = ""
+csv_parents = ""
external_id = ""
match = ""
no_candidates = ""
+parent = ""
+parent_companies = ""
+parent_company_groups = ""
+parent_organizations = ""
parent_unresolved = ""
slug_exists = ""
title = ""
@@ -568,6 +573,7 @@ remove_success = ""
subtitle = ""
[msg.admin.tenants.create]
+pick_parent_first = ""
subtitle = ""
[msg.admin.tenants.create.form]
@@ -1492,11 +1498,20 @@ domains_placeholder = ""
name = ""
name_placeholder = ""
parent = ""
+pick_hanmac_parent = ""
+pick_other_parent = ""
+root_tenant = ""
slug = ""
slug_placeholder = ""
status = ""
type = ""
+[ui.admin.tenants.create.parent_context]
+general = ""
+hanmac = ""
+pick_required = ""
+root = ""
+
[ui.admin.tenants.create.memo]
title = ""
@@ -1565,6 +1580,11 @@ seed_delete_blocked = ""
[msg.admin.tenants.import_preview]
description = ""
+[msg.admin.tenants.parent]
+local_picker_description = ""
+local_picker_empty = ""
+picker_description = ""
+
[msg.admin.users]
self_delete_blocked = ""
export_error = ""
@@ -1610,16 +1630,22 @@ allowed_domains_help = ""
approve_button = ""
description = ""
name = ""
+org_unit_type = ""
slug = ""
status = ""
subtitle = ""
title = ""
type = ""
+visibility = ""
[ui.admin.tenants.profile.form]
parent = ""
parent_help = ""
+[ui.admin.tenants.parent]
+local_search_placeholder = ""
+pick_tenant = ""
+
[ui.admin.tenants.registry]
title = ""
@@ -1662,9 +1688,14 @@ tree_search_placeholder = ""
candidates = ""
confirm = ""
create_new = ""
+csv_parents = ""
fixed_id = ""
match = ""
no_candidates = ""
+parent = ""
+parent_companies = ""
+parent_company_groups = ""
+parent_organizations = ""
title = ""
[ui.admin.tenants.sub.table]
diff --git a/orgfront/playwright.config.ts b/orgfront/playwright.config.ts
index fb65a23e..0673c05b 100644
--- a/orgfront/playwright.config.ts
+++ b/orgfront/playwright.config.ts
@@ -1,5 +1,12 @@
+import { createRequire } from "node:module";
import { defineConfig, devices } from "@playwright/test";
+const require = createRequire(import.meta.url);
+const { shouldIncludeWebKit } =
+ require("../scripts/playwrightHostDeps.cjs") as {
+ shouldIncludeWebKit: () => boolean;
+ };
+
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
@@ -58,10 +65,14 @@ export default defineConfig({
use: { ...devices["Desktop Firefox"] },
},
- {
- name: "webkit",
- use: { ...devices["Desktop Safari"] },
- },
+ ...(shouldIncludeWebKit()
+ ? [
+ {
+ name: "webkit",
+ use: { ...devices["Desktop Safari"] },
+ },
+ ]
+ : []),
],
/* Run your local dev server before starting the tests */
diff --git a/orgfront/scripts/runtime-mode.sh b/orgfront/scripts/runtime-mode.sh
index ab6384c0..caec8753 100644
--- a/orgfront/scripts/runtime-mode.sh
+++ b/orgfront/scripts/runtime-mode.sh
@@ -35,6 +35,29 @@ if [ "${1:-}" = "--print-mode" ]; then
exit 0
fi
+ensure_frontend_dependencies() {
+ if [ ! -f package.json ] || [ ! -f package-lock.json ]; then
+ return 0
+ fi
+
+ if command -v sha256sum >/dev/null 2>&1; then
+ deps_hash="$(sha256sum package.json package-lock.json | sha256sum | awk '{print $1}')"
+ else
+ deps_hash="$(cksum package.json package-lock.json | cksum | awk '{print $1}')"
+ fi
+ deps_stamp="node_modules/.baron-deps-hash"
+ installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
+
+ if [ "$installed_hash" != "$deps_hash" ]; then
+ echo "Installing frontend dependencies from package-lock.json..."
+ npm ci
+ mkdir -p node_modules
+ printf '%s\n' "$deps_hash" > "$deps_stamp"
+ fi
+}
+
+ensure_frontend_dependencies
+
if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..."
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0 --port 5175"
diff --git a/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts b/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts
index 28d69c14..df2df94a 100644
--- a/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts
+++ b/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts
@@ -46,9 +46,7 @@ describe("hanmac family organization order", () => {
it("does not rank generic technical centers as GPDTDC", () => {
expect(
- getHanmacFamilyTenantOrderRank(
- tenant("기술개발센터", "rnd-center"),
- ),
+ getHanmacFamilyTenantOrderRank(tenant("기술개발센터", "rnd-center")),
).toBe(Number.MAX_SAFE_INTEGER);
});
});
diff --git a/orgfront/src/features/orgchart/pickerTree.ts b/orgfront/src/features/orgchart/pickerTree.ts
index 3a367235..c1acbdf9 100644
--- a/orgfront/src/features/orgchart/pickerTree.ts
+++ b/orgfront/src/features/orgchart/pickerTree.ts
@@ -51,10 +51,9 @@ function tenantToPickerNode(
tenant: TenantNode,
usersBySlug: Map,
): OrgPickerTreeNode {
- const tenantChildren = orderHanmacFamilyChildren(
- tenant,
- tenant.children,
- ).map((child) => tenantToPickerNode(child, usersBySlug));
+ const tenantChildren = orderHanmacFamilyChildren(tenant, tenant.children).map(
+ (child) => tenantToPickerNode(child, usersBySlug),
+ );
const userChildren = (usersBySlug.get(tenant.slug.toLowerCase()) || []).map(
(user) => ({
type: "user" as const,
diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
index 64d94410..341cf4fa 100644
--- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
+++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
@@ -1032,12 +1032,11 @@ export function buildOrgSelectionOptions(
(familyRoot?.children ?? []).filter((node) =>
["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes(node.type),
),
- )
- .map((node) => ({
- descendants: collectOrgSelectionDescendants(node, 2),
- id: node.id,
- label: node.name,
- }));
+ ).map((node) => ({
+ descendants: collectOrgSelectionDescendants(node, 2),
+ id: node.id,
+ label: node.name,
+ }));
}
function getOrgSelectionLabel(
diff --git a/scripts/playwrightHostDeps.cjs b/scripts/playwrightHostDeps.cjs
new file mode 100644
index 00000000..a3c2d824
--- /dev/null
+++ b/scripts/playwrightHostDeps.cjs
@@ -0,0 +1,60 @@
+const { execFileSync } = require("node:child_process");
+
+const webkitHostLibraries = [
+ "libgtk-4.so.1",
+ "libgraphene-1.0.so.0",
+ "libxslt.so.1",
+ "libevent-2.1.so.7",
+ "libopus.so.0",
+ "libgstallocators-1.0.so.0",
+ "libgstapp-1.0.so.0",
+ "libgstpbutils-1.0.so.0",
+ "libgstaudio-1.0.so.0",
+ "libgsttag-1.0.so.0",
+ "libgstvideo-1.0.so.0",
+ "libgstgl-1.0.so.0",
+ "libgstcodecparsers-1.0.so.0",
+ "libgstfft-1.0.so.0",
+ "libflite.so.1",
+ "libwebpdemux.so.2",
+ "libavif.so.16",
+ "libharfbuzz-icu.so.0",
+ "libwebpmux.so.3",
+ "libwayland-server.so.0",
+ "libmanette-0.2.so.0",
+ "libenchant-2.so.2",
+ "libhyphen.so.0",
+ "libsecret-1.so.0",
+ "libwoff2dec.so.1.0.2",
+ "libx264.so",
+];
+
+function hasWebKitHostDependencies() {
+ if (process.platform !== "linux") {
+ return true;
+ }
+
+ let output = "";
+ try {
+ output = execFileSync("ldconfig", ["-p"], { encoding: "utf8" });
+ } catch {
+ return false;
+ }
+
+ return webkitHostLibraries.every((library) => output.includes(library));
+}
+
+function shouldIncludeWebKit() {
+ if (process.env.PLAYWRIGHT_FORCE_WEBKIT === "1") {
+ return true;
+ }
+ if (process.env.PLAYWRIGHT_SKIP_WEBKIT === "1") {
+ return false;
+ }
+ return hasWebKitHostDependencies();
+}
+
+module.exports = {
+ hasWebKitHostDependencies,
+ shouldIncludeWebKit,
+};
diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh
index 27491756..7c5d3f03 100755
--- a/scripts/run_adminfront_ci_tests.sh
+++ b/scripts/run_adminfront_ci_tests.sh
@@ -18,6 +18,8 @@ rm -rf adminfront/node_modules
tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)"
playwright_browsers_path="$tmp_dir/ms-playwright"
+mkdir -p "$tmp_dir/scripts"
+cp "$repo_root/scripts/playwrightHostDeps.cjs" "$tmp_dir/scripts/"
if command -v rsync >/dev/null 2>&1; then
rsync -rlptD --delete \
@@ -58,6 +60,53 @@ find_available_port() {
playwright_install_cmd=(npx playwright install)
playwright_install_desc="npx playwright install"
+playwright_project_args=()
+
+has_webkit_host_dependencies() {
+ if [ "$(uname -s)" != "Linux" ]; then
+ return 0
+ fi
+ if ! command -v ldconfig >/dev/null 2>&1; then
+ return 1
+ fi
+
+ local missing=0
+ local lib
+ for lib in \
+ libgtk-4.so.1 \
+ libgraphene-1.0.so.0 \
+ libxslt.so.1 \
+ libevent-2.1.so.7 \
+ libopus.so.0 \
+ libgstallocators-1.0.so.0 \
+ libgstapp-1.0.so.0 \
+ libgstpbutils-1.0.so.0 \
+ libgstaudio-1.0.so.0 \
+ libgsttag-1.0.so.0 \
+ libgstvideo-1.0.so.0 \
+ libgstgl-1.0.so.0 \
+ libgstcodecparsers-1.0.so.0 \
+ libgstfft-1.0.so.0 \
+ libflite.so.1 \
+ libwebpdemux.so.2 \
+ libavif.so.16 \
+ libharfbuzz-icu.so.0 \
+ libwebpmux.so.3 \
+ libwayland-server.so.0 \
+ libmanette-0.2.so.0 \
+ libenchant-2.so.2 \
+ libhyphen.so.0 \
+ libsecret-1.so.0 \
+ libwoff2dec.so.1.0.2 \
+ libx264.so; do
+ if ! ldconfig -p 2>/dev/null | grep -Fq "$lib"; then
+ missing=1
+ break
+ fi
+ done
+
+ [ "$missing" -eq 0 ]
+}
if [ "$(id -u)" -eq 0 ]; then
playwright_install_cmd=(npx playwright install --with-deps)
@@ -65,6 +114,17 @@ if [ "$(id -u)" -eq 0 ]; then
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
playwright_install_cmd=(npx playwright install --with-deps)
playwright_install_desc="npx playwright install --with-deps"
+elif ! has_webkit_host_dependencies; then
+ playwright_install_cmd=(npx playwright install chromium firefox)
+ playwright_install_desc="npx playwright install chromium firefox"
+ playwright_project_args=(--project=chromium --project=firefox)
+ {
+ echo "# Adminfront WebKit Skipped"
+ echo
+ echo "- Reason: WebKit host dependencies are not installed and this user cannot run passwordless sudo."
+ echo "- Action: Running Chromium and Firefox projects only."
+ echo "- To enable WebKit locally: run \`cd adminfront && npx playwright install-deps webkit\` with sudo privileges."
+ } > reports/adminfront-webkit-skipped.md
fi
set +e
@@ -134,7 +194,7 @@ echo "==> adminfront using PORT=$port"
(
cd "$tmp_dir/adminfront"
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" \
- node ./node_modules/playwright/cli.js test
+ node ./node_modules/playwright/cli.js test "${playwright_project_args[@]}"
) 2>&1 | tee reports/adminfront-test.log
test_exit_code=${PIPESTATUS[0]}
set -e
diff --git a/scripts/test_frontend_runtime_mode.sh b/scripts/test_frontend_runtime_mode.sh
index c7fc5404..5d63a859 100644
--- a/scripts/test_frontend_runtime_mode.sh
+++ b/scripts/test_frontend_runtime_mode.sh
@@ -17,6 +17,14 @@ for script in \
"./devfront/scripts/runtime-mode.sh" \
"./orgfront/scripts/runtime-mode.sh"
do
+ if ! grep -Fq "ensure_frontend_dependencies" "$script"; then
+ echo "script=$script must sync frontend dependencies before start" >&2
+ exit 1
+ fi
+ if ! grep -Fq "package-lock.json" "$script"; then
+ echo "script=$script must use package-lock.json for dependency sync" >&2
+ exit 1
+ fi
assert_mode "$script" "production" "production"
assert_mode "$script" "prod" "production"
assert_mode "$script" "stage" "production"
diff --git a/test/staging_frontend_deploy_policy_test.sh b/test/staging_frontend_deploy_policy_test.sh
index 809ac147..f4c2509e 100644
--- a/test/staging_frontend_deploy_policy_test.sh
+++ b/test/staging_frontend_deploy_policy_test.sh
@@ -24,13 +24,19 @@ pull_compose="docker/staging_pull_compose.template.yaml"
devfront_vite="devfront/vite.config.ts"
orgfront_vite="orgfront/vite.config.ts"
adminfront_vite="adminfront/vite.config.ts"
+adminfront_runtime="adminfront/scripts/runtime-mode.sh"
+devfront_runtime="devfront/scripts/runtime-mode.sh"
+orgfront_runtime="orgfront/scripts/runtime-mode.sh"
for file in \
"$staging_pull" \
"$pull_compose" \
"$adminfront_vite" \
"$devfront_vite" \
- "$orgfront_vite"
+ "$orgfront_vite" \
+ "$adminfront_runtime" \
+ "$devfront_runtime" \
+ "$orgfront_runtime"
do
if [ ! -f "$file" ]; then
echo "missing expected file: $file" >&2
@@ -49,6 +55,7 @@ done
assert_contains "$staging_pull" 'bash scripts/render_ory_config.sh'
assert_contains "$staging_pull" 'chmod -R 777 config/.generated/ory'
assert_contains "$staging_pull" 'docker compose -f staging_pull_compose.yaml build --pull'
+assert_contains "$staging_pull" 'docker compose -f staging_pull_compose.yaml up -d --remove-orphans --renew-anon-volumes'
assert_contains "$pull_compose" "baron_devfront"
assert_contains "$pull_compose" "baron_orgfront"
@@ -71,4 +78,11 @@ assert_contains "$orgfront_vite" '"sorg.hmac.kr"'
assert_contains "orgfront/biome.json" '".vite"'
assert_contains "orgfront/tsconfig.app.json" '"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]'
+for runtime_script in "$adminfront_runtime" "$devfront_runtime" "$orgfront_runtime"; do
+ assert_contains "$runtime_script" "ensure_frontend_dependencies"
+ assert_contains "$runtime_script" "package-lock.json"
+ assert_contains "$runtime_script" "npm ci"
+ assert_contains "$runtime_script" ".baron-deps-hash"
+done
+
echo "staging frontend deploy policy checks passed"
diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock
index 238c821f..5a7fb7b9 100644
--- a/userfront/pubspec.lock
+++ b/userfront/pubspec.lock
@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
- sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
- version: "1.4.0"
+ version: "1.4.1"
cli_config:
dependency: transitive
description:
@@ -276,14 +276,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
- js:
- dependency: transitive
- description:
- name: js
- sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
- url: "https://pub.dev"
- source: hosted
- version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -336,18 +328,18 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
+ sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
- version: "0.12.17"
+ version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
- version: "0.11.1"
+ version: "0.13.0"
meta:
dependency: transitive
description:
@@ -669,26 +661,26 @@ packages:
dependency: transitive
description:
name: test
- sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
+ sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
- version: "1.26.3"
+ version: "1.30.0"
test_api:
dependency: transitive
description:
name: test_api
- sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
+ sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
- version: "0.7.7"
+ version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
- sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
+ sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
- version: "0.6.12"
+ version: "0.6.16"
toml:
dependency: "direct main"
description: