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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user