forked from baron/baron-sso
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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 /> },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
152
adminfront/src/features/tenants/routes/TenantSchemaPage.tsx
Normal file
152
adminfront/src/features/tenants/routes/TenantSchemaPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
backend/internal/domain/json_map.go
Normal file
42
backend/internal/domain/json_map.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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:"-"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"email"
|
"email"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user