-
{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),