diff --git a/Makefile b/Makefile index 71ec8719..2f11547d 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,12 @@ COMPOSE_INFRA := compose.infra.yaml COMPOSE_ORY := compose.ory.yaml COMPOSE_APP := docker-compose.yaml AUTH_CONFIG_ENV := .generated/auth-config.env +DEV_SERVICES ?= backend adminfront devfront orgfront userfront +DEV_NETWORKS := baron_net ory-net hydranet kratosnet public_net +INFRA_CONTAINERS := baron_postgres baron_clickhouse baron_redis baron_gateway +ORY_CONTAINERS := ory_postgres ory_kratos ory_hydra ory_keto ory_oathkeeper ory_clickhouse ory_vector +APP_CONTAINERS := baron_backend baron_adminfront baron_devfront baron_orgfront baron_userfront +DROP_CONTAINERS := $(INFRA_CONTAINERS) $(ORY_CONTAINERS) $(APP_CONTAINERS) ory_stack_check COMPOSE_CLI_ENV_ARGS := ifneq (,$(wildcard ./.env)) @@ -18,6 +24,13 @@ COMPOSE_CLI_ENV_ARGS += --env-file .env endif COMPOSE_CLI_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV) +COMPOSE_DROP_ENV_ARGS := +ifneq (,$(wildcard ./.env)) +COMPOSE_DROP_ENV_ARGS += --env-file .env +endif + +.PHONY: build-auth-config validate-auth-config verify-auth-config up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app + # --- 인증 설정 빌드/검증 --- build-auth-config: @echo "Building auth config..." @@ -34,38 +47,94 @@ verify-auth-config: validate-auth-config # --- 기본 실행 --- # 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음) -up-all: validate-auth-config +up-all: ensure-networks validate-auth-config @echo "Starting ALL stacks (infra + ory + app)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d # --- 개별 스택 실행 --- -up-infra: +up-infra: ensure-networks @echo "Starting Infra stack (postgres/clickhouse/redis)..." docker compose -f $(COMPOSE_INFRA) up -d -up-ory: validate-auth-config +up-ory: ensure-networks validate-auth-config @echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d -up-app: validate-auth-config +up-app: ensure-networks validate-auth-config @echo "Starting App stack (backend/userfront/adminfront/devfront)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d -up-backend: validate-auth-config +up-backend: ensure-networks validate-auth-config @echo "Starting Backend only..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend -up-dev: up-infra up-ory +ensure-networks: + @echo "Ensuring Docker networks..." + @for network in $(DEV_NETWORKS); do \ + if ! docker network inspect "$$network" >/dev/null 2>&1; then \ + echo "Creating Docker network $$network..."; \ + docker network create "$$network"; \ + else \ + echo "Docker network $$network already exists."; \ + fi; \ + done + +ensure-infra: ensure-networks + @echo "Ensuring Infra stack..." + @missing=0; \ + for container in $(INFRA_CONTAINERS); do \ + if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \ + missing=1; \ + break; \ + fi; \ + done; \ + if [ "$$missing" -eq 1 ]; then \ + echo "Starting missing Infra stack containers in daemon mode..."; \ + docker compose -f $(COMPOSE_INFRA) up -d; \ + else \ + echo "Infra stack is already running."; \ + fi + +ensure-ory: ensure-networks validate-auth-config + @echo "Ensuring Ory stack..." + @missing=0; \ + for container in $(ORY_CONTAINERS); do \ + if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \ + missing=1; \ + break; \ + fi; \ + done; \ + if [ "$$missing" -eq 1 ]; then \ + echo "Starting missing Ory stack containers in daemon mode..."; \ + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \ + else \ + echo "Ory stack is already running."; \ + fi + +up-dev: ensure-infra ensure-ory @echo "Dev stack is up (infra + ory)." up-front-dev: up-infra up-ory up-backend @echo "Dev stack is up (infra + ory + backend)." +dev: up-dev + @echo "Starting development app containers in foreground attach mode..." + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up $(DEV_SERVICES) + # --- 종료 (Down) --- down: @echo "Stopping ALL stacks (infra + ory + app)..." docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down +drop: + @echo "Dropping Baron SSO local Docker stack containers, volumes, and local images..." + -docker compose $(COMPOSE_DROP_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -v --rmi local + @echo "Removing any remaining fixed-name Baron SSO containers..." + @for container in $(DROP_CONTAINERS); do \ + docker rm -f "$$container" >/dev/null 2>&1 || true; \ + done + @echo "Drop complete. External Docker networks are preserved." + down-app: @echo "Stopping App stack..." docker compose -f $(COMPOSE_APP) down diff --git a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx index 79c66166..81587b08 100644 --- a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx +++ b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx @@ -111,7 +111,7 @@ test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실, const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = "org_chart_template.csv"; + a.download = "org_user_import_template.csv"; a.click(); URL.revokeObjectURL(url); }; @@ -130,18 +130,18 @@ test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실, - {t("ui.admin.org.import_title", "조직도 일괄 등록")} + {t("ui.admin.org.import_title", "조직/사용자 통합 일괄 등록")} {t( "msg.admin.org.import_description", - "CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.", + "CSV 또는 XLSX 파일을 업로드하여 조직 테넌트와 사용자를 함께 생성/업데이트하고 멤버십을 매핑합니다.", )} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 0315f6d9..a93c3ea0 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -159,7 +159,7 @@ scope = "Scope" [msg.admin.org] hover_member_info = "Hover to see member details." -import_description = "Upload a CSV file to bulk register the organization chart." +import_description = "Upload a CSV or XLSX file to create or update organization tenants and users together, then map memberships." import_error = "An error occurred during organization chart import." import_success = "Organization chart imported successfully." @@ -841,8 +841,8 @@ users = "Users" [ui.admin.org] download_template = "Download Template" -import_btn = "Import" -import_title = "Bulk Organization Import" +import_btn = "Org/User Import" +import_title = "Bulk Org/User Import" start_import = "Start Import" [ui.admin.overview] diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index da26fc7f..8225592a 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -159,7 +159,7 @@ scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다." [msg.admin.org] hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다." -import_description = "CSV 또는 XLSX 파일을 업로드하여 조직도를 일괄 등록합니다. (필수 컬럼: 이메일, 이름)" +import_description = "CSV 또는 XLSX 파일을 업로드하여 조직 테넌트와 사용자를 함께 생성/업데이트하고 멤버십을 매핑합니다. (필수 컬럼: 이메일, 이름)" import_error = "조직도 임포트 중 오류가 발생했습니다." import_success = "조직도가 성공적으로 임포트되었습니다." @@ -843,8 +843,8 @@ users = "사용자" [ui.admin.org] download_template = "템플릿 다운로드" -import_btn = "임포트" -import_title = "조직도 대량 등록" +import_btn = "조직/사용자 통합 임포트" +import_title = "조직/사용자 통합 일괄 등록" start_import = "임포트 시작" [ui.admin.overview] diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index f2b1488e..066d59ae 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -114,6 +114,26 @@ test.describe("Tenants Management", () => { await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 }); }); + test("should clarify organization import creates org tenants and users together", async ({ + page, + }) => { + await page.goto("/tenants"); + await expect(page.locator("h2").last()).toContainText( + /테넌트 목록|Tenants/i, + { timeout: 20000 }, + ); + + await page + .locator("button") + .filter({ hasText: /임포트|Import/i }) + .first() + .click(); + + await expect(page.getByRole("dialog")).toContainText( + /조직 테넌트.*사용자|organization tenants.*users/i, + ); + }); + test("should show validation error on empty name", async ({ page }) => { await page.goto("/tenants/new"); await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go index 1e7fb717..936a9e79 100644 --- a/backend/internal/bootstrap/tenant_seed.go +++ b/backend/internal/bootstrap/tenant_seed.go @@ -5,6 +5,8 @@ import ( "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "context" + "errors" + "fmt" "log/slog" "gorm.io/gorm" @@ -13,6 +15,8 @@ import ( type InitialTenantConfig struct { Name string Slug string + Type string + ParentSlug string Description string Domains []string } @@ -20,11 +24,25 @@ type InitialTenantConfig struct { // Hardcoded for now, can be moved to config file or env later var defaultTenants = []InitialTenantConfig{ { - Name: "Hanmac Engineering", + Name: "한맥가족", + Slug: "hanmac-family", + Type: domain.TenantTypeCompanyGroup, + }, + { + Name: "한맥기술", Slug: "hanmac", + Type: domain.TenantTypeCompany, + ParentSlug: "hanmac-family", Description: "Primary Family Company", Domains: []string{"hanmaceng.co.kr", "hmac.kr"}, }, + { + Name: "삼안", + Slug: "saman", + Type: domain.TenantTypeCompany, + ParentSlug: "hanmac-family", + Domains: []string{"samaneng.com"}, + }, } func SeedTenants(db *gorm.DB) error { @@ -37,9 +55,65 @@ func SeedTenants(db *gorm.DB) error { ctx := context.Background() for _, config := range defaultTenants { + tenantType := config.Type + if tenantType == "" { + tenantType = domain.TenantTypeCompany + } + + var parentID *string + if config.ParentSlug != "" { + parent, err := repo.FindBySlug(ctx, config.ParentSlug) + if err != nil || parent == nil { + if err == nil { + err = errors.New("parent tenant not found") + } + slog.Error("Failed to resolve parent tenant for seed", "slug", config.Slug, "parentSlug", config.ParentSlug, "error", err) + return fmt.Errorf("resolve parent tenant %q for seed %q: %w", config.ParentSlug, config.Slug, err) + } + parentID = &parent.ID + } + existing, err := repo.FindBySlug(ctx, config.Slug) if err == nil && existing != nil { slog.Info("[Bootstrap] Tenant already exists, checking domains...", "slug", config.Slug) + changed := false + if existing.Name != config.Name { + existing.Name = config.Name + changed = true + } + if existing.Type != tenantType { + existing.Type = tenantType + changed = true + } + if existing.Status != domain.TenantStatusActive { + existing.Status = domain.TenantStatusActive + changed = true + } + if config.ParentSlug != "" { + if existing.ParentID == nil || *existing.ParentID != *parentID { + existing.ParentID = parentID + changed = true + if err := outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: existing.ID, + Relation: "parents", + Subject: "Tenant:" + *parentID, + Action: domain.KetoOutboxActionCreate, + }); err != nil { + slog.Error("Failed to create outbox entry for seeded tenant hierarchy", "tenant", existing.ID, "error", err) + return err + } + } + } else if existing.ParentID != nil { + existing.ParentID = nil + changed = true + } + if changed { + if err := repo.Update(ctx, existing); err != nil { + slog.Error("Failed to update seeded tenant", "slug", config.Slug, "error", err) + return err + } + } // Optional: Check and add missing domains for _, d := range config.Domains { found := false @@ -60,7 +134,7 @@ func SeedTenants(db *gorm.DB) error { } slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug) - tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil, "") + tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, tenantType, config.Description, config.Domains, parentID, "") if err != nil { slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) return err diff --git a/backend/internal/bootstrap/tenant_seed_test.go b/backend/internal/bootstrap/tenant_seed_test.go new file mode 100644 index 00000000..835016cf --- /dev/null +++ b/backend/internal/bootstrap/tenant_seed_test.go @@ -0,0 +1,74 @@ +package bootstrap + +import ( + "baron-sso-backend/internal/domain" + "reflect" + "testing" +) + +func TestDefaultTenantsSeedOrderAndHierarchy(t *testing.T) { + expected := []struct { + name string + slug string + tenantType string + parentSlug string + domains []string + }{ + { + name: "한맥가족", + slug: "hanmac-family", + tenantType: domain.TenantTypeCompanyGroup, + }, + { + name: "한맥기술", + slug: "hanmac", + tenantType: domain.TenantTypeCompany, + parentSlug: "hanmac-family", + domains: []string{"hanmaceng.co.kr", "hmac.kr"}, + }, + { + name: "삼안", + slug: "saman", + tenantType: domain.TenantTypeCompany, + parentSlug: "hanmac-family", + domains: []string{"samaneng.com"}, + }, + } + + if len(defaultTenants) != len(expected) { + t.Fatalf("expected %d default tenants, got %d", len(expected), len(defaultTenants)) + } + + for i, want := range expected { + got := defaultTenants[i] + if got.Name != want.name { + t.Fatalf("tenant[%d] name = %q, want %q", i, got.Name, want.name) + } + if got.Slug != want.slug { + t.Fatalf("tenant[%d] slug = %q, want %q", i, got.Slug, want.slug) + } + if tenantType := stringField(t, got, "Type"); tenantType != want.tenantType { + t.Fatalf("tenant[%d] type = %q, want %q", i, tenantType, want.tenantType) + } + if parentSlug := stringField(t, got, "ParentSlug"); parentSlug != want.parentSlug { + t.Fatalf("tenant[%d] parent slug = %q, want %q", i, parentSlug, want.parentSlug) + } + if !reflect.DeepEqual(got.Domains, want.domains) { + t.Fatalf("tenant[%d] domains = %#v, want %#v", i, got.Domains, want.domains) + } + } +} + +func stringField(t *testing.T, target InitialTenantConfig, name string) string { + t.Helper() + + value := reflect.ValueOf(target) + field := value.FieldByName(name) + if !field.IsValid() { + t.Fatalf("InitialTenantConfig.%s is required", name) + } + if field.Kind() != reflect.String { + t.Fatalf("InitialTenantConfig.%s must be a string", name) + } + return field.String() +} diff --git a/test/make_dev_targets_test.sh b/test/make_dev_targets_test.sh new file mode 100644 index 00000000..92a02b75 --- /dev/null +++ b/test/make_dev_targets_test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +dry_run_dev="$( + make --dry-run --always-make -C "$repo_root" dev DEV_SERVICES="backend adminfront" 2>&1 +)" + +if ! grep -q "Ensuring Infra stack" <<<"$dry_run_dev"; then + echo "make dev must ensure the infra stack first." >&2 + exit 1 +fi + +if ! grep -q "Ensuring Ory stack" <<<"$dry_run_dev"; then + echo "make dev must ensure the Ory stack first." >&2 + exit 1 +fi + +app_up_line="$( + grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront" <<<"$dry_run_dev" | tail -1 +)" + +if [[ -z "$app_up_line" ]]; then + echo "make dev must run docker compose up for development app services." >&2 + exit 1 +fi + +if grep -q -- " -d" <<<"$app_up_line"; then + echo "make dev must run app services in foreground attach mode without -d." >&2 + exit 1 +fi + +dry_run_up_dev="$( + make --dry-run --always-make -C "$repo_root" up-dev 2>&1 +)" + +if ! grep -q "Ensuring Infra stack" <<<"$dry_run_up_dev"; then + echo "make up-dev must ensure the infra stack." >&2 + exit 1 +fi + +if ! grep -q "Ensuring Ory stack" <<<"$dry_run_up_dev"; then + echo "make up-dev must ensure the Ory stack." >&2 + exit 1 +fi + +dry_run_up_all="$( + make --dry-run --always-make -C "$repo_root" up-all 2>&1 +)" + +if ! grep -q "Ensuring Docker networks" <<<"$dry_run_up_all"; then + echo "make up-all must ensure external Docker networks before compose up." >&2 + exit 1 +fi + +if ! grep -q 'docker network create "$network"' <<<"$dry_run_up_all"; then + echo "make up-all must create missing external Docker networks." >&2 + exit 1 +fi + +dry_run_drop="$( + make --dry-run --always-make -C "$repo_root" drop 2>&1 +)" + +if ! grep -q "Dropping Baron SSO local Docker stack" <<<"$dry_run_drop"; then + echo "make drop must announce that it is dropping the local stack." >&2 + exit 1 +fi + +if ! grep -q -- "down -v --rmi local" <<<"$dry_run_drop"; then + echo "make drop must remove containers, volumes, and local compose images." >&2 + exit 1 +fi + +if ! grep -q "docker rm -f" <<<"$dry_run_drop"; then + echo "make drop must force-remove known fixed-name stack containers." >&2 + exit 1 +fi