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 TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
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 UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
@@ -28,7 +30,14 @@ export const router = createBrowserRouter(
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ 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/new", element: <ApiKeyCreatePage /> },
],

View File

@@ -60,6 +60,16 @@ function TenantDetailPage() {
>
Federation
</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>
{/* 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 {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<UserCreateRequest>({
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
defaultValues: {
email: "",
password: "",
@@ -48,9 +49,21 @@ function UserCreatePage() {
role: "user",
companyCode: "",
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({
mutationFn: createUser,
onSuccess: (data: UserCreateResponse) => {
@@ -212,35 +225,109 @@ function UserCreatePage() {
</div>
</div>
<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="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</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>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</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">
<Label htmlFor="role"> (Role)</Label>
<div className="relative">
<select

View File

@@ -44,8 +44,9 @@ function UserDetailPage() {
register,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<UserUpdateRequest>({
} = useForm<UserUpdateRequest & { metadata: Record<string, any> }>({
defaultValues: {
name: "",
phone: "",
@@ -54,9 +55,21 @@ function UserDetailPage() {
companyCode: "",
department: "",
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(() => {
if (user) {
reset({
@@ -67,6 +80,7 @@ function UserDetailPage() {
companyCode: user.companyCode || "",
department: user.department || "",
password: "",
metadata: user.metadata || {},
});
}
}, [user, reset]);
@@ -200,35 +214,109 @@ function UserDetailPage() {
</div>
</div>
<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="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
<div className="space-y-2">
<div className="border-t pt-4">
<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>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</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">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3>
<div className="space-y-2">
<Label htmlFor="password"> </Label>

View File

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

View File

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

View File

@@ -72,9 +72,10 @@ type UserProfileResponse struct {
Name string `json:"name"`
Phone string `json:"phone"`
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
}
type UpdateUserRequest struct {

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"`
Status string `gorm:"default:'active'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

View File

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

View File

@@ -2338,6 +2338,7 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
Department: dept,
AffiliationType: affType,
CompanyCode: compCode,
Metadata: userResponse.CustomAttributes,
}
if compCode != "" {
@@ -4132,7 +4133,20 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
Department: dept,
AffiliationType: affType,
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
}
@@ -4157,7 +4171,20 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
Department: dept,
AffiliationType: affType,
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
}

View File

@@ -21,14 +21,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler {
}
type tenantSummary struct {
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"`
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"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type tenantListResponse struct {
@@ -99,9 +100,10 @@ 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"`
Domains []string `json:"domains"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains"`
Config map[string]any `json:"config"`
}
if err := c.BodyParser(&req); err != nil {
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()})
}
if req.Config != nil {
tenant.Config = req.Config
h.DB.Save(tenant)
}
return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant))
}
@@ -158,9 +165,10 @@ 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"`
Domains []string `json:"domains"`
Description *string `json:"description"`
Status *string `json:"status"`
Domains []string `json:"domains"`
Config map[string]any `json:"config"`
}
if err := c.BodyParser(&req); err != nil {
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
}
if req.Config != nil {
tenant.Config = req.Config
}
if err := h.DB.Save(&tenant).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -262,6 +273,7 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
Description: t.Description,
Status: t.Status,
Domains: domains,
Config: t.Config,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"context"
@@ -16,13 +17,15 @@ type UserHandler struct {
KratosAdmin *service.KratosAdminService
OryProvider *service.OryProvider
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{
KratosAdmin: kratosAdmin,
OryProvider: oryProvider,
TenantService: tenantService,
UserRepo: userRepo,
}
}
@@ -34,6 +37,7 @@ type userSummary struct {
Role string `json:"role"`
Status string `json:"status"`
CompanyCode string `json:"companyCode"`
Metadata domain.JSONMap `json:"metadata,omitempty"`
Tenant *domain.Tenant `json:"tenant,omitempty"`
Department string `json:"department"`
CreatedAt string `json:"createdAt"`
@@ -128,13 +132,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
var req struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Metadata map[string]any `json:"metadata"`
}
if err := c.BodyParser(&req); err != nil {
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,
}
// 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{
Email: email,
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()})
}
// [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)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -240,13 +279,14 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
var req struct {
Password *string `json:"password"`
Name *string `json:"name"`
Phone *string `json:"phone"`
Role *string `json:"role"`
Status *string `json:"status"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Password *string `json:"password"`
Name *string `json:"name"`
Phone *string `json:"phone"`
Role *string `json:"role"`
Status *string `json:"status"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Metadata map[string]any `json:"metadata"`
}
if err := c.BodyParser(&req); err != nil {
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
}
// [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)
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
if err != nil {
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 err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
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),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Metadata: make(domain.JSONMap),
CreatedAt: formatTime(identity.CreatedAt),
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 tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
summary.Tenant = tenant

View File

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

View File

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