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