1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx

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>
);
}