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

@@ -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() {
<option value="number">
{t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Number)",
"숫자 (Integer)",
)}
</option>
<option value="float">
{t(
"ui.admin.tenants.schema.field.type_float",
"실수 (Float)",
)}
</option>
<option value="boolean">
@@ -272,12 +293,18 @@ export function TenantSchemaPage() {
"날짜 (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 items-center gap-6">
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@@ -307,6 +334,24 @@ export function TenantSchemaPage() {
)}
</span>
</label>
{(field.type === "number" || field.type === "float") && (
<label className="flex items-center gap-2 cursor-pointer">
<input
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

View File

@@ -615,6 +615,9 @@ function UserListPage() {
</Button>
<UserBulkMoveGroupModal
userIds={selectedUserIds}
selectedUsers={items.filter((u) =>
selectedUserIds.includes(u.id),
)}
onSuccess={() => {
query.refetch();
setSelectedUserIds([]);

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" />

View File

@@ -13,6 +13,7 @@ import (
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
@@ -1463,6 +1464,87 @@ func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema [
return errors.New("field " + key + " is admin only")
}
// Type validation
if expectedType, ok := config["type"].(string); ok && expectedType != "" && val != nil && val != "" {
switch expectedType {
case "number":
var numVal float64
switch v := val.(type) {
case float64:
numVal = v
case int:
numVal = float64(v)
case string:
parsed, err := strconv.ParseFloat(v, 64)
if err != nil {
return errors.New("field " + key + " must be a number")
}
numVal = parsed
default:
return errors.New("field " + key + " must be a number")
}
if float64(int(numVal)) != numVal {
return errors.New("field " + key + " must be an integer")
}
if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
return errors.New("field " + key + " must be an unsigned integer")
}
case "float":
var numVal float64
switch v := val.(type) {
case float64:
numVal = v
case int:
numVal = float64(v)
case string:
parsed, err := strconv.ParseFloat(v, 64)
if err != nil {
return errors.New("field " + key + " must be a float")
}
numVal = parsed
default:
return errors.New("field " + key + " must be a float")
}
if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
return errors.New("field " + key + " must be an unsigned float")
}
case "boolean":
switch v := val.(type) {
case bool:
// ok
case string:
if v != "true" && v != "false" {
return errors.New("field " + key + " must be a boolean")
}
default:
return errors.New("field " + key + " must be a boolean")
}
case "date":
if strVal, ok := val.(string); ok {
if _, err := time.Parse("2006-01-02", strVal); err != nil {
return errors.New("field " + key + " must be a valid date (YYYY-MM-DD)")
}
} else {
return errors.New("field " + key + " must be a date string")
}
case "datetime":
if strVal, ok := val.(string); ok {
_, err1 := time.Parse(time.RFC3339, strVal)
_, err2 := time.Parse("2006-01-02T15:04", strVal)
_, err3 := time.Parse("2006-01-02T15:04:05", strVal)
if err1 != nil && err2 != nil && err3 != nil {
return errors.New("field " + key + " must be a valid datetime")
}
} else {
return errors.New("field " + key + " must be a datetime string")
}
case "text":
if _, ok := val.(string); !ok {
return errors.New("field " + key + " must be a string")
}
}
}
// Regex validation
if regexStr, ok := config["validation"].(string); ok && regexStr != "" {
strVal := ""