forked from baron/baron-sso
테넌트 등록 방식을 결정
This commit is contained in:
@@ -23,6 +23,7 @@ function TenantCreatePage() {
|
|||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("active");
|
const [status, setStatus] = useState("active");
|
||||||
|
const [domains, setDomains] = useState("");
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -31,6 +32,10 @@ function TenantCreatePage() {
|
|||||||
slug: slug || undefined,
|
slug: slug || undefined,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
status,
|
status,
|
||||||
|
domains: domains
|
||||||
|
.split(",")
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter((d) => d !== ""),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
navigate("/tenants");
|
navigate("/tenants");
|
||||||
@@ -92,6 +97,20 @@ function TenantCreatePage() {
|
|||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
Allowed Domains (Comma separated)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={domains}
|
||||||
|
onChange={(e) => setDomains(e.target.value)}
|
||||||
|
placeholder="example.com, example.kr"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Users with these email domains will be automatically assigned to
|
||||||
|
this tenant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold">Status</Label>
|
<Label className="text-sm font-semibold">Status</Label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function TenantProfilePage() {
|
|||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("active");
|
const [status, setStatus] = useState("active");
|
||||||
|
const [domains, setDomains] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tenantQuery.data) {
|
if (tenantQuery.data) {
|
||||||
@@ -44,6 +45,7 @@ export function TenantProfilePage() {
|
|||||||
setSlug(tenantQuery.data.slug);
|
setSlug(tenantQuery.data.slug);
|
||||||
setDescription(tenantQuery.data.description ?? "");
|
setDescription(tenantQuery.data.description ?? "");
|
||||||
setStatus(tenantQuery.data.status);
|
setStatus(tenantQuery.data.status);
|
||||||
|
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||||
}
|
}
|
||||||
}, [tenantQuery.data]);
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
@@ -54,6 +56,10 @@ export function TenantProfilePage() {
|
|||||||
slug,
|
slug,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
status,
|
status,
|
||||||
|
domains: domains
|
||||||
|
.split(",")
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter((d) => d !== ""),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
navigate("/tenants");
|
navigate("/tenants");
|
||||||
@@ -111,6 +117,20 @@ export function TenantProfilePage() {
|
|||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
Allowed Domains (Comma separated)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={domains}
|
||||||
|
onChange={(e) => setDomains(e.target.value)}
|
||||||
|
placeholder="example.com, example.kr"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Users with these email domains will be automatically assigned to
|
||||||
|
this tenant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold">Status</Label>
|
<Label className="text-sm font-semibold">Status</Label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -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 type { AxiosError } from "axios";
|
||||||
import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -16,6 +16,7 @@ import { Input } from "../../components/ui/input";
|
|||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
|
fetchTenants,
|
||||||
type UserCreateRequest,
|
type UserCreateRequest,
|
||||||
type UserCreateResponse,
|
type UserCreateResponse,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
@@ -28,6 +29,12 @@ function UserCreatePage() {
|
|||||||
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||||
|
|
||||||
|
const { data: tenantsData } = useQuery({
|
||||||
|
queryKey: ["tenants", { limit: 100 }],
|
||||||
|
queryFn: () => fetchTenants(100, 0),
|
||||||
|
});
|
||||||
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -207,12 +214,20 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="companyCode">회사 코드</Label>
|
<Label htmlFor="companyCode">테넌트 (Tenant)</Label>
|
||||||
<Input
|
<div className="relative">
|
||||||
id="companyCode"
|
<select
|
||||||
placeholder="HMAC"
|
id="companyCode"
|
||||||
{...register("companyCode")}
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
{...register("companyCode")}
|
||||||
|
>
|
||||||
|
<option value="">시스템 전역 (소속 없음)</option>
|
||||||
|
{tenants.map((t) => ( <option key={t.id} value={t.slug}>
|
||||||
|
{t.name} ({t.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
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() {
|
function UserDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -29,6 +34,12 @@ function UserDetailPage() {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: tenantsData } = useQuery({
|
||||||
|
queryKey: ["tenants", { limit: 100 }],
|
||||||
|
queryFn: () => fetchTenants(100, 0),
|
||||||
|
});
|
||||||
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -191,12 +202,20 @@ function UserDetailPage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="companyCode">회사 코드</Label>
|
<Label htmlFor="companyCode">테넌트 (Tenant)</Label>
|
||||||
<Input
|
<div className="relative">
|
||||||
id="companyCode"
|
<select
|
||||||
placeholder="HMAC"
|
id="companyCode"
|
||||||
{...register("companyCode")}
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
{...register("companyCode")}
|
||||||
|
>
|
||||||
|
<option value="">시스템 전역 (소속 없음)</option>
|
||||||
|
{tenants.map((t) => ( <option key={t.id} value={t.slug}>
|
||||||
|
{t.name} ({t.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ function UserListPage() {
|
|||||||
const total = query.data?.total ?? 0;
|
const total = query.data?.total ?? 0;
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
console.log("User items:", items);
|
||||||
|
}
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
const handleDelete = (userId: string, userName: string) => {
|
const handleDelete = (userId: string, userName: string) => {
|
||||||
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
|
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
|
||||||
return;
|
return;
|
||||||
@@ -151,7 +157,7 @@ function UserListPage() {
|
|||||||
<TableHead>NAME / EMAIL</TableHead>
|
<TableHead>NAME / EMAIL</TableHead>
|
||||||
<TableHead>ROLE</TableHead>
|
<TableHead>ROLE</TableHead>
|
||||||
<TableHead>STATUS</TableHead>
|
<TableHead>STATUS</TableHead>
|
||||||
<TableHead>COMPANY / DEPT</TableHead>
|
<TableHead>TENANT / DEPT</TableHead>
|
||||||
<TableHead>CREATED</TableHead>
|
<TableHead>CREATED</TableHead>
|
||||||
<TableHead className="text-right">ACTIONS</TableHead>
|
<TableHead className="text-right">ACTIONS</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -200,7 +206,14 @@ function UserListPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col text-sm">
|
<div className="flex flex-col text-sm">
|
||||||
<span>{user.companyCode || "-"}</span>
|
<span className="font-medium text-blue-600">
|
||||||
|
{user.tenant?.name || user.companyCode || "-"}
|
||||||
|
</span>
|
||||||
|
{user.tenant && (
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase">
|
||||||
|
Slug: {user.tenant.slug}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{user.department || "-"}
|
{user.department || "-"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type TenantSummary = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
description: string;
|
description: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
domains?: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -34,6 +35,7 @@ export type TenantCreateRequest = {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
domains?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantListResponse = {
|
export type TenantListResponse = {
|
||||||
@@ -48,6 +50,7 @@ export type TenantUpdateRequest = {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
domains?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiKeySummary = {
|
export type ApiKeySummary = {
|
||||||
@@ -168,6 +171,7 @@ export type UserSummary = {
|
|||||||
role: string;
|
role: string;
|
||||||
status: string;
|
status: string;
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
|
tenant?: TenantSummary;
|
||||||
department?: string;
|
department?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -223,14 +223,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Initialize Handlers
|
// 2. Initialize Handlers
|
||||||
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
|
tenantService := service.NewTenantService(tenantRepo)
|
||||||
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, userRepo)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
devHandler := handler.NewDevHandler()
|
devHandler := handler.NewDevHandler()
|
||||||
tenantHandler := handler.NewTenantHandler(db)
|
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
||||||
kratosAdminService := service.NewKratosAdminService()
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
oryAdminProvider := service.NewOryProvider()
|
oryAdminProvider := service.NewOryProvider()
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService)
|
||||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||||
|
|
||||||
// 3. Initialize Fiber
|
// 3. Initialize Fiber
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ func Run(db *gorm.DB) error {
|
|||||||
return fmt.Errorf("migration failed: %w", err)
|
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] User seed skipped (Kratos is SoT)")
|
||||||
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
||||||
return nil
|
return nil
|
||||||
@@ -27,6 +32,8 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
// Add all domain models here
|
// Add all domain models here
|
||||||
return db.AutoMigrate(
|
return db.AutoMigrate(
|
||||||
&domain.Tenant{},
|
&domain.Tenant{},
|
||||||
|
&domain.TenantDomain{},
|
||||||
|
&domain.User{},
|
||||||
&domain.ApiKey{},
|
&domain.ApiKey{},
|
||||||
&domain.IdentityProviderConfig{},
|
&domain.IdentityProviderConfig{},
|
||||||
// &domain.RelyingParty{}, // TODO: Uncomment when model is ready
|
// &domain.RelyingParty{}, // TODO: Uncomment when model is ready
|
||||||
|
|||||||
66
backend/internal/bootstrap/tenant_seed.go
Normal file
66
backend/internal/bootstrap/tenant_seed.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -70,9 +70,10 @@ type UserProfileResponse struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
AffiliationType string `json:"affiliationType"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
CompanyCode string `json:"companyCode,omitempty"`
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
|
Tenant *Tenant `json:"tenant,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Tenant struct {
|
|||||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `gorm:"default:'active'" json:"status"`
|
Status string `gorm:"default:'active'" json:"status"`
|
||||||
|
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|||||||
27
backend/internal/domain/tenant_domain.go
Normal file
27
backend/internal/domain/tenant_domain.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ type User struct {
|
|||||||
Role string `gorm:"default:'user'" json:"role"` // 'admin', 'user'
|
Role string `gorm:"default:'user'" json:"role"` // 'admin', 'user'
|
||||||
AffiliationType string `json:"affiliationType"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
CompanyCode string `json:"companyCode"`
|
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"`
|
Department string `json:"department"`
|
||||||
Status string `gorm:"default:'active'" json:"status"`
|
Status string `gorm:"default:'active'" json:"status"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/logger"
|
"baron-sso-backend/internal/logger"
|
||||||
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -78,6 +79,8 @@ type AuthHandler struct {
|
|||||||
IdpProvider domain.IdentityProvider
|
IdpProvider domain.IdentityProvider
|
||||||
AuditRepo domain.AuditRepository
|
AuditRepo domain.AuditRepository
|
||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
|
TenantService service.TenantService
|
||||||
|
UserRepo repository.UserRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
type signupState struct {
|
type signupState struct {
|
||||||
@@ -135,7 +138,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
|
|||||||
return false, int(interval.Seconds())
|
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")
|
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
||||||
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
||||||
|
|
||||||
@@ -161,6 +164,8 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
|
|||||||
IdpProvider: idpProvider,
|
IdpProvider: idpProvider,
|
||||||
AuditRepo: auditRepo,
|
AuditRepo: auditRepo,
|
||||||
Hydra: service.NewHydraAdminService(),
|
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 == "" {
|
if req.Email == "" || req.Password == "" || req.Name == "" || req.Phone == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing required fields"})
|
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 {
|
if !req.TermsAccepted {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Terms must be accepted"})
|
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"})
|
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 형태로 보관)
|
// Normalize Phone (E.164 형태로 보관)
|
||||||
normalizedPhone := strings.ReplaceAll(req.Phone, "-", "")
|
normalizedPhone := strings.ReplaceAll(req.Phone, "-", "")
|
||||||
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
|
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
|
||||||
@@ -398,7 +420,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
attributes := map[string]interface{}{
|
attributes := map[string]interface{}{
|
||||||
"department": req.Department,
|
"department": req.Department,
|
||||||
"affiliationType": req.AffiliationType,
|
"affiliationType": req.AffiliationType,
|
||||||
"companyCode": req.CompanyCode,
|
"companyCode": companyCode,
|
||||||
// grade는 기존 스키마 필수 키이므로 기본값을 설정
|
// grade는 기존 스키마 필수 키이므로 기본값을 설정
|
||||||
"grade": "member",
|
"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)
|
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{
|
return c.JSON(fiber.Map{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "User registered successfully",
|
"message": "User registered successfully",
|
||||||
@@ -2057,6 +2104,13 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
|||||||
AffiliationType: affType,
|
AffiliationType: affType,
|
||||||
CompanyCode: compCode,
|
CompanyCode: compCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if compCode != "" {
|
||||||
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode); err == nil && tenant != nil {
|
||||||
|
resp.Tenant = tenant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(resp)
|
return c.JSON(resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,21 +12,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TenantHandler struct {
|
type TenantHandler struct {
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
|
Service service.TenantService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTenantHandler(db *gorm.DB) *TenantHandler {
|
func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler {
|
||||||
return &TenantHandler{DB: db}
|
return &TenantHandler{DB: db, Service: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantSummary struct {
|
type tenantSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt string `json:"createdAt"`
|
Domains []string `json:"domains,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantListResponse struct {
|
type tenantListResponse struct {
|
||||||
@@ -55,7 +58,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tenants []domain.Tenant
|
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()})
|
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
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
|
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 {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
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"
|
status = "active"
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists domain.Tenant
|
// Use Service
|
||||||
if err := h.DB.Unscoped().Where("slug = ?", slug).First(&exists).Error; err == nil {
|
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains)
|
||||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "slug already exists"})
|
if err != nil {
|
||||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
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()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
tenant := domain.Tenant{
|
return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||||
@@ -161,10 +156,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Slug *string `json:"slug"`
|
Slug *string `json:"slug"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
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()})
|
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))
|
return c.JSON(mapTenantSummary(tenant))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +250,18 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
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{
|
return tenantSummary{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Slug: t.Slug,
|
Slug: t.Slug,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Status: t.Status,
|
Status: t.Status,
|
||||||
|
Domains: domains,
|
||||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,29 +13,32 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserHandler struct {
|
type UserHandler struct {
|
||||||
KratosAdmin *service.KratosAdminService
|
KratosAdmin *service.KratosAdminService
|
||||||
OryProvider *service.OryProvider
|
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{
|
return &UserHandler{
|
||||||
KratosAdmin: kratosAdmin,
|
KratosAdmin: kratosAdmin,
|
||||||
OryProvider: oryProvider,
|
OryProvider: oryProvider,
|
||||||
|
TenantService: tenantService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type userSummary struct {
|
type userSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CompanyCode string `json:"companyCode"`
|
CompanyCode string `json:"companyCode"`
|
||||||
Department string `json:"department"`
|
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
Department string `json:"department"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
InitialPassword string `json:"initialPassword,omitempty"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
InitialPassword string `json:"initialPassword,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type userListResponse struct {
|
type userListResponse struct {
|
||||||
@@ -89,7 +94,8 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
items := make([]userSummary, 0, end-offset)
|
items := make([]userSummary, 0, end-offset)
|
||||||
for _, identity := range filtered[offset:end] {
|
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})
|
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.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 {
|
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||||
@@ -138,6 +144,9 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
if email == "" {
|
if email == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"})
|
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)
|
name := strings.TrimSpace(req.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
|
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})
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
||||||
}
|
}
|
||||||
|
|
||||||
response := mapIdentitySummary(*identity)
|
response := h.mapIdentitySummary(c.Context(), *identity)
|
||||||
if generatedPassword != "" {
|
if generatedPassword != "" {
|
||||||
response.InitialPassword = 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 {
|
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)
|
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
|
traits := identity.Traits
|
||||||
role := extractTraitString(traits, "grade")
|
role := extractTraitString(traits, "grade")
|
||||||
if role == "" {
|
if role == "" {
|
||||||
role = "user"
|
role = "user"
|
||||||
}
|
}
|
||||||
return userSummary{
|
|
||||||
|
compCode := extractTraitString(traits, "companyCode")
|
||||||
|
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
|
||||||
|
summary := userSummary{
|
||||||
ID: identity.ID,
|
ID: identity.ID,
|
||||||
Email: extractTraitString(traits, "email"),
|
Email: extractTraitString(traits, "email"),
|
||||||
Name: extractTraitString(traits, "name"),
|
Name: extractTraitString(traits, "name"),
|
||||||
Phone: extractTraitString(traits, "phone_number"),
|
Phone: extractTraitString(traits, "phone_number"),
|
||||||
Role: role,
|
Role: role,
|
||||||
Status: normalizeStatus(identity.State),
|
Status: normalizeStatus(identity.State),
|
||||||
CompanyCode: extractTraitString(traits, "companyCode"),
|
CompanyCode: compCode,
|
||||||
Department: extractTraitString(traits, "department"),
|
Department: extractTraitString(traits, "department"),
|
||||||
CreatedAt: formatTime(identity.CreatedAt),
|
CreatedAt: formatTime(identity.CreatedAt),
|
||||||
UpdatedAt: formatTime(identity.UpdatedAt),
|
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 {
|
func extractTraitString(traits map[string]interface{}, key string) string {
|
||||||
|
|||||||
66
backend/internal/repository/tenant_repository.go
Normal file
66
backend/internal/repository/tenant_repository.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
56
backend/internal/repository/user_repository.go
Normal file
56
backend/internal/repository/user_repository.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
66
backend/internal/service/tenant_service.go
Normal file
66
backend/internal/service/tenant_service.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> json) {
|
||||||
|
return Tenant(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
slug: json['slug'] ?? '',
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'slug': slug,
|
||||||
|
'description': description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class UserProfile {
|
class UserProfile {
|
||||||
final String id;
|
final String id;
|
||||||
final String email;
|
final String email;
|
||||||
@@ -6,6 +38,7 @@ class UserProfile {
|
|||||||
final String department;
|
final String department;
|
||||||
final String affiliationType;
|
final String affiliationType;
|
||||||
final String companyCode;
|
final String companyCode;
|
||||||
|
final Tenant? tenant;
|
||||||
|
|
||||||
UserProfile({
|
UserProfile({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -15,6 +48,7 @@ class UserProfile {
|
|||||||
required this.department,
|
required this.department,
|
||||||
required this.affiliationType,
|
required this.affiliationType,
|
||||||
required this.companyCode,
|
required this.companyCode,
|
||||||
|
this.tenant,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory UserProfile.fromJson(Map<String, dynamic> json) {
|
factory UserProfile.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -26,6 +60,7 @@ class UserProfile {
|
|||||||
department: json['department'] ?? '',
|
department: json['department'] ?? '',
|
||||||
affiliationType: json['affiliationType'] ?? '',
|
affiliationType: json['affiliationType'] ?? '',
|
||||||
companyCode: json['companyCode'] ?? '',
|
companyCode: json['companyCode'] ?? '',
|
||||||
|
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +73,7 @@ class UserProfile {
|
|||||||
'department': department,
|
'department': department,
|
||||||
'affiliationType': affiliationType,
|
'affiliationType': affiliationType,
|
||||||
'companyCode': companyCode,
|
'companyCode': companyCode,
|
||||||
|
'tenant': tenant?.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +90,7 @@ class UserProfile {
|
|||||||
department: department ?? this.department,
|
department: department ?? this.department,
|
||||||
affiliationType: affiliationType,
|
affiliationType: affiliationType,
|
||||||
companyCode: companyCode,
|
companyCode: companyCode,
|
||||||
|
tenant: tenant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
_buildInfoChip(Icons.badge_outlined, '프로필 관리'),
|
_buildInfoChip(Icons.badge_outlined, '프로필 관리'),
|
||||||
_buildInfoChip(Icons.apartment, department),
|
_buildInfoChip(Icons.apartment, profile.tenant?.name ?? department),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -743,6 +743,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile('구분', profile.affiliationType),
|
_buildReadOnlyTile('구분', profile.affiliationType),
|
||||||
|
if (profile.tenant != null) ...[
|
||||||
|
const Divider(height: 24),
|
||||||
|
_buildReadOnlyTile('소속 테넌트', profile.tenant!.name),
|
||||||
|
],
|
||||||
if (profile.companyCode.isNotEmpty) ...[
|
if (profile.companyCode.isNotEmpty) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile('회사코드', profile.companyCode),
|
_buildReadOnlyTile('회사코드', profile.companyCode),
|
||||||
|
|||||||
Reference in New Issue
Block a user