1
0
forked from baron/baron-sso

사용자 필드 관리

This commit is contained in:
2026-02-02 17:11:43 +09:00
parent a54c2ab138
commit 12ae5929e3
17 changed files with 648 additions and 103 deletions

View File

@@ -9,6 +9,8 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
@@ -28,7 +30,14 @@ export const router = createBrowserRouter(
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "tenants/:id", element: <TenantDetailPage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
],

View File

@@ -60,6 +60,16 @@ function TenantDetailPage() {
>
Federation
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${
location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Schema
</Link>
</div>
{/* Outlet for nested routes */}

View File

@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
type SchemaField = {
key: string;
label: string;
type: "text" | "number" | "boolean";
required: boolean;
};
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
if (!tenantId) return <div>Tenant ID missing</div>;
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
});
const [fields, setFields] = useState<SchemaField[]>([]);
useEffect(() => {
if (tenantQuery.data?.config?.userSchema) {
setFields(tenantQuery.data.config.userSchema as SchemaField[]);
}
}, [tenantQuery.data]);
const updateMutation = useMutation({
mutationFn: (newFields: SchemaField[]) =>
updateTenant(tenantId, {
config: {
...tenantQuery.data?.config,
userSchema: newFields,
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Schema updated successfully");
},
onError: (err: AxiosError<{ error?: string }>) => {
alert(err.response?.data?.error || "Failed to update schema");
},
});
const addField = () => {
setFields([...fields, { key: "", label: "", type: "text", required: false }]);
};
const removeField = (index: number) => {
setFields(fields.filter((_, i) => i !== index));
};
const updateField = (index: number, updates: Partial<SchemaField>) => {
const newFields = [...fields];
newFields[index] = { ...newFields[index], ...updates };
setFields(newFields);
};
return (
<div className="space-y-6 mt-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>User Schema Extension</CardTitle>
<CardDescription>
Define custom attributes for users in this tenant.
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Plus size={16} className="mr-2" />
Add Field
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{fields.length === 0 && (
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
No custom fields defined. Click "Add Field" to begin.
</div>
)}
{fields.map((field, index) => (
<div key={index} className="flex items-end gap-4 p-4 border rounded-md bg-muted/30">
<div className="flex-1 space-y-2">
<Label>Field Key (ID)</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder="e.g. employee_id"
/>
</div>
<div className="flex-1 space-y-2">
<Label>Display Label</Label>
<Input
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
placeholder="e.g. 사번"
/>
</div>
<div className="w-32 space-y-2">
<Label>Type</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value as any })}
>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeField(index)}
>
<Trash2 size={16} />
</Button>
</div>
))}
</CardContent>
</Card>
<div className="flex justify-end">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading}
>
<Save size={16} className="mr-2" />
Save Schema Changes
</Button>
</div>
</div>
);
}

View File

@@ -38,8 +38,9 @@ function UserCreatePage() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<UserCreateRequest>({
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
defaultValues: {
email: "",
password: "",
@@ -48,9 +49,21 @@ function UserCreatePage() {
role: "user",
companyCode: "",
department: "",
metadata: {},
},
});
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (data: UserCreateResponse) => {
@@ -212,35 +225,109 @@ function UserCreatePage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value=""> ( )</option>
{tenants.map((t) => ( <option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
<div className="space-y-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="relative">
<select

View File

@@ -44,8 +44,9 @@ function UserDetailPage() {
register,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<UserUpdateRequest>({
} = useForm<UserUpdateRequest & { metadata: Record<string, any> }>({
defaultValues: {
name: "",
phone: "",
@@ -54,9 +55,21 @@ function UserDetailPage() {
companyCode: "",
department: "",
password: "",
metadata: {},
},
});
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
React.useEffect(() => {
if (user) {
reset({
@@ -67,6 +80,7 @@ function UserDetailPage() {
companyCode: user.companyCode || "",
department: user.department || "",
password: "",
metadata: user.metadata || {},
});
}
}, [user, reset]);
@@ -200,35 +214,109 @@ function UserDetailPage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value=""> ( )</option>
{tenants.map((t) => ( <option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
<div className="space-y-2">
<div className="border-t pt-4">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3>
<div className="space-y-2">
<Label htmlFor="password"> </Label>

View File

@@ -26,6 +26,7 @@ export type TenantSummary = {
description: string;
status: string;
domains?: string[];
config?: Record<string, any>;
createdAt: string;
updatedAt: string;
};
@@ -36,6 +37,7 @@ export type TenantCreateRequest = {
description?: string;
status?: string;
domains?: string[];
config?: Record<string, any>;
};
export type TenantListResponse = {
@@ -51,6 +53,7 @@ export type TenantUpdateRequest = {
description?: string;
status?: string;
domains?: string[];
config?: Record<string, any>;
};
export type ApiKeySummary = {
@@ -172,6 +175,7 @@ export type UserSummary = {
status: string;
companyCode?: string;
tenant?: TenantSummary;
metadata?: Record<string, any>;
department?: string;
createdAt: string;
updatedAt: string;