+
+ {(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 (
-
)}
+
+ {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(", ")}
+
+ )}
+
+
+
+ )}