1
0
forked from baron/baron-sso

feat: 커스텀 필드 기반 로그인 ID 연동 기능 추가 (#440)

- Kratos Identity 스키마에 로그인 전용 `id` 속성 추가
- 테넌트 Config의 `loginIdField` 설정에 따라 User의 `login_id` 및 Kratos `traits.id` 동기화 로직 구현
- Admin UI 테넌트 스키마 설정 내 '로그인 ID로 사용' 체크박스 추가
- Admin UI 사용자 생성/수정/조회 화면에 로그인 ID 관리 필드 및 컬럼 반영
- Userfront 로그인 화면 접속 시 테넌트 설정에 따라 동적 로그인 ID 라벨 적용
- 관련 다국어(ko/en) 번역 추가 및 로그인 ID 설계 문서 업데이트
This commit is contained in:
2026-03-25 15:27:44 +09:00
parent 8cadd82a2b
commit d10f80d41d
18 changed files with 799 additions and 420 deletions

View File

@@ -34,6 +34,7 @@ type SchemaField = {
adminOnly: boolean;
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
};
function createFieldId() {
@@ -96,6 +97,8 @@ export function TenantSchemaPage() {
useEffect(() => {
const rawSchema = tenantQuery.data?.config?.userSchema;
const loginIdField = tenantQuery.data?.config?.loginIdField;
if (Array.isArray(rawSchema)) {
setFields(
rawSchema.map((field) => ({
@@ -115,19 +118,23 @@ export function TenantSchemaPage() {
validation:
typeof field?.validation === "string" ? field.validation : "",
unsigned: Boolean(field?.unsigned),
isLoginId: field?.key === loginIdField,
})),
);
}
}, [tenantQuery.data]);
const updateMutation = useMutation({
mutationFn: (newFields: SchemaField[]) =>
updateTenant(tenantId, {
mutationFn: (newFields: SchemaField[]) => {
const loginIdField = newFields.find((f) => f.isLoginId)?.key || "";
return updateTenant(tenantId, {
config: {
...tenantQuery.data?.config,
userSchema: newFields,
loginIdField: loginIdField,
},
}),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
toast.success(
@@ -334,6 +341,26 @@ export function TenantSchemaPage() {
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.isLoginId}
onChange={(e) => {
const newFields = fields.map((f, i) => ({
...f,
isLoginId: i === index ? e.target.checked : false,
}));
setFields(newFields);
}}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium text-blue-600">
{t(
"ui.admin.tenants.schema.field.is_login_id",
"로그인 ID로 사용",
)}
</span>
</label>
{(field.type === "number" || field.type === "float") && (
<label className="flex items-center gap-2 cursor-pointer">
<input

View File

@@ -65,6 +65,7 @@ function UserCreatePage() {
} = useForm<UserFormValues>({
defaultValues: {
email: "",
loginId: "",
password: "",
name: "",
phone: "",
@@ -273,6 +274,26 @@ function UserCreatePage() {
)}
</div>
<div className="space-y-2">
<Label htmlFor="loginId">
{t("ui.admin.users.create.form.login_id", "로그인 ID (선택)")}
</Label>
<Input
id="loginId"
placeholder={t(
"ui.admin.users.create.form.login_id_placeholder",
"사번 또는 아이디",
)}
{...register("loginId")}
/>
<p className="text-[10px] text-muted-foreground">
{t(
"msg.admin.users.create.form.login_id_help",
"이메일/전화번호 외에 별도의 식별자로 로그인할 때 사용합니다.",
)}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">

View File

@@ -5,20 +5,20 @@ import {
BadgeCheck,
Building2,
Copy,
Dices,
Eye,
EyeOff,
Key,
Loader2,
Mail,
RefreshCw,
Save,
Trash2,
UserCheck,
UserMinus,
Users,
} from "lucide-react";
import * as React from "react";
import {
type FieldErrors,
type UseFormRegister,
useForm,
} from "react-hook-form";
import { useForm } from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -31,8 +31,9 @@ import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { toast } from "../../components/ui/use-toast";
import {
type TenantSummary,
type UserSummary,
type UserUpdateRequest,
deleteUser,
fetchMe,
fetchTenant,
fetchTenants,
@@ -40,19 +41,7 @@ import {
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
// Utility for secure password generation
function generateSecurePassword(length = 16) {
const charset =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-=";
let retVal = "";
const values = new Uint32Array(length);
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
retVal += charset.charAt(values[i] % charset.length);
}
return retVal;
}
import { generateSecurePassword } from "../../lib/utils";
type UserSchemaField = {
key: string;
@@ -60,40 +49,19 @@ type UserSchemaField = {
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
};
type UserFormValues = UserUpdateRequest & {
metadata: Record<string, Record<string, unknown>>;
};
// [New] Component for per-tenant profile/schema management
function TenantProfileCard({
function TenantMetadataFields({
tenant,
schema,
register,
errors,
isAdmin,
}: {
tenant: TenantSummary;
register: UseFormRegister<UserFormValues>;
errors: FieldErrors<UserFormValues>;
isAdmin: boolean;
tenant: { id: string; name: string; slug: string };
schema: UserSchemaField[];
register: any;
errors: any;
}) {
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 (
@@ -171,6 +139,8 @@ function TenantProfileCard({
);
}
type UserFormValues = UserUpdateRequest & { metadata: Record<string, any> };
function UserDetailPage() {
const params = useParams<{ id: string }>();
const userId = params.id ?? "";
@@ -210,6 +180,7 @@ function UserDetailPage() {
formState: { errors },
} = useForm<UserFormValues>({
defaultValues: {
loginId: "",
name: "",
phone: "",
role: "user",
@@ -251,6 +222,7 @@ function UserDetailPage() {
React.useEffect(() => {
if (user) {
reset({
loginId: user.loginId || "",
name: user.name,
phone: user.phone || "",
role: user.role,
@@ -259,11 +231,7 @@ function UserDetailPage() {
department: user.department || "",
position: user.position || "",
jobTitle: user.jobTitle || "",
password: "",
metadata: (user.metadata || {}) as unknown as Record<
string,
Record<string, unknown>
>,
metadata: user.metadata || {},
});
}
}, [user, reset]);
@@ -279,404 +247,500 @@ function UserDetailPage() {
"사용자 정보가 수정되었습니다.",
),
);
setError(null);
toast.success(
t(
"msg.admin.users.detail.update_success",
"사용자 정보가 수정되었습니다.",
),
);
setTimeout(() => setSuccessMsg(null), 3000);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(
err.response?.data?.error ||
t(
"msg.admin.users.detail.update_error",
"사용자 수정에 실패했습니다.",
),
t("msg.admin.users.detail.update_error", "수정에 실패했습니다."),
);
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteUser(userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success(
t("msg.admin.users.detail.delete_success", "사용자가 삭제되었습니다."),
);
navigate("/users");
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.users.detail.delete_error", "삭제에 실패했습니다."),
);
setSuccessMsg(null);
},
});
const onSubmit = (data: UserFormValues) => {
const payload: UserUpdateRequest = { ...data };
if (!payload.password) {
payload.password = undefined;
setError(null);
setSuccessMsg(null);
mutation.mutate(data);
};
const handleDelete = () => {
if (
window.confirm(
t(
"msg.admin.users.detail.delete_confirm",
"정말로 이 사용자를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
),
)
) {
deleteMutation.mutate();
}
mutation.mutate(payload);
};
if (isLoading) {
return (
<div className="p-8 text-center">
{t("msg.common.loading", "Loading...")}
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (isError || !user) {
return (
<div className="p-8 text-center text-destructive">
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
<div className="rounded-md bg-destructive/15 p-6 text-center">
<p className="text-destructive">
{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>
);
}
// Combined affiliated tenants
const userAffiliatedTenants = [...(user.joinedTenants || [])];
if (
user.tenant &&
!userAffiliatedTenants.find((t) => t.id === user.tenant?.id)
) {
userAffiliatedTenants.push(user.tenant);
}
// Get only tenants that the user is actually affiliated with
const userAffiliatedTenants =
user.joinedTenants || (user.tenant ? [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">
<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>
<div className="space-y-6">
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => navigate("/users")} size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.back", "목록으로")}
</Button>
</header>
<div className="flex gap-2">
{isAdmin && (
<Button variant="destructive" onClick={handleDelete} size="sm">
<Trash2 className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.delete", "사용자 삭제")}
</Button>
)}
</div>
</div>
<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" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>
{t("ui.admin.users.detail.edit_title", "사용자 정보 수정")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.users.detail.tenants_section.title",
"소속 및 조직 정보",
"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>
)}
</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">
{/* 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.primary",
"대표 소속 테넌트",
"ui.admin.users.detail.tenants_section.title",
"소속 및 조직 정보",
)}
</Label>
</h3>
{/* 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("tenantSlug")}
disabled={
profile?.role === "tenant_admin" &&
userAffiliatedTenants.length <= 1
}
>
<option value="">
<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("tenantSlug")}
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",
"시스템 전역",
"시스템 전역 (소속 없음)",
)}
</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>
)}
<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("tenantSlug"))
?.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>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={t(
"ui.admin.users.detail.security.password_placeholder",
"변경할 경우에만 입력",
)}
className="font-mono"
{...register("password")}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<Button
type="button"
variant="outline"
onClick={handleGeneratePassword}
title={t(
"ui.admin.users.detail.generate_password",
"자동 생성",
{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("tenantSlug"),
)?.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>
)}
>
<Dices size={16} className="mr-2" />
{t("ui.common.generate", "생성")}
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyPassword}
disabled={!watch("password")}
title={t("ui.common.copy", "복사")}
>
<Copy size={16} />
</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="space-y-2">
<Label htmlFor="loginId">
{t("ui.admin.users.detail.form.login_id", "로그인 ID")}
</Label>
<Input
id="loginId"
placeholder={t(
"ui.admin.users.detail.form.login_id_placeholder",
"사번 또는 아이디",
)}
{...register("loginId")}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="role">
{t("ui.admin.users.detail.form.role", "권한")}
</Label>
<select
id="role"
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("role")}
>
<option value="user">
{t("ui.admin.users.detail.form.role_user", "일반 사용자")}
</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 className="space-y-2">
<Label htmlFor="status">
{t("ui.admin.users.detail.form.status", "상태")}
</Label>
<select
id="status"
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("status")}
>
<option value="active">
{t("ui.admin.users.detail.form.status_active", "활성")}
</option>
<option value="inactive">
{t("ui.admin.users.detail.form.status_inactive", "비활성")}
</option>
</select>
</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 className="space-y-2">
<Label htmlFor="position">
{t("ui.admin.users.detail.form.position", "직급")}
</Label>
<Input
id="position"
placeholder={t(
"ui.admin.users.detail.form.position_placeholder",
"수석",
)}
{...register("position")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="jobTitle">
{t("ui.admin.users.detail.form.job_title", "직무")}
</Label>
<Input
id="jobTitle"
placeholder={t(
"ui.admin.users.detail.form.job_title_placeholder",
"백엔드 개발",
)}
{...register("jobTitle")}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>{t("ui.admin.users.detail.metadata", "추가 정보")}</Label>
</div>
<div className="grid gap-4">
{userAffiliatedTenants.map((t) => {
const tDetail = tenants.find((tenant) => tenant.id === t.id);
const schema = (tDetail?.config?.userSchema ||
[]) as UserSchemaField[];
return (
<TenantMetadataFields
key={t.id}
tenant={t}
schema={schema}
register={register}
errors={errors}
/>
);
})}
</div>
</div>
<div className="flex justify-end pt-4">
<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.admin.users.detail.save", "변경사항 저장")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.users.detail.security.password_hint",
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
)}
</p>
</div>
</div>
</form>
</CardContent>
</Card>
<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>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Key size={18} />
{t("ui.admin.users.detail.password_title", "비밀번호 관리")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/20">
<div className="space-y-1">
<p className="text-sm font-medium">
{t(
"ui.admin.users.detail.reset_password_label",
"비밀번호 초기화",
)}
</p>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<Button variant="outline" onClick={handleGeneratePassword}>
<RefreshCw className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.reset_password", "초기화 및 생성")}
</Button>
</div>
{showPassword && (
<div className="p-4 border border-dashed rounded-lg bg-yellow-500/5 flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-xs font-bold text-yellow-600 uppercase tracking-wider">
Generated Password
</p>
<p className="font-mono text-lg font-bold">
{watch("password")}
</p>
</div>
<Button size="sm" variant="secondary" onClick={handleCopyPassword}>
<Copy className="mr-2 h-4 w-4" />
{t("ui.common.copy", "복사")}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t("ui.admin.users.detail.status_title", "계정 상태")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<UserCheck size={24} />
</div>
<div>
<div className="flex items-center gap-2">
<Badge variant={user.status === "active" ? "default" : "secondary"}>
{user.status === "active" ? "Active" : "Inactive"}
</Badge>
<Badge variant="outline">{user.role}</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
{t("ui.admin.users.detail.created_at", "가입일")}:{" "}
{new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t("ui.admin.users.detail.contact_title", "연락처 정보")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<Mail size={14} className="text-muted-foreground" />
<span>{user.email}</span>
</div>
{user.phone && (
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="font-normal">
SMS
</Badge>
<span>{user.phone}</span>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -430,6 +430,9 @@ function UserListPage() {
"NAME / EMAIL",
)}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.login_id", "LOGIN ID")}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.role", "ROLE")}
</TableHead>
@@ -511,6 +514,11 @@ function UserListPage() {
</div>
</div>
</TableCell>
<TableCell>
<span className="text-sm font-mono">
{user.loginId || "-"}
</span>
</TableCell>
<TableCell>
<Badge variant="outline">
{t(`ui.admin.role.${user.role}`, user.role)}