forked from baron/baron-sso
530 lines
19 KiB
TypeScript
530 lines
19 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import { ArrowLeft, BadgeCheck, Building2, Loader2, Save, Users } from "lucide-react";
|
|
import * as React from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { Link, useNavigate, 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 {
|
|
type UserUpdateRequest,
|
|
fetchMe,
|
|
fetchTenant,
|
|
fetchTenants,
|
|
fetchUser,
|
|
updateUser,
|
|
} from "../../lib/adminApi";
|
|
import { t } from "../../lib/i18n";
|
|
|
|
type UserSchemaField = {
|
|
key: string;
|
|
label?: string;
|
|
type?: "text" | "number" | "boolean" | "date";
|
|
required?: boolean;
|
|
adminOnly?: boolean;
|
|
validation?: string;
|
|
};
|
|
|
|
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
|
|
|
|
// [New] Component for per-tenant profile/schema management
|
|
function TenantProfileCard({
|
|
tenant,
|
|
register,
|
|
errors,
|
|
isAdmin
|
|
}: {
|
|
tenant: any,
|
|
register: any,
|
|
errors: any,
|
|
isAdmin: boolean
|
|
}) {
|
|
const { data: detail, isLoading } = useQuery({
|
|
queryKey: ["tenant", tenant.id],
|
|
queryFn: () => fetchTenant(tenant.id),
|
|
});
|
|
|
|
const schema: UserSchemaField[] = Array.isArray(detail?.config?.userSchema)
|
|
? (detail?.config?.userSchema as UserSchemaField[])
|
|
: [];
|
|
|
|
if (isLoading) return <div className="p-4 border rounded-lg animate-pulse bg-muted/20">Loading schema...</div>;
|
|
if (schema.length === 0) return null;
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
|
<div className="bg-muted/50 px-4 py-2 border-b border-border flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 size={14} className="text-primary" />
|
|
<span className="text-xs font-bold uppercase tracking-tight">{tenant.name}</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono opacity-50">{tenant.slug}</span>
|
|
</div>
|
|
<div className="p-4 grid gap-4 md:grid-cols-2">
|
|
{schema.map((field) => (
|
|
<div key={field.key} className="space-y-2">
|
|
<Label htmlFor={`metadata.${tenant.id}.${field.key}`} className="text-xs">
|
|
{field.label}
|
|
{field.required && <span className="ml-1 text-destructive">*</span>}
|
|
{field.adminOnly && (
|
|
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
|
|
Admin Only
|
|
</span>
|
|
)}
|
|
</Label>
|
|
<Input
|
|
id={`metadata.${tenant.id}.${field.key}`}
|
|
type={
|
|
field.type === "number" ? "number" :
|
|
field.type === "date" ? "date" :
|
|
field.type === "boolean" ? "checkbox" : "text"
|
|
}
|
|
className={field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"}
|
|
{...register(`metadata.${tenant.id}.${field.key}`, {
|
|
required: field.required ? t("msg.admin.users.detail.form.field_required", "필수입니다.") : false,
|
|
})}
|
|
/>
|
|
{errors.metadata?.[tenant.id]?.[field.key] && (
|
|
<p className="text-[10px] text-destructive">
|
|
{errors.metadata[tenant.id][field.key].message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserDetailPage() {
|
|
const params = useParams<{ id: string }>();
|
|
const userId = params.id ?? "";
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
|
|
|
const { data: profile } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: fetchMe,
|
|
});
|
|
|
|
const {
|
|
data: user,
|
|
isLoading,
|
|
isError,
|
|
} = useQuery({
|
|
queryKey: ["user", userId],
|
|
queryFn: () => fetchUser(userId),
|
|
enabled: userId.length > 0,
|
|
});
|
|
|
|
const { data: tenantsData } = useQuery({
|
|
queryKey: ["tenants", { limit: 100 }],
|
|
queryFn: () => fetchTenants(100, 0),
|
|
});
|
|
const tenants = tenantsData?.items ?? [];
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
watch,
|
|
formState: { errors },
|
|
} = useForm<UserFormValues>({
|
|
defaultValues: {
|
|
name: "",
|
|
phone: "",
|
|
role: "user",
|
|
status: "active",
|
|
companyCode: "",
|
|
department: "",
|
|
position: "",
|
|
jobTitle: "",
|
|
password: "",
|
|
metadata: {},
|
|
},
|
|
});
|
|
|
|
const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
|
|
|
React.useEffect(() => {
|
|
if (user) {
|
|
reset({
|
|
name: user.name,
|
|
phone: user.phone || "",
|
|
role: user.role,
|
|
status: user.status,
|
|
companyCode: user.companyCode || "",
|
|
department: user.department || "",
|
|
position: user.position || "",
|
|
jobTitle: user.jobTitle || "",
|
|
password: "",
|
|
metadata: user.metadata || {},
|
|
});
|
|
}
|
|
}, [user, reset]);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
queryClient.invalidateQueries({ queryKey: ["user", userId] });
|
|
setSuccessMsg(
|
|
t(
|
|
"msg.admin.users.detail.update_success",
|
|
"사용자 정보가 수정되었습니다.",
|
|
),
|
|
);
|
|
setError(null);
|
|
},
|
|
onError: (err: AxiosError<{ error?: string }>) => {
|
|
setError(
|
|
err.response?.data?.error ||
|
|
t(
|
|
"msg.admin.users.detail.update_error",
|
|
"사용자 수정에 실패했습니다.",
|
|
),
|
|
);
|
|
setSuccessMsg(null);
|
|
},
|
|
});
|
|
|
|
const onSubmit = (data: UserFormValues) => {
|
|
const payload: UserUpdateRequest = { ...data };
|
|
if (!payload.password) {
|
|
payload.password = undefined;
|
|
}
|
|
mutation.mutate(payload);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-8 text-center">
|
|
{t("msg.common.loading", "Loading...")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError || !user) {
|
|
return (
|
|
<div className="p-8 text-center text-destructive">
|
|
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Combined affiliated tenants
|
|
const userAffiliatedTenants = [...(user.joinedTenants || [])];
|
|
if (user.tenant && !userAffiliatedTenants.find(t => t.id === user.tenant?.id)) {
|
|
userAffiliatedTenants.push(user.tenant);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-3xl space-y-8">
|
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
|
<Link to="/users" className="hover:underline">
|
|
{t("ui.admin.users.detail.breadcrumb.section", "Users")}
|
|
</Link>
|
|
<span>/</span>
|
|
<span className="text-foreground">{user.name}</span>
|
|
</div>
|
|
<h2 className="text-3xl font-semibold">
|
|
{t("ui.admin.users.detail.title", "사용자 상세")}
|
|
</h2>
|
|
</div>
|
|
<Button variant="ghost" asChild>
|
|
<Link to="/users">
|
|
<ArrowLeft size={16} className="mr-2" />
|
|
{t("ui.admin.users.detail.back", "목록으로 돌아가기")}
|
|
</Link>
|
|
</Button>
|
|
</header>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{t("ui.admin.users.detail.edit_title", "정보 수정")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.users.detail.edit_subtitle",
|
|
"{{email}} 계정의 정보를 수정합니다.",
|
|
{ email: user.email },
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
|
{error && (
|
|
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
{successMsg && (
|
|
<div className="rounded-md bg-green-500/15 p-3 text-sm text-green-500">
|
|
{successMsg}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tenant Affiliation Section (Enhanced) */}
|
|
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
<Building2 size={16} className="text-primary" />
|
|
{t("ui.admin.users.detail.tenants_section.title", "소속 및 조직 정보")}
|
|
</h3>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
|
{t("ui.admin.users.detail.tenants_section.primary", "대표 소속 테넌트")}
|
|
</Label>
|
|
|
|
{/* Select box to specify representative tenant from joined ones */}
|
|
{userAffiliatedTenants.length > 0 ? (
|
|
<div className="relative">
|
|
<select
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
|
|
{...register("companyCode")}
|
|
disabled={profile?.role === "tenant_admin" && userAffiliatedTenants.length <= 1}
|
|
>
|
|
<option value="">{t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}</option>
|
|
{userAffiliatedTenants.map((t) => (
|
|
<option key={t.id} value={t.slug}>
|
|
{t.name} ({t.slug})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<BadgeCheck size={14} className="absolute right-8 top-3 text-primary pointer-events-none" />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs">
|
|
{t("ui.admin.users.detail.form.tenant_global", "시스템 전역 (소속 없음)")}
|
|
</div>
|
|
)}
|
|
<p className="text-[10px] text-muted-foreground">
|
|
* 사용자의 주된 정체성을 결정하는 대표 조직을 선택합니다.
|
|
</p>
|
|
</div>
|
|
|
|
{userAffiliatedTenants.length > 1 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
|
{t("ui.admin.users.detail.tenants_section.additional", "전체 소속 목록")}
|
|
</Label>
|
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
{userAffiliatedTenants.map(jt => (
|
|
<Link
|
|
key={jt.id}
|
|
to={`/tenants/${jt.id}`}
|
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
|
|
jt.id === tenants.find(t => t.slug === watch("companyCode"))?.id ? "bg-primary/10 border-primary/30 text-primary font-bold" : "bg-background border-border text-muted-foreground hover:border-primary/50"
|
|
}`}
|
|
>
|
|
<Users size={10} />
|
|
{jt.name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">
|
|
{t("ui.admin.users.detail.form.name", "이름")}
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
placeholder={t(
|
|
"ui.admin.users.detail.form.name_placeholder",
|
|
"홍길동",
|
|
)}
|
|
{...register("name", {
|
|
required: t(
|
|
"msg.admin.users.detail.form.name_required",
|
|
"이름은 필수입니다.",
|
|
),
|
|
})}
|
|
/>
|
|
{errors.name && (
|
|
<p className="text-xs text-destructive">
|
|
{errors.name.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">
|
|
{t("ui.admin.users.detail.form.phone", "전화번호")}
|
|
</Label>
|
|
<Input
|
|
id="phone"
|
|
placeholder={t(
|
|
"ui.admin.users.detail.form.phone_placeholder",
|
|
"010-1234-5678",
|
|
)}
|
|
{...register("phone")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="status">
|
|
{t("ui.admin.users.detail.form.status", "상태")}
|
|
</Label>
|
|
<div className="relative">
|
|
<select
|
|
id="status"
|
|
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("status")}
|
|
>
|
|
<option value="active">
|
|
{t("ui.common.status.active", "Active")}
|
|
</option>
|
|
<option value="inactive">
|
|
{t("ui.common.status.inactive", "Inactive")}
|
|
</option>
|
|
<option value="blocked">
|
|
{t("ui.common.status.blocked", "Blocked")}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="role">
|
|
{t("ui.admin.users.detail.form.role", "역할 (Role)")}
|
|
</Label>
|
|
<div className="relative">
|
|
<select
|
|
id="role"
|
|
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("role")}
|
|
>
|
|
<option value="user">
|
|
{t("ui.admin.role.user", "TENANT MEMBER")}
|
|
</option>
|
|
<option value="tenant_admin">
|
|
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
|
|
</option>
|
|
<option value="rp_admin">
|
|
{t("ui.admin.role.rp_admin", "RP ADMIN")}
|
|
</option>
|
|
<option value="super_admin">
|
|
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="department">
|
|
{t("ui.admin.users.detail.form.department", "부서")}
|
|
</Label>
|
|
|
|
<Input
|
|
id="department"
|
|
placeholder={t(
|
|
"ui.admin.users.detail.form.department_placeholder",
|
|
"개발팀",
|
|
)}
|
|
{...register("department")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tenant-specific Profiles (Namespaced Metadata) */}
|
|
<div className="border-t pt-6 space-y-6">
|
|
<div className="flex flex-col gap-1">
|
|
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
|
{t("ui.admin.users.detail.custom_fields.multi_title", "테넌트별 프로필 관리")}
|
|
</h3>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
사용자가 소속된 각 테넌트별 맞춤 정보를 관리합니다.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4">
|
|
{userAffiliatedTenants.map((tenant) => (
|
|
<TenantProfileCard
|
|
key={tenant.id}
|
|
tenant={tenant}
|
|
register={register}
|
|
errors={errors}
|
|
isAdmin={isAdmin}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<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(
|
|
"ui.admin.users.detail.security.password",
|
|
"비밀번호 변경",
|
|
)}
|
|
</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder={t(
|
|
"ui.admin.users.detail.security.password_placeholder",
|
|
"변경할 경우에만 입력",
|
|
)}
|
|
{...register("password")}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.admin.users.detail.security.password_hint",
|
|
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => navigate("/users")}
|
|
>
|
|
{t("ui.common.cancel", "취소")}
|
|
</Button>
|
|
<Button type="submit" disabled={mutation.isPending}>
|
|
{mutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
<Save className="mr-2 h-4 w-4" />
|
|
{t("ui.common.save", "저장")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default UserDetailPage;
|