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 { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
type SchemaFieldType = "text" | "number" | "boolean" | "date";
|
type SchemaFieldType =
|
||||||
|
| "text"
|
||||||
|
| "number"
|
||||||
|
| "boolean"
|
||||||
|
| "date"
|
||||||
|
| "float"
|
||||||
|
| "datetime";
|
||||||
|
|
||||||
type SchemaField = {
|
type SchemaField = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +33,7 @@ type SchemaField = {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
adminOnly: boolean;
|
adminOnly: boolean;
|
||||||
validation?: string;
|
validation?: string;
|
||||||
|
unsigned?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createFieldId() {
|
function createFieldId() {
|
||||||
@@ -98,13 +105,16 @@ export function TenantSchemaPage() {
|
|||||||
type:
|
type:
|
||||||
field?.type === "number" ||
|
field?.type === "number" ||
|
||||||
field?.type === "boolean" ||
|
field?.type === "boolean" ||
|
||||||
field?.type === "date"
|
field?.type === "date" ||
|
||||||
|
field?.type === "float" ||
|
||||||
|
field?.type === "datetime"
|
||||||
? field.type
|
? field.type
|
||||||
: "text",
|
: "text",
|
||||||
required: Boolean(field?.required),
|
required: Boolean(field?.required),
|
||||||
adminOnly: Boolean(field?.adminOnly),
|
adminOnly: Boolean(field?.adminOnly),
|
||||||
validation:
|
validation:
|
||||||
typeof field?.validation === "string" ? field.validation : "",
|
typeof field?.validation === "string" ? field.validation : "",
|
||||||
|
unsigned: Boolean(field?.unsigned),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -146,6 +156,7 @@ export function TenantSchemaPage() {
|
|||||||
required: false,
|
required: false,
|
||||||
adminOnly: false,
|
adminOnly: false,
|
||||||
validation: "",
|
validation: "",
|
||||||
|
unsigned: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@@ -242,9 +253,13 @@ export function TenantSchemaPage() {
|
|||||||
nextType === "text" ||
|
nextType === "text" ||
|
||||||
nextType === "number" ||
|
nextType === "number" ||
|
||||||
nextType === "boolean" ||
|
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">
|
<option value="number">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.tenants.schema.field.type_number",
|
"ui.admin.tenants.schema.field.type_number",
|
||||||
"숫자 (Number)",
|
"숫자 (Integer)",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
<option value="float">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.schema.field.type_float",
|
||||||
|
"실수 (Float)",
|
||||||
)}
|
)}
|
||||||
</option>
|
</option>
|
||||||
<option value="boolean">
|
<option value="boolean">
|
||||||
@@ -272,12 +293,18 @@ export function TenantSchemaPage() {
|
|||||||
"날짜 (Date)",
|
"날짜 (Date)",
|
||||||
)}
|
)}
|
||||||
</option>
|
</option>
|
||||||
|
<option value="datetime">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.schema.field.type_datetime",
|
||||||
|
"일시 (DateTime)",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
<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">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -307,6 +334,24 @@ export function TenantSchemaPage() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -615,6 +615,9 @@ function UserListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<UserBulkMoveGroupModal
|
<UserBulkMoveGroupModal
|
||||||
userIds={selectedUserIds}
|
userIds={selectedUserIds}
|
||||||
|
selectedUsers={items.filter((u) =>
|
||||||
|
selectedUserIds.includes(u.id),
|
||||||
|
)}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
query.refetch();
|
query.refetch();
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
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 * as React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -18,19 +18,28 @@ import { ScrollArea } from "../../../components/ui/scroll-area";
|
|||||||
import {
|
import {
|
||||||
type GroupSummary,
|
type GroupSummary,
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
|
type UserSummary,
|
||||||
bulkUpdateUsers,
|
bulkUpdateUsers,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
|
type UserSchemaField = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface UserBulkMoveGroupModalProps {
|
interface UserBulkMoveGroupModalProps {
|
||||||
userIds: string[];
|
userIds: string[];
|
||||||
|
selectedUsers?: UserSummary[];
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserBulkMoveGroupModal({
|
export function UserBulkMoveGroupModal({
|
||||||
userIds,
|
userIds,
|
||||||
|
selectedUsers = [],
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: UserBulkMoveGroupModalProps) {
|
}: UserBulkMoveGroupModalProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
@@ -38,6 +47,7 @@ export function UserBulkMoveGroupModal({
|
|||||||
React.useState<string>("");
|
React.useState<string>("");
|
||||||
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
|
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
|
||||||
const [searchTerm, setSearchTerm] = React.useState("");
|
const [searchTerm, setSearchTerm] = React.useState("");
|
||||||
|
const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -48,10 +58,11 @@ export function UserBulkMoveGroupModal({
|
|||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
const selectedTenantId = React.useMemo(
|
const selectedTenant = React.useMemo(
|
||||||
() => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "",
|
() => tenants.find((t) => t.slug === selectedTenantSlug),
|
||||||
[tenants, selectedTenantSlug],
|
[tenants, selectedTenantSlug],
|
||||||
);
|
);
|
||||||
|
const selectedTenantId = selectedTenant?.id ?? "";
|
||||||
|
|
||||||
const { data: groups, isLoading: isGroupsLoading } = useQuery({
|
const { data: groups, isLoading: isGroupsLoading } = useQuery({
|
||||||
queryKey: ["tenant-groups", selectedTenantId],
|
queryKey: ["tenant-groups", selectedTenantId],
|
||||||
@@ -59,6 +70,51 @@ export function UserBulkMoveGroupModal({
|
|||||||
enabled: open && !!selectedTenantId,
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: bulkUpdateUsers,
|
mutationFn: bulkUpdateUsers,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -96,7 +152,18 @@ export function UserBulkMoveGroupModal({
|
|||||||
}, [groups, searchTerm]);
|
}, [groups, searchTerm]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
if (!val) {
|
||||||
|
setSelectedTenantSlug("");
|
||||||
|
setSelectedGroupName("");
|
||||||
|
setAcknowledgeWarning(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -131,6 +198,7 @@ export function UserBulkMoveGroupModal({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSelectedTenantSlug(e.target.value);
|
setSelectedTenantSlug(e.target.value);
|
||||||
setSelectedGroupName("");
|
setSelectedGroupName("");
|
||||||
|
setAcknowledgeWarning(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">{t("ui.common.select", "선택하세요...")}</option>
|
<option value="">{t("ui.common.select", "선택하세요...")}</option>
|
||||||
@@ -195,6 +263,49 @@ export function UserBulkMoveGroupModal({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -203,7 +314,11 @@ export function UserBulkMoveGroupModal({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleMove}
|
onClick={handleMove}
|
||||||
disabled={!selectedTenantSlug || mutation.isPending}
|
disabled={
|
||||||
|
!selectedTenantSlug ||
|
||||||
|
mutation.isPending ||
|
||||||
|
(!!schemaWarnings && !acknowledgeWarning)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{mutation.isPending && (
|
{mutation.isPending && (
|
||||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1463,6 +1464,87 @@ func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema [
|
|||||||
return errors.New("field " + key + " is admin only")
|
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
|
// Regex validation
|
||||||
if regexStr, ok := config["validation"].(string); ok && regexStr != "" {
|
if regexStr, ok := config["validation"].(string); ok && regexStr != "" {
|
||||||
strVal := ""
|
strVal := ""
|
||||||
|
|||||||
Reference in New Issue
Block a user