1
0
forked from baron/baron-sso

본인 계정 비밀번호 초기화 기능 제한

This commit is contained in:
2026-03-31 10:15:06 +09:00
parent 33afe1eddf
commit 468ca475ed
4 changed files with 410 additions and 21 deletions

View File

@@ -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<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, string | number | boolean>>;
};
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<PasswordResetMode>("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() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/20">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-4">
<div className="space-y-1">
<p className="text-sm font-medium">
{t(
@@ -726,44 +903,205 @@ function UserDetailPage() {
)}
</p>
<p className="text-xs text-muted-foreground">
.
{t(
"msg.admin.users.detail.reset_password_help",
"사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다.",
)}
</p>
</div>
<Button variant="outline" onClick={handleGeneratePassword}>
<Button
variant="outline"
onClick={handleOpenPasswordReset}
disabled={isSelf}
>
<RefreshCw className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.reset_password", "초기화 및 생성")}
{t("ui.admin.users.detail.reset_password", "초기화 및 설정")}
</Button>
</div>
{isPasswordResetOpen && !generatedPassword && (
<div className="p-4 border rounded-lg bg-destructive/5 space-y-4">
<p className="text-sm">
{isSelf && (
<div className="rounded-lg border px-4 py-3">
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.users.detail.reset_password_confirm",
"정말로 이 사용자의 비밀번호를 초기화하시겠습니까? 기존 비밀번호로는 즉시 로그인할 수 없게 됩니다.",
"msg.admin.users.detail.self_password_reset_blocked",
"본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요.",
)}
</p>
</div>
)}
{isPasswordResetOpen && !generatedPassword && !isSelf && (
<div className="space-y-4 rounded-lg border px-4 py-4">
<Tabs
value={passwordResetMode}
onValueChange={(value) => {
setPasswordResetMode(value as PasswordResetMode);
setPasswordResetError(null);
}}
>
<TabsList className="grid w-full grid-cols-2 rounded-lg bg-muted p-1">
<TabsTrigger
value="generated"
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
{t(
"ui.admin.users.detail.password_mode_generated",
"자동 생성",
)}
</TabsTrigger>
<TabsTrigger
value="manual"
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
{t(
"ui.admin.users.detail.password_mode_manual",
"수동 입력",
)}
</TabsTrigger>
</TabsList>
<TabsContent value="generated" className="space-y-2">
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.users.detail.password_generated_help",
"보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다.",
)}
</p>
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<p className="text-sm text-muted-foreground">
{isPasswordPolicyLoading
? t(
"msg.userfront.signup.policy.loading",
"비밀번호 정책을 불러오는 중입니다...",
)
: t(
"msg.userfront.signup.policy.summary",
"보안 정책: {{rules}}",
{
rules: buildPasswordPolicyDescription(
passwordPolicy,
),
},
)}
</p>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="manualPassword"
type={
isManualPasswordVisible ? "text" : "password"
}
value={manualPassword}
placeholder=" "
className="peer pr-12 pt-5"
onChange={(event) => {
setManualPassword(event.target.value);
if (passwordResetError) {
setPasswordResetError(null);
}
}}
/>
<label
htmlFor="manualPassword"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
>
{t("ui.userfront.reset.new_password", "새 비밀번호")}
</label>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
onClick={() =>
setIsManualPasswordVisible((prev) => !prev)
}
aria-label={t(
"ui.admin.users.detail.toggle_password_visibility",
"비밀번호 표시 전환",
)}
>
{isManualPasswordVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="manualPasswordConfirm"
type={
isManualPasswordVisible ? "text" : "password"
}
value={manualPasswordConfirm}
placeholder=" "
className="peer pr-12 pt-5"
onChange={(event) => {
setManualPasswordConfirm(event.target.value);
if (passwordResetError) {
setPasswordResetError(null);
}
}}
/>
<label
htmlFor="manualPasswordConfirm"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
>
{t(
"ui.userfront.reset.confirm_password",
"새 비밀번호 확인",
)}
</label>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
onClick={() =>
setIsManualPasswordVisible((prev) => !prev)
}
aria-label={t(
"ui.admin.users.detail.toggle_password_visibility",
"비밀번호 표시 전환",
)}
>
{isManualPasswordVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
</TabsContent>
</Tabs>
{passwordResetError && (
<p className="text-sm text-destructive">
{passwordResetError}
</p>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setIsPasswordResetOpen(false)}
onClick={handleClosePasswordReset}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={confirmGeneratePassword}
onClick={confirmPasswordReset}
disabled={resetPasswordMutation.isPending}
>
{resetPasswordMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t(
"ui.admin.users.detail.reset_password",
"초기화 및 생성",
"ui.admin.users.detail.reset_password_apply",
"비밀번호 적용",
)}
</Button>
</div>
@@ -774,7 +1112,10 @@ function UserDetailPage() {
<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
{t(
"ui.admin.users.detail.password_result_title",
"Reset Password",
)}
</p>
<p className="font-mono text-lg font-bold">
{generatedPassword}

View File

@@ -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"

View File

@@ -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 = "대표 소속 테넌트"

View File

@@ -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 = ""