1
0
forked from baron/baron-sso

feat: implement enhanced user schema management with validation and admin_only fields

This commit is contained in:
2026-03-04 10:09:52 +09:00
parent 69470e8e4a
commit db88c7ab1c
4 changed files with 346 additions and 96 deletions

View File

@@ -17,7 +17,7 @@ import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type SchemaFieldType = "text" | "number" | "boolean";
type SchemaFieldType = "text" | "number" | "boolean" | "date";
type SchemaField = {
id: string;
@@ -25,6 +25,8 @@ type SchemaField = {
label: string;
type: SchemaFieldType;
required: boolean;
adminOnly: boolean;
validation?: string;
};
function createFieldId() {
@@ -62,10 +64,14 @@ export function TenantSchemaPage() {
key: typeof field?.key === "string" ? field.key : "",
label: typeof field?.label === "string" ? field.label : "",
type:
field?.type === "number" || field?.type === "boolean"
field?.type === "number" ||
field?.type === "boolean" ||
field?.type === "date"
? field.type
: "text",
required: Boolean(field?.required),
adminOnly: Boolean(field?.adminOnly),
validation: typeof field?.validation === "string" ? field.validation : "",
})),
);
}
@@ -105,6 +111,8 @@ export function TenantSchemaPage() {
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
},
]);
};
@@ -141,7 +149,7 @@ export function TenantSchemaPage() {
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-6">
{fields.length === 0 && (
<div className="py-12 text-center text-muted-foreground border border-dashed rounded-lg bg-muted/10">
{t(
@@ -153,84 +161,142 @@ export function TenantSchemaPage() {
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-end gap-4 p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors"
className="p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors space-y-4"
>
<div className="flex-1 space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder",
"예: employee_id",
)}
className="h-10"
/>
</div>
<div className="flex-1 space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
</Label>
<Input
value={field.label}
onChange={(e) =>
updateField(index, { label: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder",
"예: 사번",
)}
className="h-10"
/>
</div>
<div className="w-40 space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
value={field.type}
onChange={(e) => {
const nextType = e.target.value;
if (
nextType === "text" ||
nextType === "number" ||
nextType === "boolean"
) {
updateField(index, { type: nextType });
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder",
"예: employee_id",
)}
className="h-10"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
</Label>
<Input
value={field.label}
onChange={(e) =>
updateField(index, { label: e.target.value })
}
}}
>
<option value="text">
{t(
"ui.admin.tenants.schema.field.type_text",
"텍스트 (Text)",
placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder",
"예: 사번",
)}
</option>
<option value="number">
{t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Number)",
)}
</option>
<option value="boolean">
{t(
"ui.admin.tenants.schema.field.type_boolean",
"불리언 (Boolean)",
)}
</option>
</select>
className="h-10"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
value={field.type}
onChange={(e) => {
const nextType = e.target.value;
if (
nextType === "text" ||
nextType === "number" ||
nextType === "boolean" ||
nextType === "date"
) {
updateField(index, { type: nextType });
}
}}
>
<option value="text">
{t(
"ui.admin.tenants.schema.field.type_text",
"텍스트 (Text)",
)}
</option>
<option value="number">
{t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Number)",
)}
</option>
<option value="boolean">
{t(
"ui.admin.tenants.schema.field.type_boolean",
"불리언 (Boolean)",
)}
</option>
<option value="date">
{t(
"ui.admin.tenants.schema.field.type_date",
"날짜 (Date)",
)}
</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">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.required}
onChange={(e) =>
updateField(index, { required: 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.required", "필수 입력")}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.adminOnly}
onChange={(e) =>
updateField(index, { adminOnly: 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.admin_only",
"관리자 전용",
)}
</span>
</label>
</div>
<div className="space-y-2">
<Input
value={field.validation}
onChange={(e) =>
updateField(index, { validation: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.validation_placeholder",
"정규식 (예: ^[0-9]+$)",
)}
className="h-9 text-xs font-mono"
/>
</div>
<div className="flex justify-end">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)}
>
<Trash2 size={18} />
</Button>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)}
>
<Trash2 size={18} />
</Button>
</div>
))}
</CardContent>
@@ -249,3 +315,4 @@ export function TenantSchemaPage() {
</div>
);
}

View File

