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:
@@ -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
|
||||
|
||||
@@ -615,6 +615,9 @@ function UserListPage() {
|
||||
</Button>
|
||||
<UserBulkMoveGroupModal
|
||||
userIds={selectedUserIds}
|
||||
selectedUsers={items.filter((u) =>
|
||||
selectedUserIds.includes(u.id),
|
||||
)}
|
||||
onSuccess={() => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
Reference in New Issue
Block a user