forked from baron/baron-sso
사용자 필드 관리
This commit is contained in:
@@ -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 */}
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user