feat: 대시보드 메뉴 DASHBOARD_MENU_ALLOWED_EMAILS(.env) 화이트리스트

- OPS 로그인 이메일만 메뉴·/dashboard·경영성과 API 허용
- DEV 옵션: DASHBOARD_MENU_DEV_USE_MEETING_EMAIL+MEETING_DEV_EMAIL

Made-with: Cursor
This commit is contained in:
2026-04-13 13:38:24 +09:00
parent 4d78cae990
commit e70280c929
4 changed files with 70 additions and 6 deletions

View File

@@ -968,6 +968,52 @@ function isAiExploreDevGuestRestricted(req, res) {
return isOpsStateDev() && !res.locals.adminMode;
}
/** `.env` DASHBOARD_MENU_ALLOWED_EMAILS (쉼표 구분, 소문자 정규화) */
function parseDashboardMenuAllowlist() {
return String(process.env.DASHBOARD_MENU_ALLOWED_EMAILS || "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
const DASHBOARD_MENU_ALLOWLIST = parseDashboardMenuAllowlist();
/**
* 대시보드 메뉴·경로 허용 여부: 목록에 있고, (OPS 로그인 이메일 또는 DEV에서 관리자+MEETING_DEV_EMAIL 대조)
*/
function getEmailForDashboardAccess(req, res) {
if (res.locals.opsUserEmail) {
return String(res.locals.opsUserEmail).trim().toLowerCase();
}
if (
isOpsStateDev() &&
res.locals.adminMode &&
process.env.DASHBOARD_MENU_DEV_USE_MEETING_EMAIL === "1"
) {
return (process.env.MEETING_DEV_EMAIL || "").trim().toLowerCase();
}
return null;
}
function computeDashboardMenuAllowed(req, res) {
if (DASHBOARD_MENU_ALLOWLIST.length === 0) return false;
const email = getEmailForDashboardAccess(req, res);
if (!email) return false;
return DASHBOARD_MENU_ALLOWLIST.includes(email);
}
function requireDashboardAccess(req, res, next) {
if (computeDashboardMenuAllowed(req, res)) return next();
if (String(req.path || "").startsWith("/api/")) {
return res.status(403).json({ error: "대시보드 접근 권한이 없습니다." });
}
return res
.status(403)
.type("html")
.send(
`<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"/><title>권한 없음</title><link rel="icon" href="/favicon.ico" type="image/x-icon"/></head><body style="font-family:sans-serif;padding:24px;"><p>대시보드 접근 권한이 없습니다.</p><p><a href="/learning">학습센터로</a></p></body></html>`
);
}
/** OPS 이메일 세션, DEV+관리자(MEETING_DEV_EMAIL), SUPER(데모 이메일) — 회의록 AI */
function getMeetingMinutesUserEmail(req, res) {
if (res.locals.opsUserEmail) return String(res.locals.opsUserEmail).trim().toLowerCase();
@@ -1154,9 +1200,13 @@ app.get("/resources/ax-apply/AX_과제_신청서.docx", (req, res) => {
app.use("/resources/ax-apply", express.static(RESOURCES_AX_APPLY_DIR));
app.use(opsAuth.middleware);
app.use((req, res, next) => {
res.locals.dashboardMenuAllowed = computeDashboardMenuAllowed(req, res);
next();
});
opsAuth.registerRoutes(app);
app.get("/api/mgmt-perf/status", async (req, res) => {
app.get("/api/mgmt-perf/status", requireDashboardAccess, async (req, res) => {
try {
const row = await mgmtPerf.getLatestPayloadRow(pgPool);
const p = row.payload && typeof row.payload === "object" ? row.payload : {};
@@ -1176,7 +1226,11 @@ app.get("/api/mgmt-perf/status", async (req, res) => {
}
});
app.post("/api/mgmt-perf/upload", uploadMgmtPerfExcel.single("file"), async (req, res) => {
app.post(
"/api/mgmt-perf/upload",
requireDashboardAccess,
uploadMgmtPerfExcel.single("file"),
async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: "파일이 없습니다." });
const fiscalYear = Math.min(2100, Math.max(2020, parseInt(req.body.fiscalYear, 10) || new Date().getFullYear()));
@@ -1198,7 +1252,8 @@ app.post("/api/mgmt-perf/upload", uploadMgmtPerfExcel.single("file"), async (req
console.error("mgmt-perf upload:", err);
res.status(500).json({ error: err.message || "처리 실패" });
}
});
}
);
const pageRouter = express.Router();
pageRouter.get("/chat", (req, res) =>
@@ -1237,14 +1292,14 @@ pageRouter.get("/ai-explore/task-checklist", (req, res) =>
opsState: normalizeOpsState(),
})
);
pageRouter.get("/dashboard", (req, res) =>
pageRouter.get("/dashboard", requireDashboardAccess, (req, res) =>
res.render("dashboard", {
activeMenu: "dashboard",
adminMode: res.locals.adminMode,
opsUserEmail: !!res.locals.opsUserEmail,
})
);
pageRouter.get("/dashboard/business-performance", async (req, res, next) => {
pageRouter.get("/dashboard/business-performance", requireDashboardAccess, async (req, res, next) => {
try {
const latest = await mgmtPerf.getLatestPayloadRow(pgPool);
const uploadHistory = await mgmtPerf.listUploads(pgPool, 12);
@@ -1268,7 +1323,7 @@ pageRouter.get("/dashboard/business-performance", async (req, res, next) => {
next(err);
}
});
pageRouter.get("/dashboard/business-performance/embed", async (req, res, next) => {
pageRouter.get("/dashboard/business-performance/embed", requireDashboardAccess, async (req, res, next) => {
try {
const row = await mgmtPerf.getLatestPayloadRow(pgPool);
const fy = row.fiscal_year || new Date().getFullYear();