diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index 4155f37a..366679e7 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -174,7 +174,7 @@ jobs: # 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등) docker compose -f staging_pull_compose.yaml build --pull - docker compose -f staging_pull_compose.yaml up -d --remove-orphans + docker compose -f staging_pull_compose.yaml up -d --remove-orphans --renew-anon-volumes docker compose -f staging_pull_compose.yaml up -d --force-recreate kratos hydra keto oathkeeper docker compose -f staging_pull_compose.yaml up -d --force-recreate ory_stack_check docker compose -f staging_pull_compose.yaml up -d init-rp diff --git a/adminfront/playwright.config.ts b/adminfront/playwright.config.ts index 970a7ad9..bbfcbd3f 100644 --- a/adminfront/playwright.config.ts +++ b/adminfront/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; @@ -57,10 +64,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/adminfront/scripts/runtime-mode.sh b/adminfront/scripts/runtime-mode.sh index 07d2a539..817ca498 100644 --- a/adminfront/scripts/runtime-mode.sh +++ b/adminfront/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/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 5a21b7ea..9c1d580a 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -101,18 +101,10 @@ export function ParentTenantSelector({ return (
- + {labelAction}
- +
-
+
diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 785bd1f0..951fd8bd 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -338,10 +338,7 @@ export function TenantProfilePage() { ))}
-
+
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: