From 468ca475edb449818f04dc3d0ed6b3226cac4de4 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 31 Mar 2026 10:15:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B8=EC=9D=B8=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/users/UserDetailPage.tsx | 383 +++++++++++++++++- adminfront/src/locales/en.toml | 16 + adminfront/src/locales/ko.toml | 16 + adminfront/src/locales/template.toml | 16 + 4 files changed, 410 insertions(+), 21 deletions(-) diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index fc5730fe..de2e495b 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -5,6 +5,8 @@ import { BadgeCheck, Building2, Copy, + Eye, + EyeOff, Key, Loader2, Mail, @@ -32,16 +34,19 @@ import { } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; import { toast } from "../../components/ui/use-toast"; import { type UserSummary, type UserUpdateRequest, deleteUser, + fetchPasswordPolicy, fetchMe, fetchTenants, fetchUser, updateUser, } from "../../lib/adminApi"; +import type { PasswordPolicyResponse } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { generateSecurePassword } from "../../lib/utils"; @@ -58,6 +63,116 @@ type UserFormValues = Omit & { metadata: Record>; }; +type PasswordResetMode = "generated" | "manual"; + +const PASSWORD_RESET_MIN_LENGTH = 12; + +function buildPasswordPolicyDescription(policy?: PasswordPolicyResponse) { + const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH; + const minTypes = policy?.minCharacterTypes ?? 0; + const requiresLower = policy?.lowercase ?? true; + const requiresUpper = policy?.uppercase ?? false; + const requiresNumber = policy?.number ?? true; + const requiresSymbol = policy?.nonAlphanumeric ?? true; + + const parts = [ + t("msg.userfront.signup.policy.min_length", "최소 {{count}}자 이상", { + count: String(minLength), + }), + ]; + + if (minTypes > 0) { + parts.push( + t( + "msg.userfront.signup.policy.min_types", + "영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상", + { count: String(minTypes) }, + ), + ); + } + if (requiresLower) { + parts.push(t("msg.userfront.signup.policy.lowercase", "소문자")); + } + if (requiresUpper) { + parts.push(t("msg.userfront.signup.policy.uppercase", "대문자")); + } + if (requiresNumber) { + parts.push(t("msg.userfront.signup.policy.number", "숫자")); + } + if (requiresSymbol) { + parts.push(t("msg.userfront.signup.policy.symbol", "특수문자")); + } + + return parts.join(", "); +} + +function validateManualPassword( + password: string, + policy?: PasswordPolicyResponse, +) { + if (password.trim().length === 0) { + return t( + "msg.admin.users.detail.password_manual_required", + "비밀번호를 입력해 주세요.", + ); + } + + const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH; + if (password.length < minLength) { + return t( + "msg.userfront.reset.error.min_length", + "비밀번호는 최소 {{count}}자 이상이어야 합니다.", + { count: String(minLength) }, + ); + } + + const hasLower = /[a-z]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasNumber = /[0-9]/.test(password); + const hasSymbol = /[\W_]/.test(password); + let typeCount = 0; + if (hasLower) typeCount++; + if (hasUpper) typeCount++; + if (hasNumber) typeCount++; + if (hasSymbol) typeCount++; + + const minTypes = policy?.minCharacterTypes ?? 0; + if (minTypes > 0 && typeCount < minTypes) { + return t( + "msg.userfront.reset.error.min_types", + "비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.", + { count: String(minTypes) }, + ); + } + + if ((policy?.lowercase ?? true) && !hasLower) { + return t( + "msg.userfront.reset.error.lowercase", + "최소 1개 이상의 소문자를 포함해야 합니다.", + ); + } + if ((policy?.uppercase ?? false) && !hasUpper) { + return t( + "msg.userfront.reset.error.uppercase", + "최소 1개 이상의 대문자를 포함해야 합니다.", + ); + } + if ((policy?.number ?? true) && !hasNumber) { + return t( + "msg.userfront.reset.error.number", + "최소 1개 이상의 숫자를 포함해야 합니다.", + ); + } + if ((policy?.nonAlphanumeric ?? true) && !hasSymbol) { + return t( + "msg.userfront.reset.error.symbol", + "최소 1개 이상의 특수문자를 포함해야 합니다.", + ); + } + + return null; +} + function TenantMetadataFields({ tenant, schema, @@ -166,6 +281,15 @@ function UserDetailPage() { const [generatedPassword, setGeneratedPassword] = React.useState< string | null >(null); + const [passwordResetMode, setPasswordResetMode] = + React.useState("generated"); + const [manualPassword, setManualPassword] = React.useState(""); + const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState(""); + const [isManualPasswordVisible, setIsManualPasswordVisible] = + React.useState(false); + const [passwordResetError, setPasswordResetError] = React.useState< + string | null + >(null); const { data: profile } = useQuery({ queryKey: ["me"], @@ -187,6 +311,10 @@ function UserDetailPage() { queryFn: () => fetchTenants(100, 0), }); const tenants = tenantsData?.items ?? []; + const { data: passwordPolicy, isLoading: isPasswordPolicyLoading } = useQuery({ + queryKey: ["password-policy"], + queryFn: fetchPasswordPolicy, + }); const { register, @@ -211,11 +339,13 @@ function UserDetailPage() { const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin"; + const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id); const resetPasswordMutation = useMutation({ mutationFn: (newPass: string) => updateUser(userId, { password: newPass }), onSuccess: (_, newPass) => { setGeneratedPassword(newPass); + setPasswordResetError(null); toast.success( t( "msg.admin.users.detail.password_generated", @@ -224,20 +354,67 @@ function UserDetailPage() { ); }, onError: (err: AxiosError<{ error?: string }>) => { - toast.error( + const message = err.response?.data?.error || - t("msg.admin.users.detail.update_error", "수정에 실패했습니다."), - ); + t("msg.admin.users.detail.update_error", "수정에 실패했습니다."); + setPasswordResetError(message); + toast.error(message); }, }); - const handleGeneratePassword = () => { + const handleOpenPasswordReset = () => { + if (isSelf) { + return; + } setIsPasswordResetOpen(true); setGeneratedPassword(null); + setPasswordResetMode("generated"); + setManualPassword(""); + setManualPasswordConfirm(""); + setIsManualPasswordVisible(false); + setPasswordResetError(null); }; - const confirmGeneratePassword = () => { - const newPass = generateSecurePassword(); + const handleClosePasswordReset = () => { + setIsPasswordResetOpen(false); + setGeneratedPassword(null); + setPasswordResetMode("generated"); + setManualPassword(""); + setManualPasswordConfirm(""); + setIsManualPasswordVisible(false); + setPasswordResetError(null); + }; + + const confirmPasswordReset = () => { + if (isSelf) { + return; + } + + let newPass = manualPassword; + + if (passwordResetMode === "manual") { + const validationError = validateManualPassword( + manualPassword, + passwordPolicy, + ); + if (validationError) { + setPasswordResetError(validationError); + return; + } + if (manualPassword !== manualPasswordConfirm) { + setPasswordResetError( + t( + "msg.userfront.reset.error.mismatch", + "비밀번호가 일치하지 않습니다.", + ), + ); + return; + } + } else { + newPass = generateSecurePassword(); + } + + setPasswordResetError(null); resetPasswordMutation.mutate(newPass); }; @@ -717,7 +894,7 @@ function UserDetailPage() { -
+

{t( @@ -726,44 +903,205 @@ function UserDetailPage() { )}

- 사용자의 비밀번호를 강제로 재설정하고 새 비밀번호를 - 생성합니다. + {t( + "msg.admin.users.detail.reset_password_help", + "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다.", + )}

-
- {isPasswordResetOpen && !generatedPassword && ( -
-

+ {isSelf && ( +

+

{t( - "msg.admin.users.detail.reset_password_confirm", - "정말로 이 사용자의 비밀번호를 초기화하시겠습니까? 기존 비밀번호로는 즉시 로그인할 수 없게 됩니다.", + "msg.admin.users.detail.self_password_reset_blocked", + "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요.", )}

+
+ )} + + {isPasswordResetOpen && !generatedPassword && !isSelf && ( +
+ { + setPasswordResetMode(value as PasswordResetMode); + setPasswordResetError(null); + }} + > + + + {t( + "ui.admin.users.detail.password_mode_generated", + "자동 생성", + )} + + + {t( + "ui.admin.users.detail.password_mode_manual", + "수동 입력", + )} + + + +

+ {t( + "msg.admin.users.detail.password_generated_help", + "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다.", + )} +

+
+ +

+ {isPasswordPolicyLoading + ? t( + "msg.userfront.signup.policy.loading", + "비밀번호 정책을 불러오는 중입니다...", + ) + : t( + "msg.userfront.signup.policy.summary", + "보안 정책: {{rules}}", + { + rules: buildPasswordPolicyDescription( + passwordPolicy, + ), + }, + )} +

+
+
+ { + setManualPassword(event.target.value); + if (passwordResetError) { + setPasswordResetError(null); + } + }} + /> + + +
+
+
+
+ { + setManualPasswordConfirm(event.target.value); + if (passwordResetError) { + setPasswordResetError(null); + } + }} + /> + + +
+
+
+
+ {passwordResetError && ( +

+ {passwordResetError} +

+ )}
@@ -774,7 +1112,10 @@ function UserDetailPage() {

- Generated Password + {t( + "ui.admin.users.detail.password_result_title", + "Reset Password", + )}

{generatedPassword} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index dbca6282..a4b19113 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -288,6 +288,12 @@ name_required = "Name Required" [msg.admin.users.detail.security] password_hint = "Password Hint" +password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately." +password_manual_help = "Enter and apply a password with at least {{count}} characters." +password_manual_min_length = "Password must be at least {{count}} characters long." +password_manual_required = "Please enter a password." +reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one." +self_password_reset_blocked = "Please change your own password from the UserFront settings page." [msg.admin.users.list] delete_confirm = "Delete Confirm" @@ -1094,6 +1100,16 @@ password = "Password" password_placeholder = "Password Placeholder" title = "Security Settings" +[ui.admin.users.detail] +manual_password = "New Password" +manual_password_placeholder = "Enter a new password" +password_mode_generated = "Auto Generate" +password_mode_manual = "Manual Input" +password_result_title = "Reset Password" +reset_password = "Reset & Set" +reset_password_apply = "Apply Password" +toggle_password_visibility = "Toggle password visibility" + [ui.admin.users.detail.tenants_section] additional = "Additional Affiliated/Manageable Tenants" primary = "Representative Affiliated Tenant" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 843717bc..92ad133a 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -288,6 +288,12 @@ name_required = "이름은 필수입니다." [msg.admin.users.detail.security] password_hint = "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다." +password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다." +password_manual_help = "최소 {{count}}자 이상의 비밀번호를 직접 입력해 적용합니다." +password_manual_min_length = "비밀번호는 최소 {{count}}자 이상이어야 합니다." +password_manual_required = "비밀번호를 입력해 주세요." +reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다." +self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요." [msg.admin.users.list] delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?" @@ -1094,6 +1100,16 @@ password = "비밀번호 변경" password_placeholder = "변경할 경우에만 입력" title = "보안 설정" +[ui.admin.users.detail] +manual_password = "새 비밀번호" +manual_password_placeholder = "새 비밀번호를 입력하세요" +password_mode_generated = "자동 생성" +password_mode_manual = "수동 입력" +password_result_title = "Reset Password" +reset_password = "초기화 및 설정" +reset_password_apply = "비밀번호 적용" +toggle_password_visibility = "비밀번호 표시 전환" + [ui.admin.users.detail.tenants_section] additional = "추가 소속/관리 테넌트" primary = "대표 소속 테넌트" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 853b4355..5766218e 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -288,6 +288,12 @@ name_required = "" [msg.admin.users.detail.security] password_hint = "" +password_generated_help = "" +password_manual_help = "" +password_manual_min_length = "" +password_manual_required = "" +reset_password_help = "" +self_password_reset_blocked = "" [msg.admin.users.list] delete_confirm = "" @@ -1094,6 +1100,16 @@ password = "" password_placeholder = "" title = "" +[ui.admin.users.detail] +manual_password = "" +manual_password_placeholder = "" +password_mode_generated = "" +password_mode_manual = "" +password_result_title = "" +reset_password = "" +reset_password_apply = "" +toggle_password_visibility = "" + [ui.admin.users.detail.tenants_section] additional = "" primary = ""