diff --git a/.env.example b/.env.example index 9358107..cf0ca55 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 94e742f..b2d22eb 100644 --- a/README.md +++ b/README.md @@ -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`) 기반 제목/설명/태그 필터 diff --git a/server.js b/server.js index 6206868..e481aaa 100644 --- a/server.js +++ b/server.js @@ -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( + `권한 없음

대시보드 접근 권한이 없습니다.

학습센터로

` + ); +} + /** 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(); diff --git a/views/partials/nav.ejs b/views/partials/nav.ejs index cc8c83d..9abe1ea 100644 --- a/views/partials/nav.ejs +++ b/views/partials/nav.ejs @@ -28,8 +28,11 @@ 학습센터 과제신청 성공사례 + <% var _dashOk = typeof dashboardMenuAllowed !== 'undefined' && dashboardMenuAllowed; %> + <% if (_dashOk) { %> 대시보드 + <% } %>