From 5dd425050cdde30d11653023f9ebfe2984b727fc Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 2 Feb 2026 14:05:50 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20=EA=B2=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantCreatePage.tsx | 19 ++++ .../tenants/routes/TenantProfilePage.tsx | 20 ++++ .../src/features/users/UserCreatePage.tsx | 29 +++-- .../src/features/users/UserDetailPage.tsx | 33 ++++-- .../src/features/users/UserListPage.tsx | 17 ++- adminfront/src/lib/adminApi.ts | 4 + backend/cmd/server/main.go | 10 +- backend/internal/bootstrap/bootstrap.go | 7 ++ backend/internal/bootstrap/tenant_seed.go | 66 ++++++++++++ backend/internal/domain/auth_models.go | 7 +- backend/internal/domain/tenant.go | 1 + backend/internal/domain/tenant_domain.go | 27 +++++ backend/internal/domain/user.go | 2 + backend/internal/handler/auth_handler.go | 58 +++++++++- backend/internal/handler/tenant_handler.go | 100 +++++++++++------- backend/internal/handler/user_handler.go | 66 ++++++++---- .../internal/repository/tenant_repository.go | 66 ++++++++++++ .../internal/repository/user_repository.go | 56 ++++++++++ backend/internal/service/tenant_service.go | 66 ++++++++++++ .../data/models/user_profile_model.dart | 37 +++++++ .../presentation/pages/profile_page.dart | 6 +- 21 files changed, 613 insertions(+), 84 deletions(-) create mode 100644 backend/internal/bootstrap/tenant_seed.go create mode 100644 backend/internal/domain/tenant_domain.go create mode 100644 backend/internal/repository/tenant_repository.go create mode 100644 backend/internal/repository/user_repository.go create mode 100644 backend/internal/service/tenant_service.go diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index 6d67d81d..cfe52198 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -23,6 +23,7 @@ function TenantCreatePage() { const [slug, setSlug] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("active"); + const [domains, setDomains] = useState(""); const mutation = useMutation({ mutationFn: () => @@ -31,6 +32,10 @@ function TenantCreatePage() { slug: slug || undefined, description: description || undefined, status, + domains: domains + .split(",") + .map((d) => d.trim()) + .filter((d) => d !== ""), }), onSuccess: () => { navigate("/tenants"); @@ -92,6 +97,20 @@ function TenantCreatePage() { onChange={(e) => setDescription(e.target.value)} /> +
+ + setDomains(e.target.value)} + placeholder="example.com, example.kr" + /> +

+ Users with these email domains will be automatically assigned to + this tenant. +

+
diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 44c0aa9a..9850b814 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -37,6 +37,7 @@ export function TenantProfilePage() { const [slug, setSlug] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("active"); + const [domains, setDomains] = useState(""); useEffect(() => { if (tenantQuery.data) { @@ -44,6 +45,7 @@ export function TenantProfilePage() { setSlug(tenantQuery.data.slug); setDescription(tenantQuery.data.description ?? ""); setStatus(tenantQuery.data.status); + setDomains(tenantQuery.data.domains?.join(", ") ?? ""); } }, [tenantQuery.data]); @@ -54,6 +56,10 @@ export function TenantProfilePage() { slug, description: description || undefined, status, + domains: domains + .split(",") + .map((d) => d.trim()) + .filter((d) => d !== ""), }), onSuccess: () => { navigate("/tenants"); @@ -111,6 +117,20 @@ export function TenantProfilePage() { onChange={(e) => setDescription(e.target.value)} />
+
+ + setDomains(e.target.value)} + placeholder="example.com, example.kr" + /> +

+ Users with these email domains will be automatically assigned to + this tenant. +

+
diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index 6d32ada3..536644d7 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react"; import * as React from "react"; @@ -16,6 +16,7 @@ import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { createUser, + fetchTenants, type UserCreateRequest, type UserCreateResponse, } from "../../lib/adminApi"; @@ -28,6 +29,12 @@ function UserCreatePage() { const [createdEmail, setCreatedEmail] = React.useState(null); const [autoPassword, setAutoPassword] = React.useState(true); + const { data: tenantsData } = useQuery({ + queryKey: ["tenants", { limit: 100 }], + queryFn: () => fetchTenants(100, 0), + }); + const tenants = tenantsData?.items ?? []; + const { register, handleSubmit, @@ -207,12 +214,20 @@ function UserCreatePage() {
- - + +
+ +
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index afb4512a..3b0fdc9e 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -14,7 +14,12 @@ import { } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; -import { fetchUser, updateUser, type UserUpdateRequest } from "../../lib/adminApi"; +import { + fetchUser, + fetchTenants, + updateUser, + type UserUpdateRequest, +} from "../../lib/adminApi"; function UserDetailPage() { const { id } = useParams<{ id: string }>(); @@ -29,6 +34,12 @@ function UserDetailPage() { enabled: !!id, }); + const { data: tenantsData } = useQuery({ + queryKey: ["tenants", { limit: 100 }], + queryFn: () => fetchTenants(100, 0), + }); + const tenants = tenantsData?.items ?? []; + const { register, handleSubmit, @@ -191,12 +202,20 @@ function UserDetailPage() {
- - + +
+ +
diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index e27fbb29..29ab5d1c 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -73,6 +73,12 @@ function UserListPage() { const total = query.data?.total ?? 0; const totalPages = Math.ceil(total / limit); + React.useEffect(() => { + if (items.length > 0) { + console.log("User items:", items); + } + }, [items]); + const handleDelete = (userId: string, userName: string) => { if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) { return; @@ -151,7 +157,7 @@ function UserListPage() { NAME / EMAIL ROLE STATUS - COMPANY / DEPT + TENANT / DEPT CREATED ACTIONS @@ -200,7 +206,14 @@ function UserListPage() {
- {user.companyCode || "-"} + + {user.tenant?.name || user.companyCode || "-"} + + {user.tenant && ( + + Slug: {user.tenant.slug} + + )} {user.department || "-"} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index ec4844e3..28486211 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -25,6 +25,7 @@ export type TenantSummary = { slug: string; description: string; status: string; + domains?: string[]; createdAt: string; updatedAt: string; }; @@ -34,6 +35,7 @@ export type TenantCreateRequest = { slug?: string; description?: string; status?: string; + domains?: string[]; }; export type TenantListResponse = { @@ -48,6 +50,7 @@ export type TenantUpdateRequest = { slug?: string; description?: string; status?: string; + domains?: string[]; }; export type ApiKeySummary = { @@ -168,6 +171,7 @@ export type UserSummary = { role: string; status: string; companyCode?: string; + tenant?: TenantSummary; department?: string; createdAt: string; updatedAt: string; diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 1a835f59..d56b992e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -223,14 +223,18 @@ func main() { } // 2. Initialize Handlers + tenantRepo := repository.NewTenantRepository(db) + tenantService := service.NewTenantService(tenantRepo) + userRepo := repository.NewUserRepository(db) + auditHandler := handler.NewAuditHandler(auditRepo) - authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo) + authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, userRepo) adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler() - tenantHandler := handler.NewTenantHandler(db) + tenantHandler := handler.NewTenantHandler(db, tenantService) kratosAdminService := service.NewKratosAdminService() oryAdminProvider := service.NewOryProvider() - userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider) + userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService) apiKeyHandler := handler.NewApiKeyHandler(db) // 3. Initialize Fiber diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index a1aaa2bd..a4e57f05 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -17,6 +17,11 @@ func Run(db *gorm.DB) error { return fmt.Errorf("migration failed: %w", err) } + // 2. Seed Tenants + if err := SeedTenants(db); err != nil { + return fmt.Errorf("tenant seeding failed: %w", err) + } + slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)") slog.Info("[Bootstrap] Bootstrap completed successfully.") return nil @@ -27,6 +32,8 @@ func migrateSchemas(db *gorm.DB) error { // Add all domain models here return db.AutoMigrate( &domain.Tenant{}, + &domain.TenantDomain{}, + &domain.User{}, &domain.ApiKey{}, &domain.IdentityProviderConfig{}, // &domain.RelyingParty{}, // TODO: Uncomment when model is ready diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go new file mode 100644 index 00000000..394576ba --- /dev/null +++ b/backend/internal/bootstrap/tenant_seed.go @@ -0,0 +1,66 @@ +package bootstrap + +import ( + "baron-sso-backend/internal/repository" + "baron-sso-backend/internal/service" + "context" + "log/slog" + + "gorm.io/gorm" +) + +type InitialTenantConfig struct { + Name string + Slug string + Description string + Domains []string +} + +// Hardcoded for now, can be moved to config file or env later +var defaultTenants = []InitialTenantConfig{ + { + Name: "Hanmac Engineering", + Slug: "hanmac", + Description: "Primary Family Company", + Domains: []string{"hanmaceng.co.kr", "hmac.kr"}, + }, +} + +func SeedTenants(db *gorm.DB) error { + slog.Info("[Bootstrap] Seeding initial tenants...") + repo := repository.NewTenantRepository(db) + svc := service.NewTenantService(repo) + ctx := context.Background() + + for _, config := range defaultTenants { + existing, err := repo.FindBySlug(ctx, config.Slug) + if err == nil && existing != nil { + slog.Info("[Bootstrap] Tenant already exists, checking domains...", "slug", config.Slug) + // Optional: Check and add missing domains + for _, d := range config.Domains { + found := false + for _, ed := range existing.Domains { + if ed.Domain == d { + found = true + break + } + } + if !found { + slog.Info("[Bootstrap] Adding missing domain to tenant", "slug", config.Slug, "domain", d) + if err := repo.AddDomain(ctx, existing.ID, d); err != nil { + slog.Error("Failed to add domain", "error", err) + } + } + } + continue + } + + slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug) + _, err = svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains) + if err != nil { + slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) + return err + } + } + return nil +} diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index b87fdf9b..34676c02 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -70,9 +70,10 @@ type UserProfileResponse struct { Email string `json:"email"` Name string `json:"name"` Phone string `json:"phone"` - Department string `json:"department"` - AffiliationType string `json:"affiliationType"` - CompanyCode string `json:"companyCode,omitempty"` + Department string `json:"department"` + AffiliationType string `json:"affiliationType"` + CompanyCode string `json:"companyCode,omitempty"` + Tenant *Tenant `json:"tenant,omitempty"` } type UpdateUserRequest struct { diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go index e6a2f99a..52acc9c0 100644 --- a/backend/internal/domain/tenant.go +++ b/backend/internal/domain/tenant.go @@ -14,6 +14,7 @@ type Tenant struct { Slug string `gorm:"uniqueIndex;not null" json:"slug"` Description string `json:"description"` Status string `gorm:"default:'active'" json:"status"` + Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` diff --git a/backend/internal/domain/tenant_domain.go b/backend/internal/domain/tenant_domain.go new file mode 100644 index 00000000..ef3706a7 --- /dev/null +++ b/backend/internal/domain/tenant_domain.go @@ -0,0 +1,27 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// TenantDomain represents a domain associated with a tenant for auto-assignment. +type TenantDomain struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"` + Domain string `gorm:"uniqueIndex;not null" json:"domain"` // e.g. "example.com" + Verified bool `gorm:"default:false" json:"verified"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// BeforeCreate hook to generate UUID if not present. +func (td *TenantDomain) BeforeCreate(tx *gorm.DB) (err error) { + if td.ID == "" { + td.ID = uuid.NewString() + } + return +} diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 68b45d82..4ef96583 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -17,6 +17,8 @@ type User struct { Role string `gorm:"default:'user'" json:"role"` // 'admin', 'user' AffiliationType string `json:"affiliationType"` CompanyCode string `json:"companyCode"` + TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"` + Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` Department string `json:"department"` Status string `gorm:"default:'active'" json:"status"` CreatedAt time.Time `json:"createdAt"` diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 651d664b..e47d9268 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3,6 +3,7 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/logger" + "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "bytes" @@ -78,6 +79,8 @@ type AuthHandler struct { IdpProvider domain.IdentityProvider AuditRepo domain.AuditRepository Hydra *service.HydraAdminService + TenantService service.TenantService + UserRepo repository.UserRepository } type signupState struct { @@ -135,7 +138,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du return false, int(interval.Seconds()) } -func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository) *AuthHandler { +func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") @@ -161,6 +164,8 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident IdpProvider: idpProvider, AuditRepo: auditRepo, Hydra: service.NewHydraAdminService(), + TenantService: tenantService, + UserRepo: userRepo, } } @@ -357,6 +362,9 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { if req.Email == "" || req.Password == "" || req.Name == "" || req.Phone == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing required fields"}) } + if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email format"}) + } if !req.TermsAccepted { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Terms must be accepted"}) } @@ -385,6 +393,20 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"}) } + // [New] Auto-Assign Tenant by Domain + companyCode := req.CompanyCode + if companyCode == "" { + parts := strings.Split(req.Email, "@") + if len(parts) == 2 { + domainName := parts[1] + tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName) + if err == nil && tenant != nil { + slog.Info("[Signup] Auto-assigning tenant", "email", req.Email, "tenant", tenant.Slug) + companyCode = tenant.Slug + } + } + } + // Normalize Phone (E.164 형태로 보관) normalizedPhone := strings.ReplaceAll(req.Phone, "-", "") normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "") @@ -398,7 +420,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { attributes := map[string]interface{}{ "department": req.Department, "affiliationType": req.AffiliationType, - "companyCode": req.CompanyCode, + "companyCode": companyCode, // grade는 기존 스키마 필수 키이므로 기본값을 설정 "grade": "member", } @@ -427,6 +449,31 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID) + // [New] Local DB Sync + localUser := &domain.User{ + ID: providerID, // Match IDP Subject + Email: req.Email, + Name: req.Name, + Phone: normalizedPhone, + AffiliationType: req.AffiliationType, + CompanyCode: companyCode, + Department: req.Department, + Role: "user", + Status: "active", + } + + // Link TenantID if possible + if companyCode != "" { + if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), companyCode); err == nil && tenant != nil { + localUser.TenantID = &tenant.ID + } + } + + if err := h.UserRepo.Create(c.Context(), localUser); err != nil { + slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err) + // We don't fail the whole signup if local sync fails + } + return c.JSON(fiber.Map{ "success": true, "message": "User registered successfully", @@ -2057,6 +2104,13 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error { AffiliationType: affType, CompanyCode: compCode, } + + if compCode != "" { + if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode); err == nil && tenant != nil { + resp.Tenant = tenant + } + } + return c.JSON(resp) } } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index d19a5fb1..f9b04ad1 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -2,6 +2,7 @@ package handler import ( "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" "errors" "strings" "time" @@ -11,21 +12,23 @@ import ( ) type TenantHandler struct { - DB *gorm.DB + DB *gorm.DB + Service service.TenantService } -func NewTenantHandler(db *gorm.DB) *TenantHandler { - return &TenantHandler{DB: db} +func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler { + return &TenantHandler{DB: db, Service: svc} } type tenantSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - Status string `json:"status"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status string `json:"status"` + Domains []string `json:"domains,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } type tenantListResponse struct { @@ -55,7 +58,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { } var tenants []domain.Tenant - if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Find(&tenants).Error; err != nil { + if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } @@ -78,7 +81,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { } var tenant domain.Tenant - if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil { + if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) } @@ -94,10 +97,11 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { } var req struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - Status string `json:"status"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status string `json:"status"` + Domains []string `json:"domains"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) @@ -121,25 +125,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { status = "active" } - var exists domain.Tenant - if err := h.DB.Unscoped().Where("slug = ?", slug).First(&exists).Error; err == nil { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "slug already exists"}) - } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + // Use Service + tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains) + if err != nil { + if strings.Contains(err.Error(), "already exists") { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) + } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - tenant := domain.Tenant{ - Name: name, - Slug: slug, - Description: strings.TrimSpace(req.Description), - Status: status, - } - - if err := h.DB.Create(&tenant).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(tenant)) + return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant)) } func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { @@ -161,10 +156,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { } var req struct { - Name *string `json:"name"` - Slug *string `json:"slug"` - Description *string `json:"description"` - Status *string `json:"status"` + Name *string `json:"name"` + Slug *string `json:"slug"` + Description *string `json:"description"` + Status *string `json:"status"` + Domains []string `json:"domains"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) @@ -207,6 +203,32 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + // Update domains if provided + if req.Domains != nil { + // Simple approach: Delete existing and recreate + if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to clear old domains"}) + } + for _, d := range req.Domains { + if strings.TrimSpace(d) == "" { + continue + } + td := domain.TenantDomain{ + TenantID: tenant.ID, + Domain: strings.TrimSpace(d), + Verified: true, + } + if err := h.DB.Create(&td).Error; err != nil { + // Log and continue or return error? + // For now return error to be safe. + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to add domain: " + d}) + } + } + } + + // Refetch to get updated relations + h.DB.Preload("Domains").First(&tenant, "id = ?", tenant.ID) + return c.JSON(mapTenantSummary(tenant)) } @@ -228,12 +250,18 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { } func mapTenantSummary(t domain.Tenant) tenantSummary { + domains := make([]string, 0, len(t.Domains)) + for _, d := range t.Domains { + domains = append(domains, d.Domain) + } + return tenantSummary{ ID: t.ID, Name: t.Name, Slug: t.Slug, Description: t.Description, Status: t.Status, + Domains: domains, CreatedAt: t.CreatedAt.Format(time.RFC3339), UpdatedAt: t.UpdatedAt.Format(time.RFC3339), } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 1769291a..ab3a6f91 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -4,6 +4,8 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" + "context" + "log/slog" "strings" "time" @@ -11,29 +13,32 @@ import ( ) type UserHandler struct { - KratosAdmin *service.KratosAdminService - OryProvider *service.OryProvider + KratosAdmin *service.KratosAdminService + OryProvider *service.OryProvider + TenantService service.TenantService } -func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider) *UserHandler { +func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService) *UserHandler { return &UserHandler{ - KratosAdmin: kratosAdmin, - OryProvider: oryProvider, + KratosAdmin: kratosAdmin, + OryProvider: oryProvider, + TenantService: tenantService, } } type userSummary struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role"` - Status string `json:"status"` - CompanyCode string `json:"companyCode"` - Department string `json:"department"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - InitialPassword string `json:"initialPassword,omitempty"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + Status string `json:"status"` + CompanyCode string `json:"companyCode"` + Tenant *domain.Tenant `json:"tenant,omitempty"` + Department string `json:"department"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + InitialPassword string `json:"initialPassword,omitempty"` } type userListResponse struct { @@ -89,7 +94,8 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { items := make([]userSummary, 0, end-offset) for _, identity := range filtered[offset:end] { - items = append(items, mapIdentitySummary(identity)) + summary := h.mapIdentitySummary(c.Context(), identity) + items = append(items, summary) } return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) @@ -113,7 +119,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) } - return c.JSON(mapIdentitySummary(*identity)) + return c.JSON(h.mapIdentitySummary(c.Context(), *identity)) } func (h *UserHandler) CreateUser(c *fiber.Ctx) error { @@ -138,6 +144,9 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { if email == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"}) } + if !strings.Contains(email, "@") || !strings.Contains(email, ".") { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid email format"}) + } name := strings.TrimSpace(req.Name) if name == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) @@ -205,7 +214,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword}) } - response := mapIdentitySummary(*identity) + response := h.mapIdentitySummary(c.Context(), *identity) if generatedPassword != "" { response.InitialPassword = generatedPassword } @@ -279,7 +288,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } } - return c.JSON(mapIdentitySummary(*updated)) + return c.JSON(h.mapIdentitySummary(c.Context(), *updated)) } func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { @@ -299,24 +308,35 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } -func mapIdentitySummary(identity service.KratosIdentity) userSummary { +func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary { traits := identity.Traits role := extractTraitString(traits, "grade") if role == "" { role = "user" } - return userSummary{ + + compCode := extractTraitString(traits, "companyCode") + slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode) + summary := userSummary{ ID: identity.ID, Email: extractTraitString(traits, "email"), Name: extractTraitString(traits, "name"), Phone: extractTraitString(traits, "phone_number"), Role: role, Status: normalizeStatus(identity.State), - CompanyCode: extractTraitString(traits, "companyCode"), + CompanyCode: compCode, Department: extractTraitString(traits, "department"), CreatedAt: formatTime(identity.CreatedAt), UpdatedAt: formatTime(identity.UpdatedAt), } + + if compCode != "" && h.TenantService != nil { + if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil { + summary.Tenant = tenant + } + } + + return summary } func extractTraitString(traits map[string]interface{}, key string) string { diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go new file mode 100644 index 00000000..4e9c785f --- /dev/null +++ b/backend/internal/repository/tenant_repository.go @@ -0,0 +1,66 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + + "gorm.io/gorm" +) + +type TenantRepository interface { + Create(ctx context.Context, tenant *domain.Tenant) error + FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) + FindByName(ctx context.Context, name string) (*domain.Tenant, error) + FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) + AddDomain(ctx context.Context, tenantID string, domainName string) error +} + +type tenantRepository struct { + db *gorm.DB +} + +func NewTenantRepository(db *gorm.DB) TenantRepository { + return &tenantRepository{db: db} +} + +func (r *tenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error { + return r.db.WithContext(ctx).Create(tenant).Error +} + +func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + var tenant domain.Tenant + if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil { + return nil, err + } + return &tenant, nil +} + +func (r *tenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) { + var tenant domain.Tenant + if err := r.db.WithContext(ctx).Preload("Domains").Where("name = ?", name).First(&tenant).Error; err != nil { + return nil, err + } + return &tenant, nil +} + +func (r *tenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + var tenantDomain domain.TenantDomain + if err := r.db.WithContext(ctx).Where("domain = ?", domainName).First(&tenantDomain).Error; err != nil { + return nil, err + } + + var tenant domain.Tenant + if err := r.db.WithContext(ctx).Preload("Domains").First(&tenant, "id = ?", tenantDomain.TenantID).Error; err != nil { + return nil, err + } + return &tenant, nil +} + +func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error { + td := domain.TenantDomain{ + TenantID: tenantID, + Domain: domainName, + Verified: true, // Auto-verify for internal init/admin usage for now + } + return r.db.WithContext(ctx).Create(&td).Error +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go new file mode 100644 index 00000000..77c6f15a --- /dev/null +++ b/backend/internal/repository/user_repository.go @@ -0,0 +1,56 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + + "gorm.io/gorm" +) + +type UserRepository interface { + Create(ctx context.Context, user *domain.User) error + Update(ctx context.Context, user *domain.User) error + FindByEmail(ctx context.Context, email string) (*domain.User, error) + FindByID(ctx context.Context, id string) (*domain.User, error) + ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) +} + +type userRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) UserRepository { + return &userRepository{db: db} +} + +func (r *userRepository) Create(ctx context.Context, user *domain.User) error { + return r.db.WithContext(ctx).Create(user).Error +} + +func (r *userRepository) Update(ctx context.Context, user *domain.User) error { + return r.db.WithContext(ctx).Save(user).Error +} + +func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + var user domain.User + if err := r.db.WithContext(ctx).Preload("Tenant").Where("email = ?", email).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *userRepository) FindByID(ctx context.Context, id string) (*domain.User, error) { + var user domain.User + if err := r.db.WithContext(ctx).Preload("Tenant").Where("id = ?", id).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { + var users []domain.User + if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go new file mode 100644 index 00000000..79bb68de --- /dev/null +++ b/backend/internal/service/tenant_service.go @@ -0,0 +1,66 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "context" + "errors" + "log/slog" + + "gorm.io/gorm" +) + +type TenantService interface { + RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) + GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) + GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) +} + +type tenantService struct { + repo repository.TenantRepository +} + +func NewTenantService(repo repository.TenantRepository) TenantService { + return &tenantService{repo: repo} +} + +func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) { + // 1. Check if slug exists + existing, err := s.repo.FindBySlug(ctx, slug) + if err == nil && existing != nil { + return nil, errors.New("tenant slug already exists") + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + // 2. Create Tenant + tenant := &domain.Tenant{ + Name: name, + Slug: slug, + Description: description, + Status: "active", + } + + if err := s.repo.Create(ctx, tenant); err != nil { + return nil, err + } + + // 3. Add Domains + for _, d := range domains { + if err := s.repo.AddDomain(ctx, tenant.ID, d); err != nil { + slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err) + // Continue adding other domains? Or fail? For now, log and continue. + } + } + + return s.repo.FindBySlug(ctx, slug) // Return with preloaded domains +} + +func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { + return s.repo.FindByDomain(ctx, emailDomain) +} + +func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + return s.repo.FindBySlug(ctx, slug) +} diff --git a/userfront/lib/features/profile/data/models/user_profile_model.dart b/userfront/lib/features/profile/data/models/user_profile_model.dart index 5efe4bea..ed12c4ba 100644 --- a/userfront/lib/features/profile/data/models/user_profile_model.dart +++ b/userfront/lib/features/profile/data/models/user_profile_model.dart @@ -1,3 +1,35 @@ +class Tenant { + final String id; + final String name; + final String slug; + final String description; + + Tenant({ + required this.id, + required this.name, + required this.slug, + required this.description, + }); + + factory Tenant.fromJson(Map json) { + return Tenant( + id: json['id'] ?? '', + name: json['name'] ?? '', + slug: json['slug'] ?? '', + description: json['description'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'slug': slug, + 'description': description, + }; + } +} + class UserProfile { final String id; final String email; @@ -6,6 +38,7 @@ class UserProfile { final String department; final String affiliationType; final String companyCode; + final Tenant? tenant; UserProfile({ required this.id, @@ -15,6 +48,7 @@ class UserProfile { required this.department, required this.affiliationType, required this.companyCode, + this.tenant, }); factory UserProfile.fromJson(Map json) { @@ -26,6 +60,7 @@ class UserProfile { department: json['department'] ?? '', affiliationType: json['affiliationType'] ?? '', companyCode: json['companyCode'] ?? '', + tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null, ); } @@ -38,6 +73,7 @@ class UserProfile { 'department': department, 'affiliationType': affiliationType, 'companyCode': companyCode, + 'tenant': tenant?.toJson(), }; } @@ -54,6 +90,7 @@ class UserProfile { department: department ?? this.department, affiliationType: affiliationType, companyCode: companyCode, + tenant: tenant, ); } } diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 1e86f822..ea467f4a 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -503,7 +503,7 @@ class _ProfilePageState extends ConsumerState { runSpacing: 8, children: [ _buildInfoChip(Icons.badge_outlined, '프로필 관리'), - _buildInfoChip(Icons.apartment, department), + _buildInfoChip(Icons.apartment, profile.tenant?.name ?? department), ], ), ], @@ -743,6 +743,10 @@ class _ProfilePageState extends ConsumerState { ), const Divider(height: 24), _buildReadOnlyTile('구분', profile.affiliationType), + if (profile.tenant != null) ...[ + const Divider(height: 24), + _buildReadOnlyTile('소속 테넌트', profile.tenant!.name), + ], if (profile.companyCode.isNotEmpty) ...[ const Divider(height: 24), _buildReadOnlyTile('회사코드', profile.companyCode),