fix(admin): 권한 멤버 추가 fail 표시 + 사이드바 중복 제거 + 로그인 ID/PW 저장
Deploy momo-erp / deploy (push) Successful in 51s

- 권한그룹 멤버 추가/제거 API: history insert 를 best-effort 로 분리해 메인 INSERT
  실패가 누적 에러로 noisy 응답에 담김. 클라이언트는 fail 분기에서 swal 로 사유 표시
- admin-panel 좌측 사이드바: '메뉴관리' 카테고리는 항상 고정 노출되므로 DB groups
  에서 같은 라벨이 다시 내려와도 중복 렌더링 안 함
- 로그인 화면: '아이디/비밀번호 저장' 체크박스 추가 (localStorage SAVE_KEY).
  체크 후 로그인 → 다음 방문 시 자동 채움. 해제하면 즉시 삭제

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-08 13:44:25 +09:00
parent faf8315260
commit 5cbc324627
4 changed files with 172 additions and 56 deletions
+51 -11
View File
@@ -1,11 +1,13 @@
"use client";
import { useState, FormEvent } from "react";
import { useState, useEffect, FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, Lock, Eye, EyeOff, ArrowRight, Phone, Mail, MapPin } from "lucide-react";
import Swal from "sweetalert2";
const SAVE_KEY = "momo_saved_credentials"; // localStorage 키
export default function LoginPage() {
const router = useRouter();
const [userId, setUserId] = useState("");
@@ -13,6 +15,19 @@ export default function LoginPage() {
const [showPw, setShowPw] = useState(false);
const [loading, setLoading] = useState(false);
const [remember, setRemember] = useState(true); // 기본 ON
const [saveCreds, setSaveCreds] = useState(false); // 아이디/비밀번호 저장
// 마운트 시 저장된 자격증명 불러오기
useEffect(() => {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return;
const saved = JSON.parse(raw) as { userId?: string; password?: string };
if (saved.userId) setUserId(saved.userId);
if (saved.password) setPassword(saved.password);
setSaveCreds(true);
} catch { /* ignore */ }
}, []);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -33,6 +48,14 @@ export default function LoginPage() {
const data = await res.json();
if (data.success) {
// 저장 옵션에 따라 localStorage 동기화
try {
if (saveCreds) {
localStorage.setItem(SAVE_KEY, JSON.stringify({ userId, password }));
} else {
localStorage.removeItem(SAVE_KEY);
}
} catch { /* ignore */ }
router.push(data.redirectTo || "/dashboard");
} else {
Swal.fire({
@@ -176,16 +199,33 @@ export default function LoginPage() {
</div>
</div>
<label className="flex items-center gap-2.5 cursor-pointer select-none py-1">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="w-5 h-5 accent-emerald-600 cursor-pointer"
/>
<span className="text-sm text-slate-700 font-semibold"> </span>
<span className="text-xs text-slate-400 ml-auto"> </span>
</label>
<div className="space-y-2 pt-1">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="w-5 h-5 accent-emerald-600 cursor-pointer"
/>
<span className="text-sm text-slate-700 font-semibold"> </span>
<span className="text-xs text-slate-400 ml-auto"> </span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={saveCreds}
onChange={(e) => {
setSaveCreds(e.target.checked);
if (!e.target.checked) {
try { localStorage.removeItem(SAVE_KEY); } catch { /* ignore */ }
}
}}
className="w-5 h-5 accent-emerald-600 cursor-pointer"
/>
<span className="text-sm text-slate-700 font-semibold">/ </span>
<span className="text-xs text-slate-400 ml-auto"> ( PC )</span>
</label>
</div>
<button
type="submit"
+35 -8
View File
@@ -175,12 +175,15 @@ export default function AdminPanelPage() {
</button>
</div>
{/* DB 기반 동적 메뉴 (없으면 정적 ADMIN_MENUS 폴백) */}
{/* DB 기반 동적 메뉴 (없으면 정적 ADMIN_MENUS 폴백)
"메뉴관리" 카테고리는 위에서 항상 고정 노출하므로 DB groups 에서 중복 제거 */}
{(groups.length > 0 ? groups : ADMIN_MENUS.slice(1).map((g, i) => ({
objid: String(i),
label: g.label,
items: g.items.map((it, j) => ({ objid: String(j), label: it.label, url: "" })),
}))).map((section) => {
})))
.filter((section) => section.label.replace(/\s+/g, "") !== "메뉴관리")
.map((section) => {
const isOpen = openSections.has(section.label);
const Icon = SECTION_ICONS[section.label] || FileText;
return (
@@ -891,18 +894,42 @@ function AuthManagement() {
if (!memberTarget) return;
const userIds = Array.from(selectedUsers).map((i) => availableUsers[i]?.USER_ID).filter(Boolean);
if (userIds.length === 0) { Swal.fire("알림", "추가할 사용자를 선택하세요.", "warning"); return; }
const res = await fetch("/api/admin/auth/members/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ masterObjid: memberTarget.objid, userIds }) });
const json = await res.json();
if (json.success) { Swal.fire({ icon: "success", title: json.message, timer: 1200, showConfirmButton: false }); openMembers({ OBJID: memberTarget.objid, AUTH_NAME: memberTarget.name }); searchAvailableUsers(); fetchData(); }
try {
const res = await fetch("/api/admin/auth/members/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ masterObjid: memberTarget.objid, userIds }) });
const json = await res.json().catch(() => ({ success: false, message: "응답 파싱 실패" }));
if (json.success) {
Swal.fire({ icon: "success", title: json.message, timer: 1500, showConfirmButton: false });
setSelectedUsers(new Set());
openMembers({ OBJID: memberTarget.objid, AUTH_NAME: memberTarget.name });
searchAvailableUsers();
fetchData();
} else {
Swal.fire({ icon: "error", title: "추가 실패", text: json.message || `HTTP ${res.status}` });
}
} catch (err) {
Swal.fire({ icon: "error", title: "추가 실패", text: err instanceof Error ? err.message : String(err) });
}
};
const removeMembers = async () => {
if (!memberTarget) return;
const memberObjids = Array.from(selectedMembers).map((i) => members[i]?.OBJID).filter(Boolean);
if (memberObjids.length === 0) { Swal.fire("알림", "제거할 멤버를 선택하세요.", "warning"); return; }
const res = await fetch("/api/admin/auth/members/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ masterObjid: memberTarget.objid, memberObjids }) });
const json = await res.json();
if (json.success) { Swal.fire({ icon: "success", title: json.message, timer: 1200, showConfirmButton: false }); openMembers({ OBJID: memberTarget.objid, AUTH_NAME: memberTarget.name }); searchAvailableUsers(); fetchData(); }
try {
const res = await fetch("/api/admin/auth/members/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ masterObjid: memberTarget.objid, memberObjids }) });
const json = await res.json().catch(() => ({ success: false, message: "응답 파싱 실패" }));
if (json.success) {
Swal.fire({ icon: "success", title: json.message, timer: 1500, showConfirmButton: false });
setSelectedMembers(new Set());
openMembers({ OBJID: memberTarget.objid, AUTH_NAME: memberTarget.name });
searchAvailableUsers();
fetchData();
} else {
Swal.fire({ icon: "error", title: "제거 실패", text: json.message || `HTTP ${res.status}` });
}
} catch (err) {
Swal.fire({ icon: "error", title: "제거 실패", text: err instanceof Error ? err.message : String(err) });
}
};
const set = (k: string, v: string) => setEditForm((p) => ({ ...p, [k]: v }));
+38 -14
View File
@@ -8,15 +8,22 @@ export async function POST(request: NextRequest) {
const user = await getSession();
if (!user) return NextResponse.json({ success: false }, { status: 401 });
let body: { masterObjid?: string | number; memberObjids?: (string | number)[] } = {};
try {
const body = await request.json();
const { masterObjid, memberObjids } = body;
if (!masterObjid || !memberObjids || !Array.isArray(memberObjids) || memberObjids.length === 0) {
return NextResponse.json({ success: false, message: "대상을 선택하세요." }, { status: 400 });
}
body = await request.json();
} catch {
return NextResponse.json({ success: false, message: "잘못된 요청" }, { status: 400 });
}
const { masterObjid, memberObjids } = body;
if (!masterObjid || !memberObjids || !Array.isArray(memberObjids) || memberObjids.length === 0) {
return NextResponse.json({ success: false, message: "대상을 선택하세요." }, { status: 400 });
}
for (const memberObjid of memberObjids) {
// 이력 기록 (삭제 전 USER_ID 조회)
let removed = 0;
const errors: string[] = [];
for (const memberObjid of memberObjids) {
// 이력 — best-effort
try {
await execute(
`INSERT INTO AUTHORITY_MASTER_HISTORY (OBJID, PARENT_OBJID, PARENT_NAME, PARENT_CODE, USER_ID, ACTIVE, HISTORY_TYPE, WRITER, REG_DATE)
SELECT $1::numeric, $2::numeric,
@@ -28,16 +35,33 @@ export async function POST(request: NextRequest) {
FROM AUTHORITY_SUB_USER ASU WHERE ASU.OBJID = $4::numeric`,
[createObjectId(), masterObjid, user.userId, memberObjid]
);
// 멤버 삭제
await execute(
} catch (histErr) {
console.warn("[auth/members/delete] history insert skipped:", histErr instanceof Error ? histErr.message : histErr);
}
try {
const rc = await execute(
`DELETE FROM AUTHORITY_SUB_USER WHERE MASTER_OBJID = $1::numeric AND OBJID = $2::numeric`,
[masterObjid, memberObjid]
);
if (rc > 0) removed++;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[auth/members/delete] memberObjid=${memberObjid} 실패:`, msg);
errors.push(`${memberObjid}: ${msg}`);
}
return NextResponse.json({ success: true, message: `${memberObjids.length}명이 제거되었습니다.` });
} catch (error) {
console.error("Auth member delete:", error);
return NextResponse.json({ success: false, message: "처리 중 오류가 발생했습니다." }, { status: 500 });
}
if (removed === 0 && errors.length > 0) {
return NextResponse.json({
success: false,
message: `삭제 실패 (${errors.length}건): ${errors[0]}`,
}, { status: 500 });
}
return NextResponse.json({
success: true,
message: errors.length > 0
? `${removed}명 제거 / ${errors.length}건 실패`
: `${removed}명이 제거되었습니다.`,
errors,
});
}
+48 -23
View File
@@ -8,15 +8,22 @@ export async function POST(request: NextRequest) {
const user = await getSession();
if (!user) return NextResponse.json({ success: false }, { status: 401 });
let body: { masterObjid?: string | number; userIds?: string[] } = {};
try {
const body = await request.json();
const { masterObjid, userIds } = body;
if (!masterObjid || !userIds || !Array.isArray(userIds) || userIds.length === 0) {
return NextResponse.json({ success: false, message: "대상을 선택하세요." }, { status: 400 });
}
body = await request.json();
} catch {
return NextResponse.json({ success: false, message: "잘못된 요청" }, { status: 400 });
}
let added = 0;
for (const userId of userIds) {
const { masterObjid, userIds } = body;
if (!masterObjid || !userIds || !Array.isArray(userIds) || userIds.length === 0) {
return NextResponse.json({ success: false, message: "대상을 선택하세요." }, { status: 400 });
}
let added = 0;
const errors: string[] = [];
for (const userId of userIds) {
try {
const objid = createObjectId();
const rowCount = await execute(
`INSERT INTO AUTHORITY_SUB_USER (OBJID, MASTER_OBJID, USER_ID, WRITER, REGDATE)
@@ -28,23 +35,41 @@ export async function POST(request: NextRequest) {
);
if (rowCount > 0) {
added++;
// 이력 기록
await execute(
`INSERT INTO AUTHORITY_MASTER_HISTORY (OBJID, PARENT_OBJID, PARENT_NAME, PARENT_CODE, USER_ID, ACTIVE, HISTORY_TYPE, WRITER, REG_DATE)
VALUES ($1::numeric, $2::numeric,
(SELECT AUTH_NAME FROM AUTHORITY_MASTER WHERE OBJID = $2::numeric),
(SELECT AUTH_CODE FROM AUTHORITY_MASTER WHERE OBJID = $2::numeric),
$3,
(SELECT STATUS FROM AUTHORITY_MASTER WHERE OBJID = $2::numeric),
'ADD', $4, now())`,
[createObjectId(), masterObjid, userId, user.userId]
);
// 이력 — 테이블이 없거나 컬럼이 달라도 메인 동작은 살아남도록 best-effort
try {
await execute(
`INSERT INTO AUTHORITY_MASTER_HISTORY (OBJID, PARENT_OBJID, PARENT_NAME, PARENT_CODE, USER_ID, ACTIVE, HISTORY_TYPE, WRITER, REG_DATE)
VALUES ($1::numeric, $2::numeric,
(SELECT AUTH_NAME FROM AUTHORITY_MASTER WHERE OBJID = $2::numeric),
(SELECT AUTH_CODE FROM AUTHORITY_MASTER WHERE OBJID = $2::numeric),
$3,
(SELECT STATUS FROM AUTHORITY_MASTER WHERE OBJID = $2::numeric),
'ADD', $4, now())`,
[createObjectId(), masterObjid, userId, user.userId]
);
} catch (histErr) {
console.warn("[auth/members/save] history insert skipped:", histErr instanceof Error ? histErr.message : histErr);
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[auth/members/save] userId=${userId} 실패:`, msg);
errors.push(`${userId}: ${msg}`);
}
return NextResponse.json({ success: true, message: `${added}명이 추가되었습니다.` });
} catch (error) {
console.error("Auth member add:", error);
return NextResponse.json({ success: false, message: "처리 중 오류가 발생했습니다." }, { status: 500 });
}
if (added === 0 && errors.length > 0) {
return NextResponse.json({
success: false,
message: `추가 실패 (${errors.length}건): ${errors[0]}`,
}, { status: 500 });
}
return NextResponse.json({
success: true,
message: errors.length > 0
? `${added}명 추가 / ${errors.length}건 실패`
: `${added}명이 추가되었습니다.`,
errors,
});
}