@@ -26,8 +26,10 @@ import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean";
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
};
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
@@ -85,8 +87,24 @@ function UserCreatePage() {
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (key: string) =>
register(`metadata.${key}` as `metadata.${string}`);
const registerMetadata = (field: UserSchemaField) =>
register(`metadata.${field.key}` as `metadata.${string}`, {
required: field.required
? t("msg.admin.users.create.form.field_required", "{{label}}은(는) 필수입니다.", {
label: field.label || field.key,
})
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.create.form.field_invalid",
"{{label}} 형식이 올바르지 않습니다.",
{ label: field.label || field.key },
),
}
: undefined,
});
const mutation = useMutation({
mutationFn: createUser,
@@ -107,17 +125,16 @@ function UserCreatePage() {
},
});
const onSubmit = (data: UserCreateRequest) => {
const onSubmit = (data: UserFormValues) => {
setError(null);
setGeneratedPassword(null);
setCreatedEmail(null);
if (autoPassword) {
mutation.mutate({ ...data, password: "" });
return;
}
const payload = { ...data };
if (!data.password) {
if (autoPassword) {
payload.password = "";
} else if (!data.password) {
setError(
t(
"msg.admin.users.create.password_required",
@@ -127,7 +144,7 @@ function UserCreatePage() {
return;
}
mutation.mutate(data);
mutation.mutate(payload);
};
const onCopyPassword = async () => {
@@ -414,13 +431,37 @@ function UserCreatePage() {
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
{field.required && (
<span className="ml-1 text-destructive">*</span>
)}
{field.adminOnly && (
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
Admin Only
</span>
)}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...registerMetadata(field.key)}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-auto h-auto" : ""
}
{...registerMetadata(field)}
/>
{errors.metadata?.[field.key] && (
<p className="text-xs text-destructive">
{(errors.metadata[field.key] as any).message}
</p>
)}
</div>
))}
</div>
@@ -482,4 +523,5 @@ function UserCreatePage() {
);
}
export default UserCreatePage;

View File

@@ -26,8 +26,10 @@ import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean";
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
};
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
@@ -94,8 +96,24 @@ function UserDetailPage() {
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (key: string) =>
register(`metadata.${key}` as `metadata.${string}`);
const registerMetadata = (field: UserSchemaField) =>
register(`metadata.${field.key}` as `metadata.${string}`, {
required: field.required
? t("msg.admin.users.detail.form.field_required", "{{label}}은(는) 필수입니다.", {
label: field.label || field.key,
})
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.detail.form.field_invalid",
"{{label}} 형식이 올바르지 않습니다.",
{ label: field.label || field.key },
),
}
: undefined,
});
React.useEffect(() => {
if (user) {
@@ -139,8 +157,8 @@ function UserDetailPage() {
},
});
const onSubmit = (data: UserUpdateRequest) => {
const payload = { ...data };
const onSubmit = (data: UserFormValues) => {
const payload: UserUpdateRequest = { ...data };
if (!payload.password) {
payload.password = undefined;
}
@@ -393,13 +411,37 @@ function UserDetailPage() {
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
{field.required && (
<span className="ml-1 text-destructive">*</span>
)}
{field.adminOnly && (
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
Admin Only
</span>
)}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...registerMetadata(field.key)}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-auto h-auto" : ""
}
{...registerMetadata(field)}
/>
{errors.metadata?.[field.key] && (
<p className="text-xs text-destructive">
{(errors.metadata[field.key] as any).message}
</p>
)}
</div>
))}
</div>
@@ -410,6 +452,7 @@ function UserDetailPage() {
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t("ui.admin.users.detail.security.title", "보안 설정")}
</h3>
<div className="space-y-2">
<Label htmlFor="password">
{t(

View File

@@ -6,7 +6,10 @@ import (
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
@@ -302,6 +305,18 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
Attributes: attributes,
}
// [Validation] Based on Tenant Schema
if req.CompanyCode != "" && h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
if err == nil && tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
if err := h.validateMetadata(req.Metadata, schema, true); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
}
}
}
}
identityID, err := h.OryProvider.CreateUser(brokerUser, password)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
@@ -408,6 +423,24 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
// [Validation] Based on Tenant Schema
schemaCompCode := extractTraitString(identity.Traits, "companyCode")
if req.CompanyCode != nil {
schemaCompCode = *req.CompanyCode
}
if schemaCompCode != "" && h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), schemaCompCode)
if err == nil && tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
}
}
}
}
traits := identity.Traits
if traits == nil {
traits = map[string]interface{}{}
@@ -730,3 +763,68 @@ func normalizePhoneNumber(phone string) string {
}
return normalized
}
func (h *UserHandler) validateMetadata(metadata map[string]any, schema []interface{}, checkRequired bool) error {
return h.validateMetadataWithAuth(metadata, schema, true, checkRequired)
}
func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema []interface{}, isAdmin bool, checkRequired bool) error {
schemaMap := make(map[string]map[string]interface{})
for _, s := range schema {
if m, ok := s.(map[string]interface{}); ok {
if key, ok := m["key"].(string); ok {
schemaMap[key] = m
}
}
}
// 1. Check required fields
if checkRequired {
for key, config := range schemaMap {
required, _ := config["required"].(bool)
val, exists := metadata[key]
if required && (!exists || val == nil || val == "") {
return errors.New("field " + key + " is required")
}
}
}
// 2. Check each field in metadata
for key, val := range metadata {
config, exists := schemaMap[key]
if !exists {
continue // Ignore fields not in schema or allow? Let's allow for now
}
// Admin Only check
adminOnly, _ := config["adminOnly"].(bool)
if adminOnly && !isAdmin {
return errors.New("field " + key + " is admin only")
}
// Regex validation
if regexStr, ok := config["validation"].(string); ok && regexStr != "" {
strVal := ""
switch v := val.(type) {
case string:
strVal = v
case float64:
strVal = fmt.Sprintf("%v", v)
case int:
strVal = fmt.Sprintf("%v", v)
}
if strVal != "" {
matched, err := regexp.MatchString(regexStr, strVal)
if err != nil {
return errors.New("invalid regex pattern for field " + key)
}
if !matched {
return errors.New("field " + key + " does not match validation pattern")
}
}
}
}
return nil
}