1
0
forked from baron/baron-sso

feat: add schema check on bulk user move and support for float/datetime in custom metadata

This commit is contained in:
2026-03-19 14:02:56 +09:00
parent 3e335eb9cf
commit 896a51df3d
4 changed files with 256 additions and 11 deletions

View File

@@ -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<string>("");
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
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<string>();
const incompatibleFields = new Set<string>();
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 (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setSelectedTenantSlug("");
setSelectedGroupName("");
setAcknowledgeWarning(false);
setSearchTerm("");
}
}}
>
<DialogTrigger asChild>
<Button
variant="ghost"
@@ -131,6 +198,7 @@ export function UserBulkMoveGroupModal({
onChange={(e) => {
setSelectedTenantSlug(e.target.value);
setSelectedGroupName("");
setAcknowledgeWarning(false);
}}
>
<option value="">{t("ui.common.select", "선택하세요...")}</option>
@@ -195,6 +263,49 @@ export function UserBulkMoveGroupModal({
</ScrollArea>
</div>
)}
{schemaWarnings && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 space-y-2 mt-4 text-sm">
<div className="flex items-center gap-2 text-destructive font-semibold">
<AlertTriangle size={16} />
{t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")}
</div>
<div className="text-destructive/80 text-xs">
{schemaWarnings.missing.length > 0 && (
<p>
{t(
"msg.admin.users.bulk.schema_missing",
"대상 테넌트의 필수 필드가 누락되어 있습니다:",
)}{" "}
<strong>{schemaWarnings.missing.join(", ")}</strong>
</p>
)}
{schemaWarnings.incompatible.length > 0 && (
<p>
{t(
"msg.admin.users.bulk.schema_incompatible",
"대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:",
)}{" "}
<strong>{schemaWarnings.incompatible.join(", ")}</strong>
</p>
)}
</div>
<label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10">
<input
type="checkbox"
checked={acknowledgeWarning}
onChange={(e) => setAcknowledgeWarning(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-destructive focus:ring-destructive"
/>
<span className="font-medium text-destructive/90">
{t(
"ui.admin.users.bulk.acknowledge_warning",
"경고를 확인했으며 계속 진행합니다.",
)}
</span>
</label>
</div>
)}
</div>
<DialogFooter>
@@ -203,7 +314,11 @@ export function UserBulkMoveGroupModal({
</Button>
<Button
onClick={handleMove}
disabled={!selectedTenantSlug || mutation.isPending}
disabled={
!selectedTenantSlug ||
mutation.isPending ||
(!!schemaWarnings && !acknowledgeWarning)
}
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />