From 896a51df3d382beb52ca2cbe26391bff9237929f Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Mar 2026 14:02:56 +0900 Subject: [PATCH] feat: add schema check on bulk user move and support for float/datetime in custom metadata --- .../tenants/routes/TenantSchemaPage.tsx | 57 +++++++- .../src/features/users/UserListPage.tsx | 3 + .../components/UserBulkMoveGroupModal.tsx | 125 +++++++++++++++++- backend/internal/handler/user_handler.go | 82 ++++++++++++ 4 files changed, 256 insertions(+), 11 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index f9a8d04a..221c9a90 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -17,7 +17,13 @@ import { Label } from "../../../components/ui/label"; import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -type SchemaFieldType = "text" | "number" | "boolean" | "date"; +type SchemaFieldType = + | "text" + | "number" + | "boolean" + | "date" + | "float" + | "datetime"; type SchemaField = { id: string; @@ -27,6 +33,7 @@ type SchemaField = { required: boolean; adminOnly: boolean; validation?: string; + unsigned?: boolean; }; function createFieldId() { @@ -98,13 +105,16 @@ export function TenantSchemaPage() { type: field?.type === "number" || field?.type === "boolean" || - field?.type === "date" + field?.type === "date" || + field?.type === "float" || + field?.type === "datetime" ? field.type : "text", required: Boolean(field?.required), adminOnly: Boolean(field?.adminOnly), validation: typeof field?.validation === "string" ? field.validation : "", + unsigned: Boolean(field?.unsigned), })), ); } @@ -146,6 +156,7 @@ export function TenantSchemaPage() { required: false, adminOnly: false, validation: "", + unsigned: false, }, ]); }; @@ -242,9 +253,13 @@ export function TenantSchemaPage() { nextType === "text" || nextType === "number" || nextType === "boolean" || - nextType === "date" + nextType === "date" || + nextType === "float" || + nextType === "datetime" ) { - updateField(index, { type: nextType }); + updateField(index, { + type: nextType as SchemaFieldType, + }); } }} > @@ -257,7 +272,13 @@ export function TenantSchemaPage() { + +
-
+
+ {(field.type === "number" || field.type === "float") && ( + + )}
+ selectedUserIds.includes(u.id), + )} onSuccess={() => { query.refetch(); setSelectedUserIds([]); diff --git a/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx b/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx index ad1c51a1..e0b9f57f 100644 --- a/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx +++ b/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { FolderTree, Loader2, Search } from "lucide-react"; +import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { Button } from "../../../components/ui/button"; @@ -18,19 +18,28 @@ import { ScrollArea } from "../../../components/ui/scroll-area"; import { type GroupSummary, type TenantSummary, + type UserSummary, bulkUpdateUsers, fetchGroups, fetchTenants, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +type UserSchemaField = { + key: string; + label: string; + required?: boolean; +}; + interface UserBulkMoveGroupModalProps { userIds: string[]; + selectedUsers?: UserSummary[]; onSuccess?: () => void; } export function UserBulkMoveGroupModal({ userIds, + selectedUsers = [], onSuccess, }: UserBulkMoveGroupModalProps) { const [open, setOpen] = React.useState(false); @@ -38,6 +47,7 @@ export function UserBulkMoveGroupModal({ React.useState(""); const [selectedGroupName, setSelectedGroupName] = React.useState(""); const [searchTerm, setSearchTerm] = React.useState(""); + const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false); const queryClient = useQueryClient(); @@ -48,10 +58,11 @@ export function UserBulkMoveGroupModal({ }); const tenants = tenantsData?.items ?? []; - const selectedTenantId = React.useMemo( - () => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "", + const selectedTenant = React.useMemo( + () => tenants.find((t) => t.slug === selectedTenantSlug), [tenants, selectedTenantSlug], ); + const selectedTenantId = selectedTenant?.id ?? ""; const { data: groups, isLoading: isGroupsLoading } = useQuery({ queryKey: ["tenant-groups", selectedTenantId], @@ -59,6 +70,51 @@ export function UserBulkMoveGroupModal({ enabled: open && !!selectedTenantId, }); + const schemaWarnings = React.useMemo(() => { + if (!selectedTenant || selectedUsers.length === 0) return null; + + const targetSchema = + (selectedTenant.config?.userSchema as UserSchemaField[]) || []; + const targetSchemaKeys = new Set(targetSchema.map((f) => f.key)); + const requiredKeys = targetSchema + .filter((f) => f.required) + .map((f) => f.key); + + const missingRequiredFields = new Set(); + const incompatibleFields = new Set(); + + for (const user of selectedUsers) { + const userMeta = user.metadata || {}; + + // 1. Check for missing required fields + for (const key of requiredKeys) { + if ( + userMeta[key] === undefined || + userMeta[key] === null || + userMeta[key] === "" + ) { + missingRequiredFields.add(key); + } + } + + // 2. Check for fields that exist in user metadata but not in the target schema (data loss) + for (const key of Object.keys(userMeta)) { + if (!targetSchemaKeys.has(key)) { + incompatibleFields.add(key); + } + } + } + + if (missingRequiredFields.size === 0 && incompatibleFields.size === 0) { + return null; + } + + return { + missing: Array.from(missingRequiredFields), + incompatible: Array.from(incompatibleFields), + }; + }, [selectedTenant, selectedUsers]); + const mutation = useMutation({ mutationFn: bulkUpdateUsers, onSuccess: () => { @@ -96,7 +152,18 @@ export function UserBulkMoveGroupModal({ }, [groups, searchTerm]); return ( - + { + setOpen(val); + if (!val) { + setSelectedTenantSlug(""); + setSelectedGroupName(""); + setAcknowledgeWarning(false); + setSearchTerm(""); + } + }} + >
)} + + {schemaWarnings && ( +
+
+ + {t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")} +
+
+ {schemaWarnings.missing.length > 0 && ( +

+ {t( + "msg.admin.users.bulk.schema_missing", + "대상 테넌트의 필수 필드가 누락되어 있습니다:", + )}{" "} + {schemaWarnings.missing.join(", ")} +

+ )} + {schemaWarnings.incompatible.length > 0 && ( +

+ {t( + "msg.admin.users.bulk.schema_incompatible", + "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:", + )}{" "} + {schemaWarnings.incompatible.join(", ")} +

+ )} +
+ +
+ )}
@@ -203,7 +314,11 @@ export function UserBulkMoveGroupModal({