1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/main'

This commit is contained in:
Lectom C Han
2026-02-03 08:34:46 +09:00
16 changed files with 636 additions and 91 deletions

View File

@@ -9,6 +9,8 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage"; import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import UserCreatePage from "../features/users/UserCreatePage"; import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage"; import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage"; import UserListPage from "../features/users/UserListPage";
@@ -28,7 +30,14 @@ export const router = createBrowserRouter(
{ path: "users/:id", element: <UserDetailPage /> }, { path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> }, { path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> }, { path: "tenants/new", element: <TenantCreatePage /> },
{ path: "tenants/:id", element: <TenantDetailPage /> }, {
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},
{ path: "api-keys", element: <ApiKeyListPage /> }, { path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> }, { path: "api-keys/new", element: <ApiKeyCreatePage /> },
], ],

View File

@@ -60,6 +60,16 @@ function TenantDetailPage() {
> >
Federation Federation
</Link> </Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${
location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Schema
</Link>
</div> </div>
{/* Outlet for nested routes */} {/* Outlet for nested routes */}

View File

@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
type SchemaField = {
key: string;
label: string;
type: "text" | "number" | "boolean";
required: boolean;
};
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
if (!tenantId) return <div>Tenant ID missing</div>;
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
});
const [fields, setFields] = useState<SchemaField[]>([]);
useEffect(() => {
if (tenantQuery.data?.config?.userSchema) {
setFields(tenantQuery.data.config.userSchema as SchemaField[]);
}
}, [tenantQuery.data]);
const updateMutation = useMutation({
mutationFn: (newFields: SchemaField[]) =>
updateTenant(tenantId, {
config: {
...tenantQuery.data?.config,
userSchema: newFields,
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Schema updated successfully");
},
onError: (err: AxiosError<{ error?: string }>) => {
alert(err.response?.data?.error || "Failed to update schema");
},
});
const addField = () => {
setFields([...fields, { key: "", label: "", type: "text", required: false }]);
};
const removeField = (index: number) => {
setFields(fields.filter((_, i) => i !== index));
};
const updateField = (index: number, updates: Partial<SchemaField>) => {
const newFields = [...fields];
newFields[index] = { ...newFields[index], ...updates };
setFields(newFields);
};
return (
<div className="space-y-6 mt-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>User Schema Extension</CardTitle>
<CardDescription>
Define custom attributes for users in this tenant.
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Plus size={16} className="mr-2" />
Add Field
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{fields.length === 0 && (
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
No custom fields defined. Click "Add Field" to begin.
</div>
)}
{fields.map((field, index) => (
<div key={index} className="flex items-end gap-4 p-4 border rounded-md bg-muted/30">
<div className="flex-1 space-y-2">
<Label>Field Key (ID)</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder="e.g. employee_id"
/>
</div>
<div className="flex-1 space-y-2">
<Label>Display Label</Label>
<Input
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
placeholder="e.g. 사번"
/>
</div>
<div className="w-32 space-y-2">
<Label>Type</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value as any })}
>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeField(index)}
>
<Trash2 size={16} />
</Button>
</div>
))}
</CardContent>
</Card>
<div className="flex justify-end">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading}
>
<Save size={16} className="mr-2" />
Save Schema Changes
</Button>
</div>
</div>
);
}

View File

