forked from baron/baron-sso
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
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 { toast } from "../../../components/ui/use-toast";
|
|
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
|
import { t } from "../../../lib/i18n";
|
|
import { normalizeAdminRole } from "../../../lib/roles";
|
|
import {
|
|
createSchemaField,
|
|
isSchemaFieldType,
|
|
normalizeSchemaField,
|
|
type SchemaField,
|
|
} from "./tenantSchemaFields";
|
|
|
|
export function TenantSchemaPage() {
|
|
const { tenantId } = useParams<{ tenantId: string }>();
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: fetchMe,
|
|
});
|
|
|
|
const profileRole = normalizeAdminRole(profile?.role);
|
|
const canAccess = profileRole === "super_admin";
|
|
|
|
const tenantQuery = useQuery({
|
|
queryKey: ["tenant", tenantId],
|
|
queryFn: () => {
|
|
if (!tenantId) throw new Error("Tenant ID is required");
|
|
return fetchTenant(tenantId);
|
|
},
|
|
enabled: !!tenantId && canAccess,
|
|
});
|
|
|
|
const [fields, setFields] = useState<SchemaField[]>([]);
|
|
|
|
useEffect(() => {
|
|
const rawSchema = tenantQuery.data?.config?.userSchema;
|
|
|
|
if (Array.isArray(rawSchema)) {
|
|
setFields(rawSchema.map(normalizeSchemaField));
|
|
}
|
|
}, [tenantQuery.data]);
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: (newFields: SchemaField[]) => {
|
|
if (!tenantId) throw new Error("Tenant ID is required");
|
|
|
|
// Remove legacy loginIdField, keep isLoginId natively in userSchema
|
|
const newConfig = { ...tenantQuery.data?.config };
|
|
newConfig.loginIdField = undefined;
|
|
newConfig.userSchema = newFields;
|
|
|
|
return updateTenant(tenantId, {
|
|
config: newConfig,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
|
toast.success(
|
|
t(
|
|
"msg.admin.tenants.schema.update_success",
|
|
"스키마가 저장되었습니다.",
|
|
),
|
|
);
|
|
},
|
|
onError: (err: AxiosError<{ error?: string }>) => {
|
|
toast.error(
|
|
err.response?.data?.error ||
|
|
t("msg.admin.tenants.schema.update_error", "저장에 실패했습니다."),
|
|
);
|
|
},
|
|
});
|
|
|
|
if (isProfileLoading) {
|
|
return (
|
|
<div className="p-8 text-center animate-pulse text-muted-foreground">
|
|
{t("msg.common.loading", "로딩 중...")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!canAccess) {
|
|
return (
|
|
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
|
<h3 className="text-xl font-bold text-destructive">
|
|
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
|
</h3>
|
|
<p className="text-muted-foreground">
|
|
{t(
|
|
"msg.admin.tenants.schema.forbidden_desc",
|
|
"사용자 스키마 설정은 관리자만 접근할 수 있습니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!tenantId) {
|
|
return (
|
|
<div className="p-8 text-center text-muted-foreground">
|
|
{t("msg.admin.tenants.schema.missing_id", "테넌트 ID가 없습니다.")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const addField = () => {
|
|
setFields([...fields, createSchemaField()]);
|
|
};
|
|
|
|
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 className="border-none shadow-sm bg-[var(--color-panel)]">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<CardTitle className="text-2xl font-bold">
|
|
{t("ui.admin.tenants.schema.title", "사용자 스키마 확장")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.tenants.schema.subtitle",
|
|
"이 테넌트 사용자를 위한 커스텀 속성을 정의합니다.",
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
<Button onClick={addField} size="sm">
|
|
<Plus size={16} className="mr-2" />
|
|
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{fields.length === 0 && (
|
|
<div className="py-12 text-center text-muted-foreground border border-dashed rounded-lg bg-muted/10">
|
|
{t(
|
|
"msg.admin.tenants.schema.empty",
|
|
'정의된 커스텀 필드가 없습니다. "필드 추가"를 눌러 시작하세요.',
|
|
)}
|
|
</div>
|
|
)}
|
|
{fields.map((field, index) => (
|
|
<div
|
|
key={field.id}
|
|
className="p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors space-y-4"
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
|
|
</Label>
|
|
<Input
|
|
value={field.key}
|
|
onChange={(e) =>
|
|
updateField(index, { key: e.target.value })
|
|
}
|
|
placeholder={t(
|
|
"ui.admin.tenants.schema.field.key_placeholder",
|
|
"예: employee_id",
|
|
)}
|
|
className="h-10"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
|
|
</Label>
|
|
<Input
|
|
value={field.label}
|
|
onChange={(e) =>
|
|
updateField(index, { label: e.target.value })
|
|
}
|
|
placeholder={t(
|
|
"ui.admin.tenants.schema.field.label_placeholder",
|
|
"예: 사번",
|
|
)}
|
|
className="h-10"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
{t("ui.admin.tenants.schema.field.type", "유형")}
|
|
</Label>
|
|
<select
|
|
id={`tenant-schema-field-type-${field.key || index}`}
|
|
name={`tenant-schema-field-type-${field.key || index}`}
|
|
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
|
|
value={field.type}
|
|
onChange={(e) => {
|
|
const nextType = e.target.value;
|
|
if (isSchemaFieldType(nextType)) {
|
|
updateField(index, {
|
|
type: nextType,
|
|
isLoginId:
|
|
nextType === "text" ? field.isLoginId : false,
|
|
indexed:
|
|
nextType === "text"
|
|
? field.indexed || field.isLoginId || false
|
|
: field.indexed,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<option value="text">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.type_text",
|
|
"텍스트 (Text)",
|
|
)}
|
|
</option>
|
|
<option value="number">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.type_number",
|
|
"숫자 (Integer)",
|
|
)}
|
|
</option>
|
|
<option value="float">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.type_float",
|
|
"실수 (Float)",
|
|
)}
|
|
</option>
|
|
<option value="boolean">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.type_boolean",
|
|
"불리언 (Boolean)",
|
|
)}
|
|
</option>
|
|
<option value="date">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.type_date",
|
|
"날짜 (Date)",
|
|
)}
|
|
</option>
|
|
<option value="datetime">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.type_datetime",
|
|
"일시 (DateTime)",
|
|
)}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
name={`tenant-schema-field-required-${field.key || index}`}
|
|
type="checkbox"
|
|
checked={field.required}
|
|
onChange={(e) =>
|
|
updateField(index, { required: e.target.checked })
|
|
}
|
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
name={`tenant-schema-field-admin-only-${field.key || index}`}
|
|
type="checkbox"
|
|
checked={field.adminOnly}
|
|
onChange={(e) =>
|
|
updateField(index, { adminOnly: e.target.checked })
|
|
}
|
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.admin_only",
|
|
"관리자 전용",
|
|
)}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
name={`tenant-schema-field-login-id-${field.key || index}`}
|
|
type="checkbox"
|
|
checked={field.isLoginId || false}
|
|
onChange={(e) =>
|
|
updateField(index, {
|
|
isLoginId: e.target.checked,
|
|
indexed: e.target.checked ? true : field.indexed,
|
|
type: e.target.checked ? "text" : field.type,
|
|
})
|
|
}
|
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
|
/>
|
|
<span className="text-sm font-medium text-blue-600">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.is_login_id",
|
|
"로그인 ID로 사용",
|
|
)}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
name={`tenant-schema-field-indexed-${field.key || index}`}
|
|
type="checkbox"
|
|
checked={field.indexed || field.isLoginId || false}
|
|
disabled={field.isLoginId}
|
|
onChange={(e) =>
|
|
updateField(index, { indexed: e.target.checked })
|
|
}
|
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.indexed",
|
|
"검색 인덱스 필요",
|
|
)}
|
|
</span>
|
|
</label>
|
|
{(field.type === "number" || field.type === "float") && (
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
name={`tenant-schema-field-unsigned-${field.key || index}`}
|
|
type="checkbox"
|
|
checked={field.unsigned}
|
|
onChange={(e) =>
|
|
updateField(index, { unsigned: e.target.checked })
|
|
}
|
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{t(
|
|
"ui.admin.tenants.schema.field.unsigned",
|
|
"음수 불가",
|
|
)}
|
|
</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Input
|
|
value={field.validation}
|
|
onChange={(e) =>
|
|
updateField(index, { validation: e.target.value })
|
|
}
|
|
placeholder={t(
|
|
"ui.admin.tenants.schema.field.validation_placeholder",
|
|
"정규식 (예: ^[0-9]+$)",
|
|
)}
|
|
className="h-9 text-xs font-mono"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
|
onClick={() => removeField(index)}
|
|
>
|
|
<Trash2 size={18} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-end pt-2">
|
|
<Button
|
|
onClick={() => updateMutation.mutate(fields)}
|
|
disabled={updateMutation.isPending || tenantQuery.isLoading}
|
|
className="px-8 h-11"
|
|
>
|
|
<Save size={18} className="mr-2" />
|
|
{t("ui.admin.tenants.schema.save", "변경사항 저장")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|