forked from baron/baron-sso
chore: fix frontend lints and format issues
- Resolve 'noDelete' by using undefined assignment in TenantSchemaPage - Resolve React list key warning by using client_id in UserDetailPage - Run biome formatter across modified components
This commit is contained in:
@@ -127,7 +127,7 @@ export function TenantSchemaPage() {
|
||||
mutationFn: (newFields: SchemaField[]) => {
|
||||
// Remove legacy loginIdField, keep isLoginId natively in userSchema
|
||||
const newConfig = { ...tenantQuery.data?.config };
|
||||
delete newConfig.loginIdField;
|
||||
newConfig.loginIdField = undefined;
|
||||
newConfig.userSchema = newFields;
|
||||
|
||||
return updateTenant(tenantId, {
|
||||
|
||||
@@ -452,7 +452,10 @@ function UserCreatePage() {
|
||||
)}
|
||||
{field.isLoginId && (
|
||||
<span className="ml-2 text-[10px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
{t("ui.admin.users.create.form.is_login_id", "로그인 ID")}
|
||||
{t(
|
||||
"ui.admin.users.create.form.is_login_id",
|
||||
"로그인 ID",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
@@ -45,12 +45,12 @@ import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
type UserUpdateRequest,
|
||||
deleteUser,
|
||||
fetchPasswordPolicy,
|
||||
fetchMe,
|
||||
fetchPasswordPolicy,
|
||||
fetchTenants,
|
||||
fetchUser,
|
||||
updateUser,
|
||||
fetchUserRpHistory,
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
@@ -163,7 +163,9 @@ function TenantMetadataFields({
|
||||
{tenant.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono opacity-50 bg-background px-2 py-0.5 rounded border">{tenant.slug}</span>
|
||||
<span className="text-[10px] font-mono opacity-50 bg-background px-2 py-0.5 rounded border">
|
||||
{tenant.slug}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-6 grid gap-6 md:grid-cols-2">
|
||||
{schema.map((field) => (
|
||||
@@ -173,9 +175,7 @@ function TenantMetadataFields({
|
||||
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
{field.required && <span className="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
|
||||
@@ -198,9 +198,7 @@ function TenantMetadataFields({
|
||||
? "checkbox"
|
||||
: "text"
|
||||
}
|
||||
className={
|
||||
field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"
|
||||
}
|
||||
className={field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"}
|
||||
{...register(`metadata.${tenant.id}.${field.key}` as const, {
|
||||
required: field.required
|
||||
? t(
|
||||
@@ -293,12 +291,10 @@ function UserDetailPage() {
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const { data: passwordPolicy } = useQuery(
|
||||
{
|
||||
queryKey: ["password-policy"],
|
||||
queryFn: fetchPasswordPolicy,
|
||||
},
|
||||
);
|
||||
const { data: passwordPolicy } = useQuery({
|
||||
queryKey: ["password-policy"],
|
||||
queryFn: fetchPasswordPolicy,
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -373,7 +369,12 @@ function UserDetailPage() {
|
||||
return;
|
||||
}
|
||||
if (manualPassword !== manualPasswordConfirm) {
|
||||
setPasswordResetError(t("msg.userfront.reset.error.mismatch", "비밀번호가 일치하지 않습니다."));
|
||||
setPasswordResetError(
|
||||
t(
|
||||
"msg.userfront.reset.error.mismatch",
|
||||
"비밀번호가 일치하지 않습니다.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -411,7 +412,10 @@ function UserDetailPage() {
|
||||
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(err.response?.data?.error || t("err.common.unknown", "오류가 발생했습니다."));
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("err.common.unknown", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -419,7 +423,9 @@ function UserDetailPage() {
|
||||
mutationFn: () => deleteUser(userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
toast.success(t("msg.admin.users.detail.delete_success", "사용자가 삭제되었습니다."));
|
||||
toast.success(
|
||||
t("msg.admin.users.detail.delete_success", "사용자가 삭제되었습니다."),
|
||||
);
|
||||
navigate("/users");
|
||||
},
|
||||
});
|
||||
@@ -444,7 +450,11 @@ function UserDetailPage() {
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (window.confirm(t("msg.admin.users.detail.delete_confirm", "삭제하시겠습니까?"))) {
|
||||
if (
|
||||
window.confirm(
|
||||
t("msg.admin.users.detail.delete_confirm", "삭제하시겠습니까?"),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
};
|
||||
@@ -460,19 +470,33 @@ function UserDetailPage() {
|
||||
if (isError || !user) {
|
||||
return (
|
||||
<div className="rounded-md bg-destructive/15 p-6 text-center mt-6">
|
||||
<p className="text-destructive font-medium">{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => navigate("/users")}>{t("ui.admin.users.detail.go_list", "목록으로 이동")}</Button>
|
||||
<p className="text-destructive font-medium">
|
||||
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/users")}
|
||||
>
|
||||
{t("ui.admin.users.detail.go_list", "목록으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const userAffiliatedTenants = user.joinedTenants || (user.tenant ? [user.tenant] : []);
|
||||
const userAffiliatedTenants =
|
||||
user.joinedTenants || (user.tenant ? [user.tenant] : []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back button and actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => navigate("/users")} size="sm" className="hover:bg-muted">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/users")}
|
||||
size="sm"
|
||||
className="hover:bg-muted"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.users.detail.back", "목록으로")}
|
||||
</Button>
|
||||
@@ -492,12 +516,22 @@ function UserDetailPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">{user.name}</h1>
|
||||
<Badge variant="outline" className="h-6 px-3 bg-blue-500/10 text-blue-600 border-blue-200 font-bold">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">
|
||||
{user.name}
|
||||
</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 px-3 bg-blue-500/10 text-blue-600 border-blue-200 font-bold"
|
||||
>
|
||||
<Building2 size={12} className="mr-1.5" />
|
||||
{user.tenant?.name || user.companyCode || t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
|
||||
{user.tenant?.name ||
|
||||
user.companyCode ||
|
||||
t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
|
||||
</Badge>
|
||||
<Badge variant={user.status === "active" ? "default" : "secondary"} className="h-6 px-3">
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "secondary"}
|
||||
className="h-6 px-3"
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -516,49 +550,94 @@ function UserDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start md:items-end text-[10px] text-muted-foreground gap-1.5 uppercase tracking-widest font-bold opacity-70">
|
||||
<p>{t("ui.admin.users.detail.created_at", "가입일")}: {user.createdAt}</p>
|
||||
<p>{t("ui.admin.users.detail.updated_at", "최근 수정")}: {user.updatedAt}</p>
|
||||
<p>
|
||||
{t("ui.admin.users.detail.created_at", "가입일")}: {user.createdAt}
|
||||
</p>
|
||||
<p>
|
||||
{t("ui.admin.users.detail.updated_at", "최근 수정")}:{" "}
|
||||
{user.updatedAt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<TabsList className="bg-muted/50 p-1.5 rounded-xl inline-flex w-full md:w-auto">
|
||||
<TabsTrigger value="info" className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm">
|
||||
<TabsTrigger
|
||||
value="info"
|
||||
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Users size={16} className="mr-2" />
|
||||
{t("ui.admin.users.detail.tabs.info", "기본 정보")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tenants" className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm">
|
||||
<TabsTrigger
|
||||
value="tenants"
|
||||
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Building2 size={16} className="mr-2" />
|
||||
{t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm">
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Shield size={16} className="mr-2" />
|
||||
{t("ui.admin.users.detail.tabs.security", "보안 & 활동")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<TabsContent value="info" className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2">
|
||||
<TabsContent
|
||||
value="info"
|
||||
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
|
||||
>
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl overflow-hidden">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BadgeCheck size={18} className="text-primary" />
|
||||
{t("ui.admin.users.detail.edit_title", "프로필 정보")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("msg.admin.users.detail.edit_subtitle", "{{email}} 계정의 정보를 수정합니다.", { email: user.email })}</CardDescription>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.detail.edit_subtitle",
|
||||
"{{email}} 계정의 정보를 수정합니다.",
|
||||
{ email: user.email },
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8 p-8">
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-xs font-bold uppercase text-muted-foreground">{t("ui.admin.users.detail.form.email", "이메일")}</Label>
|
||||
<Input id="email" value={user.email} disabled className="bg-muted/50 border-none font-medium h-11" />
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t("ui.admin.users.detail.form.email", "이메일")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={user.email}
|
||||
disabled
|
||||
className="bg-muted/50 border-none font-medium h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-xs font-bold uppercase text-muted-foreground">{t("ui.admin.users.detail.form.name", "이름")}</Label>
|
||||
<Label
|
||||
htmlFor="name"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t("ui.admin.users.detail.form.name", "이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register("name", {
|
||||
required: t("msg.admin.users.detail.form.name_required", "이름은 필수입니다."),
|
||||
required: t(
|
||||
"msg.admin.users.detail.form.name_required",
|
||||
"이름은 필수입니다.",
|
||||
),
|
||||
})}
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
@@ -567,38 +646,86 @@ function UserDetailPage() {
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-xs font-bold uppercase text-muted-foreground">{t("ui.admin.users.detail.form.phone", "연락처")}</Label>
|
||||
<Input id="phone" {...register("phone")} className="h-11 shadow-sm" />
|
||||
<Label
|
||||
htmlFor="phone"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t("ui.admin.users.detail.form.phone", "연락처")}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
{...register("phone")}
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role" className="text-xs font-bold uppercase text-muted-foreground">{t("ui.admin.users.detail.form.role", "권한")}</Label>
|
||||
<Label
|
||||
htmlFor="role"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t("ui.admin.users.detail.form.role", "권한")}
|
||||
</Label>
|
||||
<select
|
||||
id="role"
|
||||
className="flex h-11 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"
|
||||
{...register("role")}
|
||||
>
|
||||
<option value="user">{t("ui.admin.users.detail.form.role_user", "사용자")}</option>
|
||||
<option value="rp_admin">{t("ui.admin.users.detail.form.role_rp_admin", "RP 관리자")}</option>
|
||||
<option value="tenant_admin">{t("ui.admin.users.detail.form.role_tenant_admin", "테넌트 관리자")}</option>
|
||||
<option value="super_admin">{t("ui.admin.users.detail.form.role_super_admin", "시스템 전역 관리자")}</option>
|
||||
<option value="user">
|
||||
{t("ui.admin.users.detail.form.role_user", "사용자")}
|
||||
</option>
|
||||
<option value="rp_admin">
|
||||
{t(
|
||||
"ui.admin.users.detail.form.role_rp_admin",
|
||||
"RP 관리자",
|
||||
)}
|
||||
</option>
|
||||
<option value="tenant_admin">
|
||||
{t(
|
||||
"ui.admin.users.detail.form.role_tenant_admin",
|
||||
"테넌트 관리자",
|
||||
)}
|
||||
</option>
|
||||
<option value="super_admin">
|
||||
{t(
|
||||
"ui.admin.users.detail.form.role_super_admin",
|
||||
"시스템 전역 관리자",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status" className="text-xs font-bold uppercase text-muted-foreground">{t("ui.admin.users.detail.form.status", "상태")}</Label>
|
||||
<Label
|
||||
htmlFor="status"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t("ui.admin.users.detail.form.status", "상태")}
|
||||
</Label>
|
||||
<select
|
||||
id="status"
|
||||
className="flex h-11 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"
|
||||
{...register("status")}
|
||||
>
|
||||
<option value="active">{t("ui.common.status.active", "활성")}</option>
|
||||
<option value="inactive">{t("ui.common.status.inactive", "비활성")}</option>
|
||||
<option value="active">
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</option>
|
||||
<option value="inactive">
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenantSlug" className="text-xs font-bold uppercase text-muted-foreground">{t("ui.admin.users.detail.form.tenant_slug", "대표 소속 (Tenant Slug)")}</Label>
|
||||
<Label
|
||||
htmlFor="tenantSlug"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.tenant_slug",
|
||||
"대표 소속 (Tenant Slug)",
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="tenantSlug"
|
||||
@@ -610,7 +737,10 @@ function UserDetailPage() {
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t("ui.admin.users.detail.form.tenant_global", "시스템 전역 (소속 없음)")}
|
||||
{t(
|
||||
"ui.admin.users.detail.form.tenant_global",
|
||||
"시스템 전역 (소속 없음)",
|
||||
)}
|
||||
</option>
|
||||
{userAffiliatedTenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
@@ -624,58 +754,116 @@ function UserDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
* {t("msg.admin.users.detail.tenant_slug_help", "사용자의 주된 정체성을 결정하는 대표 조직을 지정합니다.")}
|
||||
*{" "}
|
||||
{t(
|
||||
"msg.admin.users.detail.tenant_slug_help",
|
||||
"사용자의 주된 정체성을 결정하는 대표 조직을 지정합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department" className="text-xs font-bold uppercase text-muted-foreground">{t("ui.admin.users.detail.form.department", "부서/학과")}</Label>
|
||||
<Input id="department" {...register("department")} className="h-11 shadow-sm" />
|
||||
<Label
|
||||
htmlFor="department"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t("ui.admin.users.detail.form.department", "부서/학과")}
|
||||
</Label>
|
||||
<Input
|
||||
id="department"
|
||||
{...register("department")}
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position" className="text-xs font-bold uppercase text-muted-foreground">{t("ui.admin.users.detail.form.position", "직급")}</Label>
|
||||
<Input id="position" {...register("position")} className="h-11 shadow-sm" />
|
||||
<Label
|
||||
htmlFor="position"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t("ui.admin.users.detail.form.position", "직급")}
|
||||
</Label>
|
||||
<Input
|
||||
id="position"
|
||||
{...register("position")}
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jobTitle" className="text-xs font-bold uppercase text-muted-foreground">{t("ui.admin.users.detail.form.job_title", "직무")}</Label>
|
||||
<Input id="jobTitle" {...register("jobTitle")} className="h-11 shadow-sm" />
|
||||
<Label
|
||||
htmlFor="jobTitle"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t("ui.admin.users.detail.form.job_title", "직무")}
|
||||
</Label>
|
||||
<Input
|
||||
id="jobTitle"
|
||||
{...register("jobTitle")}
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={mutation.isPending} className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105">
|
||||
{mutation.isPending ? <Loader2 className="mr-2 h-5 w-5 animate-spin" /> : <Save className="mr-2 h-5 w-5" />}
|
||||
<span className="text-base font-bold">{t("ui.admin.users.detail.save", "저장하기")}</span>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<span className="text-base font-bold">
|
||||
{t("ui.admin.users.detail.save", "저장하기")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tenants" className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2">
|
||||
<TabsContent
|
||||
value="tenants"
|
||||
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
|
||||
>
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Building2 size={18} className="text-primary" />
|
||||
{t("ui.admin.users.detail.custom_fields.multi_title", "테넌트별 상세 프로필")}
|
||||
{t(
|
||||
"ui.admin.users.detail.custom_fields.multi_title",
|
||||
"테넌트별 상세 프로필",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.users.detail.tenants_desc", "각 테넌트별로 정의된 커스텀 스키마 정보를 관리합니다.")}
|
||||
{t(
|
||||
"msg.admin.users.detail.tenants_desc",
|
||||
"각 테넌트별로 정의된 커스텀 스키마 정보를 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8 p-8">
|
||||
{userAffiliatedTenants.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
|
||||
<Building2 size={48} className="mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium">{t("msg.admin.users.detail.no_tenants", "소속된 테넌트 정보가 없습니다.")}</p>
|
||||
<p className="font-medium">
|
||||
{t(
|
||||
"msg.admin.users.detail.no_tenants",
|
||||
"소속된 테넌트 정보가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-8">
|
||||
{userAffiliatedTenants.map((t) => {
|
||||
const tDetail = tenants.find((tenant) => tenant.id === t.id);
|
||||
const schema = (tDetail?.config?.userSchema || []) as UserSchemaField[];
|
||||
const tDetail = tenants.find(
|
||||
(tenant) => tenant.id === t.id,
|
||||
);
|
||||
const schema = (tDetail?.config?.userSchema ||
|
||||
[]) as UserSchemaField[];
|
||||
return (
|
||||
<TenantMetadataFields
|
||||
key={t.id}
|
||||
@@ -692,15 +880,31 @@ function UserDetailPage() {
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={mutation.isPending} className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105">
|
||||
{mutation.isPending ? <Loader2 className="mr-2 h-5 w-5 animate-spin" /> : <Save className="mr-2 h-5 w-5" />}
|
||||
<span className="text-base font-bold">{t("ui.admin.users.detail.save_tenants", "모든 테넌트 프로필 저장")}</span>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<span className="text-base font-bold">
|
||||
{t(
|
||||
"ui.admin.users.detail.save_tenants",
|
||||
"모든 테넌트 프로필 저장",
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</form>
|
||||
|
||||
<TabsContent value="security" className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2">
|
||||
<TabsContent
|
||||
value="security"
|
||||
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl h-fit">
|
||||
<CardHeader>
|
||||
@@ -708,19 +912,35 @@ function UserDetailPage() {
|
||||
<Key size={18} className="text-primary" />
|
||||
{t("ui.admin.users.detail.password_title", "비밀번호 관리")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("msg.admin.users.detail.security_desc", "비밀번호 초기화 및 보안 설정을 관리합니다.")}</CardDescription>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.detail.security_desc",
|
||||
"비밀번호 초기화 및 보안 설정을 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between rounded-2xl border bg-muted/20 px-6 py-6 transition-all hover:bg-muted/30">
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-sm">
|
||||
{t("ui.admin.users.detail.reset_password_label", "비밀번호 초기화")}
|
||||
{t(
|
||||
"ui.admin.users.detail.reset_password_label",
|
||||
"비밀번호 초기화",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("msg.admin.users.detail.reset_password_help", "안전한 새 비밀번호로 교체합니다.")}
|
||||
{t(
|
||||
"msg.admin.users.detail.reset_password_help",
|
||||
"안전한 새 비밀번호로 교체합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleOpenPasswordReset} disabled={isSelf} className="h-10 rounded-xl px-5 border-primary/20 hover:border-primary/50 hover:bg-primary/5">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenPasswordReset}
|
||||
disabled={isSelf}
|
||||
className="h-10 rounded-xl px-5 border-primary/20 hover:border-primary/50 hover:bg-primary/5"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.users.detail.reset_password", "초기화 도구")}
|
||||
</Button>
|
||||
@@ -729,45 +949,77 @@ function UserDetailPage() {
|
||||
{isSelf && (
|
||||
<div className="rounded-xl bg-blue-500/5 border border-blue-500/10 px-5 py-4 text-sm text-blue-600 flex items-center gap-3">
|
||||
<Shield size={18} className="shrink-0" />
|
||||
<p className="leading-relaxed">{t("msg.admin.users.detail.self_password_reset_blocked", "보안을 위해 본인 계정은 사용자 포털에서만 변경 가능합니다.")}</p>
|
||||
<p className="leading-relaxed">
|
||||
{t(
|
||||
"msg.admin.users.detail.self_password_reset_blocked",
|
||||
"보안을 위해 본인 계정은 사용자 포털에서만 변경 가능합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPasswordResetOpen && !generatedPassword && !isSelf && (
|
||||
<div className="mt-4 p-6 border rounded-2xl bg-card shadow-sm animate-in zoom-in-95 duration-200">
|
||||
<Tabs value={passwordResetMode} onValueChange={(v) => setPasswordResetMode(v as PasswordResetMode)}>
|
||||
<Tabs
|
||||
value={passwordResetMode}
|
||||
onValueChange={(v) =>
|
||||
setPasswordResetMode(v as PasswordResetMode)
|
||||
}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<TabsTrigger
|
||||
value="auto"
|
||||
<TabsTrigger
|
||||
value="auto"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
|
||||
>
|
||||
{t("ui.admin.users.detail.reset_auto", "자동 생성")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="manual"
|
||||
<TabsTrigger
|
||||
value="manual"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
|
||||
>
|
||||
{t("ui.admin.users.detail.reset_manual", "직접 입력")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
<TabsContent value="auto" className="space-y-4">
|
||||
<div className="bg-muted/50 p-4 rounded-xl text-xs text-muted-foreground leading-relaxed">
|
||||
{t("msg.admin.users.detail.reset_auto_desc", "해킹이 어려운 복잡한 임시 비밀번호를 시스템이 즉시 생성합니다.")}
|
||||
{t(
|
||||
"msg.admin.users.detail.reset_auto_desc",
|
||||
"해킹이 어려운 복잡한 임시 비밀번호를 시스템이 즉시 생성합니다.",
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" onClick={() => setManualPassword(generateSecurePassword())} variant="secondary" className="w-full h-11 rounded-xl font-bold">
|
||||
{t("ui.admin.users.detail.generate_button", "랜덤 비밀번호 생성")}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setManualPassword(generateSecurePassword())
|
||||
}
|
||||
variant="secondary"
|
||||
className="w-full h-11 rounded-xl font-bold"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.generate_button",
|
||||
"랜덤 비밀번호 생성",
|
||||
)}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="space-y-5 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold uppercase">{t("ui.admin.users.detail.manual_password", "새 비밀번호")}</Label>
|
||||
<Label className="text-xs font-bold uppercase">
|
||||
{t(
|
||||
"ui.admin.users.detail.manual_password",
|
||||
"새 비밀번호",
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={isManualPasswordVisible ? "text" : "password"}
|
||||
type={
|
||||
isManualPasswordVisible ? "text" : "password"
|
||||
}
|
||||
value={manualPassword}
|
||||
onChange={(e) => setManualPassword(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setManualPassword(e.target.value)
|
||||
}
|
||||
className="h-11 rounded-xl shadow-sm pr-12"
|
||||
/>
|
||||
<Button
|
||||
@@ -775,18 +1027,33 @@ function UserDetailPage() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent text-muted-foreground"
|
||||
onClick={() => setIsManualPasswordVisible(!isManualPasswordVisible)}
|
||||
onClick={() =>
|
||||
setIsManualPasswordVisible(
|
||||
!isManualPasswordVisible,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isManualPasswordVisible ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
{isManualPasswordVisible ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold uppercase">{t("ui.admin.users.detail.manual_confirm", "비밀번호 확인")}</Label>
|
||||
<Label className="text-xs font-bold uppercase">
|
||||
{t(
|
||||
"ui.admin.users.detail.manual_confirm",
|
||||
"비밀번호 확인",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={manualPasswordConfirm}
|
||||
onChange={(e) => setManualPasswordConfirm(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setManualPasswordConfirm(e.target.value)
|
||||
}
|
||||
className="h-11 rounded-xl shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -795,15 +1062,34 @@ function UserDetailPage() {
|
||||
{passwordResetError && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive mt-4 p-3 bg-destructive/5 rounded-lg border border-destructive/10">
|
||||
<Shield size={14} />
|
||||
<span className="font-medium">{passwordResetError}</span>
|
||||
<span className="font-medium">
|
||||
{passwordResetError}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 mt-8">
|
||||
<Button variant="ghost" type="button" onClick={handleClosePasswordReset} className="h-11 rounded-xl px-6 font-bold">{t("ui.common.cancel", "취소")}</Button>
|
||||
<Button type="button" onClick={handleExecutePasswordReset} disabled={resetMutation.isPending} className="h-11 rounded-xl px-8 font-bold shadow-md">
|
||||
{resetMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("ui.admin.users.detail.reset_execute", "재설정 완료")}
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={handleClosePasswordReset}
|
||||
className="h-11 rounded-xl px-6 font-bold"
|
||||
>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleExecutePasswordReset}
|
||||
disabled={resetMutation.isPending}
|
||||
className="h-11 rounded-xl px-8 font-bold shadow-md"
|
||||
>
|
||||
{resetMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t(
|
||||
"ui.admin.users.detail.reset_execute",
|
||||
"재설정 완료",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Tabs>
|
||||
@@ -814,19 +1100,37 @@ function UserDetailPage() {
|
||||
<div className="mt-4 p-8 bg-green-500/10 border border-green-500/20 rounded-2xl space-y-6 animate-in zoom-in-95">
|
||||
<div className="flex items-center gap-3 text-green-700 font-extrabold text-lg">
|
||||
<BadgeCheck size={28} className="text-green-600" />
|
||||
{t("ui.admin.users.detail.password_done", "성공적으로 초기화됨")}
|
||||
{t(
|
||||
"ui.admin.users.detail.password_done",
|
||||
"성공적으로 초기화됨",
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 bg-white border border-green-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<code className="text-2xl font-mono tracking-widest text-primary selection:bg-primary selection:text-white">{generatedPassword}</code>
|
||||
<Button variant="ghost" size="sm" onClick={() => {
|
||||
navigator.clipboard.writeText(generatedPassword);
|
||||
toast.success(t("msg.common.copied", "복사되었습니다."));
|
||||
}} className="h-10 px-4 rounded-xl hover:bg-green-50 font-bold">
|
||||
<code className="text-2xl font-mono tracking-widest text-primary selection:bg-primary selection:text-white">
|
||||
{generatedPassword}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(generatedPassword);
|
||||
toast.success(
|
||||
t("msg.common.copied", "복사되었습니다."),
|
||||
);
|
||||
}}
|
||||
className="h-10 px-4 rounded-xl hover:bg-green-50 font-bold"
|
||||
>
|
||||
<Copy size={16} className="mr-2" />
|
||||
{t("ui.common.copy", "복사")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button className="w-full h-12 rounded-xl font-bold bg-green-600 hover:bg-green-700 shadow-md" type="button" onClick={handleClosePasswordReset}>{t("ui.common.close", "안전하게 도구 닫기")}</Button>
|
||||
<Button
|
||||
className="w-full h-12 rounded-xl font-bold bg-green-600 hover:bg-green-700 shadow-md"
|
||||
type="button"
|
||||
onClick={handleClosePasswordReset}
|
||||
>
|
||||
{t("ui.common.close", "안전하게 도구 닫기")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -838,26 +1142,50 @@ function UserDetailPage() {
|
||||
<History size={18} className="text-primary" />
|
||||
{t("ui.admin.users.detail.history_title", "서비스 이용 내역")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("msg.admin.users.detail.history_desc", "최근 로그인한 연동 서비스(RP) 목록입니다.")}</CardDescription>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.detail.history_desc",
|
||||
"최근 로그인한 연동 서비스(RP) 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-8 pt-2">
|
||||
{rpHistoryQuery.isLoading ? (
|
||||
<div className="py-12 text-center text-muted-foreground animate-pulse">{t("msg.common.loading", "불러오는 중...")}</div>
|
||||
<div className="py-12 text-center text-muted-foreground animate-pulse">
|
||||
{t("msg.common.loading", "불러오는 중...")}
|
||||
</div>
|
||||
) : !rpHistoryQuery.data || rpHistoryQuery.data.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
|
||||
<History size={40} className="mx-auto mb-4 opacity-10" />
|
||||
<p className="text-sm font-medium">{t("msg.admin.users.detail.no_history", "아직 이용한 서비스가 없습니다.")}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{t(
|
||||
"msg.admin.users.detail.no_history",
|
||||
"아직 이용한 서비스가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{rpHistoryQuery.data.map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-5 rounded-2xl border bg-card hover:border-primary/40 hover:shadow-md transition-all group">
|
||||
{rpHistoryQuery.data.map((item) => (
|
||||
<div
|
||||
key={item.client_id || item.clientId}
|
||||
className="flex items-center justify-between p-5 rounded-2xl border bg-card hover:border-primary/40 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-extrabold text-base group-hover:text-primary transition-colors">{item.clientName || item.clientId}</span>
|
||||
<span className="text-[11px] text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded w-fit">{item.clientId}</span>
|
||||
<span className="font-extrabold text-base group-hover:text-primary transition-colors">
|
||||
{item.clientName || item.clientId}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded w-fit">
|
||||
{item.clientId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge variant="outline" className="text-[10px] font-bold px-2 py-1 rounded-md border-primary/20">{item.lastLoginAt}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-bold px-2 py-1 rounded-md border-primary/20"
|
||||
>
|
||||
{item.lastLoginAt}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user