jskim-node #5

Merged
jskim merged 34 commits from jskim-node into main 2026-04-02 09:30:05 +00:00
7 changed files with 104 additions and 43 deletions
Showing only changes of commit 369a201832 - Show all commits

View File

@@ -2696,6 +2696,35 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
});
return;
}
// SUPER_ADMIN 권한 부여는 최고관리자만 가능
const requestUser = req.user;
const isRequesterSuperAdmin = requestUser?.companyCode === "*" && requestUser?.userType === "SUPER_ADMIN";
if (userData.userType.trim() === "SUPER_ADMIN" && !isRequesterSuperAdmin) {
res.status(403).json({
success: false,
message: "최고 관리자 권한은 최고 관리자만 부여할 수 있습니다.",
error: { code: "FORBIDDEN_SUPER_ADMIN_GRANT" },
});
return;
}
// 기존 SUPER_ADMIN 사용자의 권한은 최고관리자만 변경 가능
if (isUpdate && !isRequesterSuperAdmin) {
const targetUser = await queryOne<{ user_type: string }>(
`SELECT user_type FROM user_info WHERE user_id = $1`,
[userData.userId?.trim()]
);
if (targetUser?.user_type === "SUPER_ADMIN") {
res.status(403).json({
success: false,
message: "최고 관리자의 권한은 다른 최고 관리자만 변경할 수 있습니다.",
error: { code: "FORBIDDEN_SUPER_ADMIN_MODIFY" },
});
return;
}
}
}
// 4. 비밀번호 최소 길이 검증 (신규 등록 시)

View File

