forked from baron/baron-sso
feat: implement enhanced user schema management with validation and admin_only fields
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user