fix(admin): 권한 멤버 추가 fail 표시 + 사이드바 중복 제거 + 로그인 ID/PW 저장
Deploy momo-erp / deploy (push) Successful in 51s
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:
@@ -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"
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user