@@ -415,30 +415,27 @@ export default function MenuPage() {
};
const loadMenus = async (showLoading = true) => {
// console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
try {
if (showLoading) {
setLoading(true);
}
// 선택된 메뉴 타입에 해당하는 메뉴만 로드
if (selectedMenuType === "admin") {
const adminResponse = await menuApi.getAdminMenusForManagement();
if (adminResponse.success && adminResponse.data) {
setLocalAdminMenus(adminResponse.data);
}
} else {
const userResponse = await menuApi.getUserMenusForManagement();
if (userResponse.success && userResponse.data) {
setLocalUserMenus(userResponse.data);
}
// 양쪽 메뉴를 모두 로드 (카운트 표시용)
const [adminResponse, userResponse] = await Promise.all([
menuApi.getAdminMenusForManagement(),
menuApi.getUserMenusForManagement(),
]);
if (adminResponse.success && adminResponse.data) {
setLocalAdminMenus(adminResponse.data);
}
if (userResponse.success && userResponse.data) {
setLocalUserMenus(userResponse.data);
}
// 전역 메뉴 상태도 업데이트 (좌측 사이드바용)
await refreshMenus();
// console.log("📋 메뉴 목록 조회 성공");
} catch (error) {
// console.error("❌ 메뉴 목록 조회 실패:", error);
toast.error(getUITextSync("message.error.load.menu.list"));
} finally {
if (showLoading) {

View File

@@ -13,14 +13,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* 사용자 권한 관리 페이지
* URL: /admin/userAuth
*
* 최고 관리자 접근 가능
* 관리자(최고관리자 + 회사관리자) 접근 가능
* 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리
* 회사관리자는 SUPER_ADMIN 권한 부여 불가
*/
export default function UserAuthPage() {
const { user: currentUser } = useAuth();
// 최고 관리자 여부
// 관리자 여부
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
const isAdmin = isSuperAdmin || currentUser?.userType === "COMPANY_ADMIN";
// 상태 관리
const [users, setUsers] = useState<any[]>([]);
@@ -49,6 +51,9 @@ export default function UserAuthPage() {
const response = await userAPI.getList({
page,
size: paginationInfo.pageSize,
...(currentUser?.userType === "COMPANY_ADMIN" && currentUser?.companyCode
? { companyCode: currentUser.companyCode }
: {}),
});
if (response.success && response.data) {
@@ -103,21 +108,21 @@ export default function UserAuthPage() {
loadUsers(page);
};
// 최고 관리자가 아닌 경우
if (!isSuperAdmin) {
// 관리자가 아닌 경우
if (!isAdmin) {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> . ( )</p>
<p className="text-muted-foreground text-sm"> .</p>
</div>
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm">
.
.
</p>
<Button variant="outline" onClick={() => window.history.back()}>
@@ -136,7 +141,7 @@ export default function UserAuthPage() {
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> . ( )</p>
<p className="text-muted-foreground text-sm"> .</p>
</div>
{/* 에러 메시지 */}
@@ -171,6 +176,7 @@ export default function UserAuthPage() {
onClose={handleAuthEditClose}
onSuccess={handleAuthEditSuccess}
user={authEditModal.user}
currentUser={currentUser}
/>
</div>

View File

@@ -20,14 +20,16 @@ interface UserAuthEditModalProps {
onClose: () => void;
onSuccess?: () => void;
user: any | null;
currentUser?: any | null;
}
/**
* 사용자 권한 변경 모달
*
* 권한 레벨만 변경 가능 (최고 관리자 전용)
* 권한 레벨만 변경 가능 (관리자 전용)
* 회사관리자는 SUPER_ADMIN 권한 부여 불가
*/
export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuthEditModalProps) {
export function UserAuthEditModal({ isOpen, onClose, onSuccess, user, currentUser }: UserAuthEditModalProps) {
const [selectedUserType, setSelectedUserType] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
@@ -79,7 +81,19 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
},
];
const selectedOption = userTypeOptions.find((opt) => opt.value === selectedUserType);
// 현재 사용자가 SUPER_ADMIN인지 확인
const isCurrentUserSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// COMPANY_ADMIN은 SUPER_ADMIN 옵션 제외
const availableOptions = isCurrentUserSuperAdmin
? userTypeOptions
: userTypeOptions.filter((opt) => opt.value !== "SUPER_ADMIN");
// 대상 사용자가 SUPER_ADMIN이면 COMPANY_ADMIN은 변경 불가
const isTargetSuperAdmin = user?.userType === "SUPER_ADMIN";
const canEdit = isCurrentUserSuperAdmin || !isTargetSuperAdmin;
const selectedOption = availableOptions.find((opt) => opt.value === selectedUserType);
// 권한 변경 여부 확인
const isUserTypeChanged = user && selectedUserType !== user.userType;
@@ -160,22 +174,32 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
<Label htmlFor="userType" className="text-sm font-medium">
<span className="text-destructive">*</span>
</Label>
<Select value={selectedUserType} onValueChange={setSelectedUserType}>
<SelectTrigger className="h-10">
<SelectValue placeholder="권한 선택" />
</SelectTrigger>
<SelectContent>
{userTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<span className={option.color}>{option.icon}</span>
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedOption && <p className="text-muted-foreground text-xs">{selectedOption.description}</p>}
{!canEdit ? (
<div className="rounded-lg border border-orange-300 bg-amber-50 p-3">
<p className="text-xs text-orange-800">
.
</p>
</div>
) : (
<>
<Select value={selectedUserType} onValueChange={setSelectedUserType}>
<SelectTrigger className="h-10">
<SelectValue placeholder="권한 선택" />
</SelectTrigger>
<SelectContent>
{availableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<span className={option.color}>{option.icon}</span>
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedOption && <p className="text-muted-foreground text-xs">{selectedOption.description}</p>}
</>
)}
</div>
{/* SUPER_ADMIN 경고 */}
@@ -206,7 +230,7 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
</Button>
<Button
onClick={handleSave}
disabled={isLoading || !isUserTypeChanged}
disabled={isLoading || !isUserTypeChanged || !canEdit}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? "처리중..." : showConfirmation ? "확인 및 저장" : "저장"}

View File

@@ -459,6 +459,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
value={formData.userId}
onChange={(e) => handleInputChange("userId", e.target.value)}
onKeyDown={handleKeyDown}
autoComplete="off"
className="flex-1"
/>
<Button
@@ -511,6 +512,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
value={formData.userPassword}
onChange={(e) => handleInputChange("userPassword", e.target.value)}
onKeyDown={handleKeyDown}
autoComplete="new-password"
className="pr-10"
/>
<Button

View File

@@ -155,6 +155,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
onChange={(e) => setNewPassword(e.target.value)}
placeholder="새 비밀번호를 입력하세요"
disabled={isLoading}
autoComplete="new-password"
className="pr-10"
/>
<Button
@@ -181,6 +182,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="비밀번호를 다시 입력하세요"
disabled={isLoading}
autoComplete="new-password"
className="pr-10"
/>
<Button

View File

@@ -81,6 +81,7 @@ export function UserToolbar({
value={searchFilter.searchValue || ""}
onChange={(e) => handleV2SearchChange(e.target.value)}
disabled={isAdvancedSearchMode}
autoComplete="off"
className={`h-10 pl-10 text-sm ${
isSearching ? "border-primary ring-2 ring-primary/20" : ""
} ${isAdvancedSearchMode ? "cursor-not-allowed bg-muted text-muted-foreground" : ""}`}