@@ -38,8 +38,9 @@ function UserCreatePage() {
const { const {
register, register,
handleSubmit, handleSubmit,
watch,
formState: { errors }, formState: { errors },
} = useForm<UserCreateRequest>({ } = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
defaultValues: { defaultValues: {
email: "", email: "",
password: "", password: "",
@@ -48,9 +49,21 @@ function UserCreatePage() {
role: "user", role: "user",
companyCode: "", companyCode: "",
department: "", department: "",
metadata: {},
}, },
}); });
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
const mutation = useMutation({ const mutation = useMutation({
mutationFn: createUser, mutationFn: createUser,
onSuccess: (data: UserCreateResponse) => { onSuccess: (data: UserCreateResponse) => {
@@ -213,33 +226,107 @@ function UserCreatePage() {
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="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 className="space-y-2"> <div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="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 className="space-y-2">
<Label htmlFor="department"></Label> <Label htmlFor="department"></Label>
<Input <Input
id="department" id="department"
placeholder="개발팀" placeholder="개발팀"
{...register("department")} {...register("department")}
/> />
</div> </div>
</div> </div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="role"> (Role)</Label> <Label htmlFor="role"> (Role)</Label>
<div className="relative"> <div className="relative">

View File

@@ -44,8 +44,9 @@ function UserDetailPage() {
register, register,
handleSubmit, handleSubmit,
reset, reset,
watch,
formState: { errors }, formState: { errors },
} = useForm<UserUpdateRequest>({ } = useForm<UserUpdateRequest & { metadata: Record<string, any> }>({
defaultValues: { defaultValues: {
name: "", name: "",
phone: "", phone: "",
@@ -54,9 +55,21 @@ function UserDetailPage() {
companyCode: "", companyCode: "",
department: "", department: "",
password: "", password: "",
metadata: {},
}, },
}); });
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
React.useEffect(() => { React.useEffect(() => {
if (user) { if (user) {
reset({ reset({
@@ -67,6 +80,7 @@ function UserDetailPage() {
companyCode: user.companyCode || "", companyCode: user.companyCode || "",
department: user.department || "", department: user.department || "",
password: "", password: "",
metadata: user.metadata || {},
}); });
} }
}, [user, reset]); }, [user, reset]);
@@ -201,33 +215,107 @@ function UserDetailPage() {
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="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 className="space-y-2"> <div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="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 className="space-y-2">
<Label htmlFor="department"></Label> <Label htmlFor="department"></Label>
<Input <Input
id="department" id="department"
placeholder="개발팀" placeholder="개발팀"
{...register("department")} {...register("department")}
/> />
</div> </div>
</div> </div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3> <h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -26,6 +26,7 @@ export type TenantSummary = {
description: string; description: string;
status: string; status: string;
domains?: string[]; domains?: string[];
config?: Record<string, any>;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -36,6 +37,7 @@ export type TenantCreateRequest = {
description?: string; description?: string;
status?: string; status?: string;
domains?: string[]; domains?: string[];
config?: Record<string, any>;
}; };
export type TenantListResponse = { export type TenantListResponse = {
@@ -51,6 +53,7 @@ export type TenantUpdateRequest = {
description?: string; description?: string;
status?: string; status?: string;
domains?: string[]; domains?: string[];
config?: Record<string, any>;
}; };
export type ApiKeySummary = { export type ApiKeySummary = {
@@ -172,6 +175,7 @@ export type UserSummary = {
status: string; status: string;
companyCode?: string; companyCode?: string;
tenant?: TenantSummary; tenant?: TenantSummary;
metadata?: Record<string, any>;
department?: string; department?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -248,7 +248,7 @@ func main() {
tenantHandler := handler.NewTenantHandler(db, tenantService) tenantHandler := handler.NewTenantHandler(db, tenantService)
kratosAdminService := service.NewKratosAdminService() kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider() oryAdminProvider := service.NewOryProvider()
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db) apiKeyHandler := handler.NewApiKeyHandler(db)
// 3. Initialize Fiber // 3. Initialize Fiber

View File

@@ -74,6 +74,7 @@ type UserProfileResponse struct {
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"`
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"` Tenant *Tenant `json:"tenant,omitempty"`
} }

View File

@@ -0,0 +1,42 @@
package domain
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
)
// JSONMap is a custom type for handling map[string]any with PostgreSQL JSONB
type JSONMap map[string]any
// Value implements the driver.Valuer interface
func (m JSONMap) Value() (driver.Value, error) {
if m == nil {
return nil, nil
}
ba, err := json.Marshal(m)
return string(ba), err
}
// Scan implements the sql.Scanner interface
func (m *JSONMap) Scan(value any) error {
if value == nil {
*m = make(JSONMap)
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New(fmt.Sprintf("failed to scan JSONMap: %v", value))
}
result := make(JSONMap)
err := json.Unmarshal(bytes, &result)
*m = result
return err
}

View File

@@ -15,6 +15,7 @@ type Tenant struct {
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"` Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,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:"-"`

View File

@@ -20,6 +20,7 @@ type User struct {
TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"` TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"`
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
Department string `json:"department"` Department string `json:"department"`
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
Status string `gorm:"default:'active'" json:"status"` Status string `gorm:"default:'active'" json:"status"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`

View File

@@ -2338,6 +2338,7 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
Department: dept, Department: dept,
AffiliationType: affType, AffiliationType: affType,
CompanyCode: compCode, CompanyCode: compCode,
Metadata: userResponse.CustomAttributes,
} }
if compCode != "" { if compCode != "" {
@@ -4132,7 +4133,20 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
Department: dept, Department: dept,
AffiliationType: affType, AffiliationType: affType,
CompanyCode: compCode, CompanyCode: compCode,
Metadata: make(map[string]any),
} }
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"affiliationType": true,
}
for k, v := range traits {
if !coreTraits[k] {
profile.Metadata[k] = v
}
}
return profile, nil return profile, nil
} }
@@ -4157,7 +4171,20 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
Department: dept, Department: dept,
AffiliationType: affType, AffiliationType: affType,
CompanyCode: compCode, CompanyCode: compCode,
Metadata: make(map[string]any),
} }
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"affiliationType": true,
}
for k, v := range traits {
if !coreTraits[k] {
profile.Metadata[k] = v
}
}
return profile, nil return profile, nil
} }

View File

@@ -27,6 +27,7 @@ type tenantSummary struct {
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
Domains []string `json:"domains,omitempty"` Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
} }
@@ -102,6 +103,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
Config map[string]any `json:"config"`
} }
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"})
@@ -134,6 +136,11 @@ func (h *TenantHandler) CreateTenant(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()})
} }
if req.Config != nil {
tenant.Config = req.Config
h.DB.Save(tenant)
}
return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant)) return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant))
} }
@@ -161,6 +168,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
Description *string `json:"description"` Description *string `json:"description"`
Status *string `json:"status"` Status *string `json:"status"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
Config map[string]any `json:"config"`
} }
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"})
@@ -198,6 +206,9 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
} }
tenant.Status = status tenant.Status = status
} }
if req.Config != nil {
tenant.Config = req.Config
}
if err := h.DB.Save(&tenant).Error; err != nil { if err := h.DB.Save(&tenant).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()})
@@ -262,6 +273,7 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
Description: t.Description, Description: t.Description,
Status: t.Status, Status: t.Status,
Domains: domains, Domains: domains,
Config: t.Config,
CreatedAt: t.CreatedAt.Format(time.RFC3339), CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339), UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
} }

View File

@@ -2,6 +2,7 @@ package handler
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"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"
"context" "context"
@@ -16,13 +17,15 @@ type UserHandler struct {
KratosAdmin *service.KratosAdminService KratosAdmin *service.KratosAdminService
OryProvider *service.OryProvider OryProvider *service.OryProvider
TenantService service.TenantService TenantService service.TenantService
UserRepo repository.UserRepository
} }
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService) *UserHandler { func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, userRepo repository.UserRepository) *UserHandler {
return &UserHandler{ return &UserHandler{
KratosAdmin: kratosAdmin, KratosAdmin: kratosAdmin,
OryProvider: oryProvider, OryProvider: oryProvider,
TenantService: tenantService, TenantService: tenantService,
UserRepo: userRepo,
} }
} }
@@ -34,6 +37,7 @@ type userSummary struct {
Role string `json:"role"` Role string `json:"role"`
Status string `json:"status"` Status string `json:"status"`
CompanyCode string `json:"companyCode"` CompanyCode string `json:"companyCode"`
Metadata domain.JSONMap `json:"metadata,omitempty"`
Tenant *domain.Tenant `json:"tenant,omitempty"` Tenant *domain.Tenant `json:"tenant,omitempty"`
Department string `json:"department"` Department string `json:"department"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
@@ -135,6 +139,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
Role string `json:"role"` Role string `json:"role"`
CompanyCode string `json:"companyCode"` CompanyCode string `json:"companyCode"`
Department string `json:"department"` Department string `json:"department"`
Metadata map[string]any `json:"metadata"`
} }
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"})
@@ -191,6 +196,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
"grade": role, "grade": role,
} }
// Merge custom metadata into attributes
for k, v := range req.Metadata {
// Don't overwrite core fields
if _, exists := attributes[k]; !exists {
attributes[k] = v
}
}
brokerUser := &domain.BrokerUser{ brokerUser := &domain.BrokerUser{
Email: email, Email: email,
Name: name, Name: name,
@@ -206,6 +219,32 @@ func (h *UserHandler) CreateUser(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()})
} }
// [New] Local DB Sync
localUser := &domain.User{
ID: identityID,
Email: email,
Name: name,
Phone: normalizePhoneNumber(req.Phone),
AffiliationType: "internal",
CompanyCode: req.CompanyCode,
Department: req.Department,
Role: role,
Status: "active",
Metadata: req.Metadata,
}
if req.CompanyCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
localUser.TenantID = &tenant.ID
}
}
if h.UserRepo != nil {
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
slog.Error("[UserHandler] Failed to sync user to local DB", "email", email, "error", err)
}
}
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID) identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -247,6 +286,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
Status *string `json:"status"` Status *string `json:"status"`
CompanyCode *string `json:"companyCode"` CompanyCode *string `json:"companyCode"`
Department *string `json:"department"` Department *string `json:"department"`
Metadata map[string]any `json:"metadata"`
} }
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"})
@@ -276,12 +316,66 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["grade"] = role traits["grade"] = role
} }
// [Refined] Metadata synchronization: replace non-core traits with new Metadata
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"affiliationType": true,
}
// 1. Remove existing non-core traits to handle deletions
for k := range traits {
if !coreTraits[k] {
delete(traits, k)
}
}
// 2. Add new metadata fields
for k, v := range req.Metadata {
if !coreTraits[k] {
traits[k] = v
}
}
state := normalizeKratosState(req.Status) state := normalizeKratosState(req.Status)
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state) updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// [New] Local DB Sync
if h.UserRepo != nil {
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil {
if req.Name != nil {
localUser.Name = *req.Name
}
if req.Phone != nil {
localUser.Phone = normalizePhoneNumber(*req.Phone)
}
if req.CompanyCode != nil {
localUser.CompanyCode = *req.CompanyCode
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
localUser.TenantID = &tenant.ID
}
}
if req.Department != nil {
localUser.Department = *req.Department
}
if req.Role != nil {
localUser.Role = *req.Role
}
if req.Status != nil {
localUser.Status = *req.Status
}
if req.Metadata != nil {
localUser.Metadata = req.Metadata
}
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", userID, "error", err)
}
}
}
if req.Password != nil && *req.Password != "" { if req.Password != nil && *req.Password != "" {
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil { if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -326,10 +420,23 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
Status: normalizeStatus(identity.State), Status: normalizeStatus(identity.State),
CompanyCode: compCode, CompanyCode: compCode,
Department: extractTraitString(traits, "department"), Department: extractTraitString(traits, "department"),
Metadata: make(domain.JSONMap),
CreatedAt: formatTime(identity.CreatedAt), CreatedAt: formatTime(identity.CreatedAt),
UpdatedAt: formatTime(identity.UpdatedAt), UpdatedAt: formatTime(identity.UpdatedAt),
} }
// Filter out core traits and put everything else in Metadata
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"affiliationType": true,
}
for k, v := range traits {
if !coreTraits[k] {
summary.Metadata[k] = v
}
}
if compCode != "" && h.TenantService != nil { if compCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil { if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
summary.Tenant = tenant summary.Tenant = tenant

View File

@@ -98,7 +98,7 @@
"required": [ "required": [
"email" "email"
], ],
"additionalProperties": false "additionalProperties": true
} }
} }
} }

View File

@@ -38,6 +38,7 @@ class UserProfile {
final String department; final String department;
final String affiliationType; final String affiliationType;
final String companyCode; final String companyCode;
final Map<String, dynamic>? metadata;
final Tenant? tenant; final Tenant? tenant;
UserProfile({ UserProfile({
@@ -48,6 +49,7 @@ class UserProfile {
required this.department, required this.department,
required this.affiliationType, required this.affiliationType,
required this.companyCode, required this.companyCode,
this.metadata,
this.tenant, this.tenant,
}); });
@@ -60,6 +62,7 @@ class UserProfile {
department: json['department'] ?? '', department: json['department'] ?? '',
affiliationType: json['affiliationType'] ?? '', affiliationType: json['affiliationType'] ?? '',
companyCode: json['companyCode'] ?? '', companyCode: json['companyCode'] ?? '',
metadata: json['metadata'] != null ? Map<String, dynamic>.from(json['metadata']) : null,
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null, tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
); );
} }
@@ -73,6 +76,7 @@ class UserProfile {
'department': department, 'department': department,
'affiliationType': affiliationType, 'affiliationType': affiliationType,
'companyCode': companyCode, 'companyCode': companyCode,
'metadata': metadata,
'tenant': tenant?.toJson(), 'tenant': tenant?.toJson(),
}; };
} }