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

@@ -24,6 +24,11 @@ ADMIN_TOKEN=xavis-admin
PAGE_SIZE=9
# 학습센터 동영상 파일 업로드 최대 크기(MB, 기본 500). 리버스 프록시(Nginx 등)의 client_max_body_size도 같이 늘려야 합니다.
LECTURE_VIDEO_MAX_MB=500
# 대시보드 메뉴·경로 허용 이메일(OPS 로그인 @xavis.co.kr), 쉼표 구분. 비우면 대시보드 비표시
DASHBOARD_MENU_ALLOWED_EMAILS=hmjin@xavis.co.kr,dsyoon@xavis.co.kr
# DEV에서만: 관리자 모드일 때 MEETING_DEV_EMAIL을 허용 목록과 대조(로컬 테스트). 운영에서는 미설정 권장
# DASHBOARD_MENU_DEV_USE_MEETING_EMAIL=1
# 1=PostgreSQL 단일 소스, 0=data/lectures.json 사용
ENABLE_POSTGRES=1
DB_HOST=your-db-host

View File

@@ -44,6 +44,7 @@
- **대시보드** (`/dashboard`): AI 탐색과 유사한 카드 그리드·검색으로 대시보드를 모아 표시. 첫 카드 **경영성과 대시보드**는 `/dashboard/business-performance`로 연결
- **경영성과 대시보드** (`/dashboard/business-performance`): 상단 **엑셀 업로드**(`.xlsx`, 매출일보 시트) → DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 하단 **대시보드 조회**는 동일 페이지에 Chart.js 템플릿을 인라인으로 렌더(iframe 제거, 별도 `/dashboard/business-performance/embed`는 직접 열람용으로 유지). 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install``xlsx` 설치 후 서버 재시작.
- **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`data/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다.
- **대시보드 메뉴 접근**: `.env``DASHBOARD_MENU_ALLOWED_EMAILS`**쉼표로 구분한 OPS 로그인 이메일**만 좌측 **대시보드** 메뉴·`/dashboard`·경영성과 API가 보입니다. 목록이 비어 있으면 누구에게도 표시되지 않습니다. 로컬(DEV)에서 관리자 토큰만 쓰는 경우 `DASHBOARD_MENU_DEV_USE_MEETING_EMAIL=1``MEETING_DEV_EMAIL`을 허용 목록과 맞추면 대조됩니다.
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`data/company-prompts.json`). **좌측 메뉴(채팅·AI·AI 성공 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
- 검색/필터/페이지네이션
- 검색어(`q`) 기반 제목/설명/태그 필터

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();

View File

@@ -28,8 +28,11 @@
<a href="/learning" class="nav-item <%= activeMenu === 'learning' ? 'active' : '' %>">학습센터</a>
<a href="/ax-apply" class="nav-item <%= activeMenu === 'ax-apply' ? 'active' : '' %>">과제신청</a>
<a href="/ai-cases" class="nav-item <%= activeMenu === 'ai-cases' ? 'active' : '' %>">성공사례</a>
<% var _dashOk = typeof dashboardMenuAllowed !== 'undefined' && dashboardMenuAllowed; %>
<% if (_dashOk) { %>
<div class="nav-separator"></div>
<a href="/dashboard" class="nav-item <%= activeMenu === 'dashboard' ? 'active' : '' %>">대시보드</a>
<% } %>
<div class="nav-footer">
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
<% var _admin = typeof adminMode !== 'undefined' && adminMode; %>