Files
nginx-portal/frontend/index.html
qorgh529 638f6773fb
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
feat: 알림 채널 다중 관리 UI 추가
2026-04-27 20:30:16 +09:00

1155 lines
59 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Portal</title>
<style>
:root {
--primary:#2563eb;--primary-dark:#1d4ed8;--bg:#f1f5f9;--card:#fff;
--text:#1e293b;--muted:#64748b;--danger:#ef4444;--success:#22c55e;
--warning:#f59e0b;--border:#e2e8f0;--admin:#7c3aed;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;}
/* Login */
#login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#1e3a5f,#2563eb);}
.login-box{background:#fff;border-radius:16px;padding:48px 40px;width:400px;box-shadow:0 25px 60px rgba(0,0,0,.3);}
.login-logo{text-align:center;margin-bottom:32px;}
.login-logo h1{font-size:1.5rem;font-weight:700;color:var(--primary);margin-top:12px;}
.login-logo p{color:var(--muted);font-size:.875rem;margin-top:4px;}
.form-group{margin-bottom:18px;}
.form-group label{display:block;font-size:.875rem;font-weight:600;margin-bottom:6px;}
.form-group input{width:100%;padding:11px 14px;border:2px solid var(--border);border-radius:8px;font-size:.95rem;transition:border-color .2s;}
.form-group input:focus{outline:none;border-color:var(--primary);}
.pw-wrap{position:relative;}
.pw-wrap input{padding-right:44px;}
.pw-toggle{position:absolute;right:12px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--muted);padding:0;width:auto;line-height:0;}
.btn{width:100%;padding:12px;background:var(--primary);color:#fff;border:none;border-radius:8px;font-size:1rem;font-weight:600;cursor:pointer;transition:background .2s;}
.btn:hover{background:var(--primary-dark);}
.btn-sm{width:auto;padding:7px 16px;font-size:.8rem;border-radius:6px;}
.btn-danger{background:var(--danger);}.btn-danger:hover{background:#dc2626;}
.btn-success{background:var(--success);}.btn-success:hover{background:#16a34a;}
.btn-warning{background:var(--warning);color:#fff;}.btn-warning:hover{background:#d97706;}
.btn-gray{background:#94a3b8;}.btn-gray:hover{background:#64748b;}
.btn-purple{background:var(--admin);}.btn-purple:hover{background:#6d28d9;}
.btn-outline{background:#fff;color:var(--primary);border:2px solid var(--primary);}.btn-outline:hover{background:var(--primary);color:#fff;}
.error-msg{color:var(--danger);font-size:.85rem;margin-top:10px;text-align:center;}
/* Force PW */
#change-pw-page{display:none;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#1e3a5f,#2563eb);}
.change-pw-box{background:#fff;border-radius:16px;padding:48px 40px;width:400px;box-shadow:0 25px 60px rgba(0,0,0,.3);}
.change-pw-box h2{font-size:1.3rem;font-weight:700;margin-bottom:8px;}
.change-pw-box p{color:var(--muted);font-size:.875rem;margin-bottom:24px;line-height:1.5;}
.notice-box{background:#fef3c7;border:1px solid #fbbf24;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:.85rem;color:#92400e;}
/* App */
#app-page{display:none;min-height:100vh;flex-direction:column;}
header{background:#fff;border-bottom:1px solid var(--border);padding:0 32px;height:60px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 1px 6px rgba(0,0,0,.06);}
.header-left{display:flex;align-items:center;gap:12px;}
.header-logo{font-weight:700;font-size:1.1rem;color:var(--primary);}
.badge-admin{background:var(--admin);color:#fff;font-size:.7rem;font-weight:700;padding:2px 8px;border-radius:20px;}
.header-right{display:flex;align-items:center;gap:10px;}
.user-chip{display:flex;align-items:center;gap:7px;background:var(--bg);border-radius:20px;padding:5px 14px;font-size:.875rem;font-weight:500;}
nav{background:#fff;border-bottom:1px solid var(--border);padding:0 32px;display:flex;gap:4px;}
.nav-btn{padding:14px 18px;border:none;background:none;cursor:pointer;font-size:.9rem;color:var(--muted);font-weight:500;border-bottom:3px solid transparent;transition:all .2s;}
.nav-btn.active{color:var(--primary);border-bottom-color:var(--primary);}
.nav-btn:hover:not(.active){color:var(--text);}
main{padding:32px;flex:1;}
.page{display:none;}.page.active{display:block;}
/* Cards */
.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;}
.page-header h2{font-size:1.3rem;font-weight:700;}
.page-header p{color:var(--muted);font-size:.875rem;margin-top:2px;}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:20px;}
.card{background:#fff;border-radius:12px;padding:24px;box-shadow:0 1px 4px rgba(0,0,0,.07);border:1px solid var(--border);transition:transform .15s,box-shadow .15s;cursor:pointer;text-decoration:none;color:inherit;display:flex;flex-direction:column;}
.card:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.1);}
.card-icon{width:48px;height:48px;border-radius:10px;display:flex;align-items:center;justify-content:center;margin-bottom:14px;overflow:hidden;background:linear-gradient(135deg,var(--primary),#60a5fa);flex-shrink:0;}
.card-icon img{width:100%;height:100%;object-fit:contain;}
.card-icon svg{color:#fff;}
.card h3{font-size:1rem;font-weight:600;margin-bottom:6px;}
.card p{font-size:.825rem;color:var(--muted);line-height:1.5;}
.empty-state{text-align:center;padding:80px 20px;color:var(--muted);}
/* Table */
.table-wrapper{background:#fff;border-radius:12px;border:1px solid var(--border);overflow:hidden;}
table{width:100%;border-collapse:collapse;}
th{background:var(--bg);padding:12px 16px;text-align:left;font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;}
td{padding:13px 16px;font-size:.875rem;border-top:1px solid var(--border);vertical-align:middle;}
tr:hover td{background:#f8fafc;}
.actions{display:flex;gap:6px;flex-wrap:wrap;}
.tag{display:inline-block;padding:2px 10px;border-radius:20px;font-size:.75rem;font-weight:600;}
.tag-admin{background:#ede9fe;color:var(--admin);}
.tag-user{background:#dbeafe;color:var(--primary);}
.tag-locked{background:#fee2e2;color:var(--danger);}
.tag-warning{background:#fef3c7;color:#92400e;}
.tag-ok{background:#dcfce7;color:#166534;}
/* Board */
.board-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;}
.board-table{width:100%;border-collapse:collapse;background:#fff;border-radius:12px;overflow:hidden;border:1px solid var(--border);}
.board-table th{background:var(--bg);padding:12px 16px;text-align:left;font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;}
.board-table td{padding:14px 16px;border-top:1px solid var(--border);font-size:.875rem;vertical-align:middle;}
.board-table tr:hover td{background:#f8fafc;cursor:pointer;}
.board-table .num{width:60px;text-align:center;color:var(--muted);}
.board-table .title-cell{font-weight:500;}
.board-table .author{width:100px;color:var(--muted);}
.board-table .date{width:160px;color:var(--muted);font-size:.8rem;}
/* Post Detail */
.post-detail{background:#fff;border-radius:12px;border:1px solid var(--border);overflow:hidden;}
.post-header{padding:24px 28px;border-bottom:1px solid var(--border);}
.post-header h2{font-size:1.2rem;font-weight:700;margin-bottom:10px;}
.post-meta{display:flex;gap:16px;font-size:.82rem;color:var(--muted);}
.post-body{padding:28px;min-height:160px;line-height:1.8;white-space:pre-wrap;border-bottom:1px solid var(--border);}
.post-actions{padding:16px 28px;display:flex;gap:8px;}
.replies-section{padding:0 28px 28px;}
.replies-title{font-size:.9rem;font-weight:600;color:var(--muted);padding:20px 0 12px;border-bottom:1px solid var(--border);margin-bottom:16px;}
.reply-item{padding:14px 16px;background:var(--bg);border-radius:8px;margin-bottom:10px;}
.reply-meta{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.reply-author{font-size:.82rem;font-weight:600;color:var(--primary);}
.reply-date{font-size:.75rem;color:var(--muted);}
.reply-content{font-size:.875rem;line-height:1.6;white-space:pre-wrap;}
.reply-del{font-size:.75rem;color:var(--danger);background:none;border:none;cursor:pointer;padding:0;}
.reply-form{margin-top:16px;background:var(--bg);border-radius:8px;padding:16px;}
.reply-form textarea{width:100%;border:2px solid var(--border);border-radius:8px;padding:10px 12px;font-size:.875rem;resize:vertical;min-height:80px;font-family:inherit;}
.reply-form textarea:focus{outline:none;border-color:var(--primary);}
.reply-form-footer{display:flex;justify-content:flex-end;margin-top:8px;}
/* Write Form */
.write-form{background:#fff;border-radius:12px;border:1px solid var(--border);padding:28px;}
.write-form h2{font-size:1.2rem;font-weight:700;margin-bottom:20px;}
.write-form input[type=text]{width:100%;padding:11px 14px;border:2px solid var(--border);border-radius:8px;font-size:.95rem;margin-bottom:14px;}
.write-form input[type=text]:focus{outline:none;border-color:var(--primary);}
.write-form textarea{width:100%;padding:14px;border:2px solid var(--border);border-radius:8px;font-size:.9rem;resize:vertical;min-height:220px;font-family:inherit;line-height:1.6;}
.write-form textarea:focus{outline:none;border-color:var(--primary);}
.write-form-footer{display:flex;gap:10px;justify-content:flex-end;margin-top:16px;}
/* Modal */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;align-items:center;justify-content:center;}
.modal-overlay.open{display:flex;}
.modal{background:#fff;border-radius:16px;padding:32px;width:480px;max-width:95vw;box-shadow:0 30px 80px rgba(0,0,0,.25);max-height:90vh;overflow-y:auto;}
.modal h3{font-size:1.2rem;font-weight:700;margin-bottom:20px;}
.modal-footer{display:flex;gap:10px;margin-top:24px;justify-content:flex-end;}
.modal-footer .btn{width:auto;}
.checkbox-list{max-height:280px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;}
.checkbox-item{display:flex;align-items:center;gap:10px;padding:11px 14px;border-bottom:1px solid var(--border);cursor:pointer;}
.checkbox-item:last-child{border-bottom:none;}
.checkbox-item:hover{background:var(--bg);}
.checkbox-item input[type=checkbox]{width:16px;height:16px;accent-color:var(--primary);}
.checkbox-item .item-info{flex:1;}
.checkbox-item .item-info strong{font-size:.875rem;display:block;}
.checkbox-item .item-info span{font-size:.75rem;color:var(--muted);}
.temp-pw-box{background:#f0fdf4;border:2px solid var(--success);border-radius:10px;padding:20px;margin-top:16px;text-align:center;}
.temp-pw-box p{font-size:.85rem;color:var(--muted);margin-bottom:8px;}
.temp-pw-box strong{font-size:1.4rem;font-family:monospace;letter-spacing:2px;}
.temp-pw-box small{display:block;margin-top:8px;font-size:.75rem;color:var(--danger);}
</style>
</head>
<body>
<!-- LOGIN -->
<div id="login-page">
<div class="login-box">
<div class="login-logo">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none"><rect width="56" height="56" rx="14" fill="#2563eb"/><path d="M14 20h28M14 28h28M14 36h18" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>
<h1>Web Portal</h1>
<p>조직 내부 웹페이지 통합 포털</p>
</div>
<div class="form-group"><label>아이디</label><input type="text" id="login-user" placeholder="사용자명 입력"/></div>
<div class="form-group">
<label>비밀번호</label>
<div class="pw-wrap">
<input type="password" id="login-pass" placeholder="비밀번호 입력" onkeydown="if(event.key==='Enter')doLogin()"/>
<button type="button" class="pw-toggle" onclick="togglePw('login-pass','eye-login')">
<svg id="eye-login" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<button class="btn" onclick="doLogin()">로그인</button>
<div id="login-error" class="error-msg"></div>
</div>
</div>
<!-- FORCE CHANGE PW -->
<div id="change-pw-page">
<div class="change-pw-box">
<h2>🔒 비밀번호 변경 필요</h2>
<p>보안을 위해 비밀번호를 변경해야 합니다.<br>변경 후 서비스를 이용하실 수 있습니다.</p>
<div class="notice-box">⚠️ 초기 비밀번호는 반드시 변경해주세요.</div>
<div class="form-group"><label>현재 비밀번호</label><input type="password" id="force-current-pw" placeholder="현재 비밀번호 입력"/></div>
<div class="form-group"><label>새 비밀번호</label><input type="password" id="force-new-pw" placeholder="6자 이상 입력"/></div>
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="force-confirm-pw" placeholder="새 비밀번호 재입력" onkeydown="if(event.key==='Enter')doForceChangePw()"/></div>
<button class="btn" onclick="doForceChangePw()">비밀번호 변경</button>
<div id="force-pw-error" class="error-msg"></div>
</div>
</div>
<!-- APP -->
<div id="app-page">
<header>
<div class="header-left">
<span class="header-logo">🌐 Web Portal</span>
<span id="admin-badge" class="badge-admin" style="display:none">ADMIN</span>
</div>
<div class="header-right">
<div class="user-chip">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
<span id="header-username"></span>
</div>
<button class="btn btn-sm btn-outline" onclick="showPage('my-password')">🔑 비밀번호 변경</button>
<button class="btn btn-sm btn-gray" onclick="doLogout()">로그아웃</button>
</div>
</header>
<nav id="main-nav">
<button class="nav-btn active" onclick="showPage('my-pages')">MY Page</button>
<button class="nav-btn" onclick="showPage('notice')">📢 공지</button>
<button class="nav-btn" onclick="showPage('board')">📋 관리자 요청</button>
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">⚙️ 페이지 관리</button>
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
<button class="nav-btn admin-only" onclick="showPage('admin-notify')" style="display:none">🔔 알림 설정</button>
</nav>
<main>
<!-- MY Page -->
<div id="page-my-pages" class="page active">
<div class="page-header"><div><h2>MY Page List</h2><p>접속 가능한 웹페이지입니다. 클릭하면 새 탭에서 열립니다.</p></div></div>
<div id="my-pages-grid" class="grid"></div>
</div>
<!-- My Password -->
<div id="page-my-password" class="page">
<div class="page-header"><div><h2>비밀번호 변경</h2><p>현재 비밀번호 확인 후 새 비밀번호로 변경합니다.</p></div></div>
<div style="background:#fff;border-radius:12px;padding:32px;max-width:440px;border:1px solid var(--border)">
<div class="form-group"><label>현재 비밀번호</label><input type="password" id="my-current-pw" placeholder="현재 비밀번호 입력"/></div>
<div class="form-group"><label>새 비밀번호</label><input type="password" id="my-new-pw" placeholder="6자 이상 입력"/></div>
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="my-confirm-pw" placeholder="새 비밀번호 재입력"/></div>
<button class="btn" onclick="doChangePw()">변경하기</button>
<div id="my-pw-msg" class="error-msg"></div>
</div>
</div>
<!-- Notice List -->
<div id="page-notice" class="page">
<div class="board-header">
<div><h2>📢 공지사항</h2><p style="color:var(--muted);font-size:.875rem;margin-top:2px">관리자가 등록한 공지사항입니다.</p></div>
<button id="notice-write-btn" class="btn btn-sm" onclick="showNoticeWrite()" style="display:none">공지 작성</button>
</div>
<table class="board-table">
<thead><tr><th class="num">번호</th><th>제목</th><th class="author">작성자</th><th class="date">작성일</th></tr></thead>
<tbody id="notice-list-body"></tbody>
</table>
</div>
<!-- Notice Write -->
<div id="page-notice-write" class="page">
<div class="page-header"><div><h2>공지 작성</h2></div></div>
<div class="write-form">
<input type="text" id="notice-write-title" placeholder="제목을 입력하세요"/>
<textarea id="notice-write-content" placeholder="내용을 입력하세요"></textarea>
<div class="write-form-footer">
<button class="btn btn-sm btn-gray" onclick="showPage('notice')">취소</button>
<button class="btn btn-sm" onclick="submitNotice()">등록</button>
</div>
</div>
</div>
<!-- Notice Detail -->
<div id="page-notice-detail" class="page">
<div class="page-header" style="margin-bottom:16px">
<button class="btn btn-sm btn-gray" onclick="showPage('notice')">← 목록으로</button>
</div>
<div class="post-detail">
<div class="post-header">
<h2 id="notice-detail-title"></h2>
<div class="post-meta">
<span>✍️ <strong id="notice-detail-author"></strong></span>
<span>🕐 <span id="notice-detail-date"></span></span>
</div>
</div>
<div class="post-body" id="notice-detail-body"></div>
<div class="post-actions" id="notice-detail-actions"></div>
<div class="replies-section">
<div class="replies-title">💬 댓글</div>
<div id="notice-replies-list"></div>
<div class="reply-form">
<textarea id="notice-reply-input" placeholder="댓글을 입력하세요..."></textarea>
<div class="reply-form-footer">
<button class="btn btn-sm" onclick="submitNoticeReply()">댓글 등록</button>
</div>
</div>
</div>
</div>
</div>
<!-- Board List -->
<div id="page-board" class="page">
<div class="board-header">
<div><h2>📋 관리자 요청</h2><p style="color:var(--muted);font-size:.875rem;margin-top:2px">관리자에게 요청사항을 남길 수 있습니다.</p></div>
<button class="btn btn-sm" onclick="showBoardWrite()">게시글 작성</button>
</div>
<table class="board-table">
<thead><tr><th class="num">번호</th><th>제목</th><th class="author">작성자</th><th class="date">작성일</th></tr></thead>
<tbody id="board-list-body"></tbody>
</table>
</div>
<!-- Board Write -->
<div id="page-board-write" class="page">
<div class="page-header"><div><h2>게시글 작성</h2></div></div>
<div class="write-form">
<input type="text" id="write-title" placeholder="제목을 입력하세요"/>
<textarea id="write-content" placeholder="내용을 입력하세요"></textarea>
<div class="write-form-footer">
<button class="btn btn-sm btn-gray" onclick="showPage('board')">취소</button>
<button class="btn btn-sm" onclick="submitPost()">등록</button>
</div>
</div>
</div>
<!-- Board Detail -->
<div id="page-board-detail" class="page">
<div class="page-header" style="margin-bottom:16px">
<button class="btn btn-sm btn-gray" onclick="showPage('board')">← 목록으로</button>
</div>
<div class="post-detail">
<div class="post-header">
<h2 id="detail-title"></h2>
<div class="post-meta">
<span>✍️ <strong id="detail-author"></strong></span>
<span>🕐 <span id="detail-date"></span></span>
</div>
</div>
<div class="post-body" id="detail-body"></div>
<div class="post-actions" id="detail-actions"></div>
<div class="replies-section">
<div class="replies-title">💬 답글</div>
<div id="replies-list"></div>
<div class="reply-form">
<textarea id="reply-input" placeholder="답글을 입력하세요..."></textarea>
<div class="reply-form-footer">
<button class="btn btn-sm" onclick="submitReply()">답글 등록</button>
</div>
</div>
</div>
</div>
</div>
<!-- Admin: Pages -->
<div id="page-admin-pages" class="page">
<div class="page-header">
<div><h2>페이지 관리</h2><p>웹페이지를 추가·수정·삭제합니다.</p></div>
<button class="btn btn-sm" onclick="openPageModal()">+ 페이지 추가</button>
</div>
<div class="table-wrapper">
<table><thead><tr><th>이름</th><th>URL</th><th>설명</th><th>작업</th></tr></thead>
<tbody id="pages-table-body"></tbody></table>
</div>
</div>
<!-- Admin: Users -->
<div id="page-admin-users" class="page">
<div class="page-header">
<div><h2>사용자 관리</h2><p>계정 생성, 삭제, 비밀번호 변경, 페이지 접근 권한을 설정합니다.</p></div>
<button class="btn btn-sm" onclick="openUserModal()">+ 사용자 추가</button>
</div>
<div class="table-wrapper">
<table><thead><tr><th>사용자명</th><th>권한</th><th>상태</th><th>작업</th></tr></thead>
<tbody id="users-table-body"></tbody></table>
</div>
</div>
<!-- Admin: 알림 설정 -->
<div id="page-admin-notify" class="page">
<div class="page-header">
<div><h2>🔔 알림 채널 관리</h2><p>Discord Webhook 및 이메일 알림 채널을 추가·수정·삭제합니다.</p></div>
<button class="btn btn-sm btn-purple" onclick="testNotify()">📨 테스트 발송</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;">
<!-- 좌측: 입력 폼 -->
<div class="table-wrapper" style="padding:24px;">
<h3 id="notify-form-title" style="font-size:1rem;font-weight:700;margin-bottom:20px;"> 채널 추가</h3>
<div class="form-group">
<label>채널 이름 *</label>
<input type="text" id="nc-name" placeholder="예: Discord 운영채널, Gmail 알림"/>
</div>
<div class="form-group">
<label>채널 유형 *</label>
<select id="nc-type" onchange="onNotifyTypeChange()" style="width:100%;padding:11px 14px;border:2px solid var(--border);border-radius:8px;font-size:.95rem;">
<option value="both">Discord + Gmail 둘 다</option>
<option value="discord">Discord 전용</option>
<option value="email">Gmail 전용</option>
</select>
</div>
<!-- Discord 설정 -->
<div id="nc-discord-section">
<div style="display:flex;align-items:center;gap:8px;margin:16px 0 12px;">
<span style="background:#5865f2;color:#fff;border-radius:6px;padding:3px 10px;font-size:.8rem;font-weight:700;">Discord</span>
</div>
<div class="form-group">
<label>Webhook URL</label>
<input type="text" id="nc-discord-url" placeholder="https://discord.com/api/webhooks/..."/>
</div>
</div>
<!-- Gmail 설정 -->
<div id="nc-email-section">
<div style="display:flex;align-items:center;gap:8px;margin:16px 0 12px;">
<span style="background:#ea4335;color:#fff;border-radius:6px;padding:3px 10px;font-size:.8rem;font-weight:700;">Gmail</span>
</div>
<div class="form-group">
<label>발송 Gmail 계정</label>
<input type="email" id="nc-gmail-user" placeholder="sender@gmail.com"/>
</div>
<div class="form-group">
<label>Gmail 앱 비밀번호</label>
<div class="pw-wrap">
<input type="password" id="nc-gmail-pw" placeholder="편집 시 변경할 경우에만 입력"/>
<button class="pw-toggle" onclick="togglePw('nc-gmail-pw',this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<div class="form-group">
<label>수신 이메일 주소</label>
<input type="email" id="nc-alert-to" placeholder="receiver@gmail.com"/>
</div>
</div>
<div class="form-group" style="display:flex;align-items:center;gap:10px;">
<input type="checkbox" id="nc-enabled" checked style="width:16px;height:16px;accent-color:var(--primary)"/>
<label for="nc-enabled" style="cursor:pointer;margin:0;">활성화</label>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:8px;">
<button class="btn btn-sm btn-gray" onclick="resetNotifyForm()">초기화</button>
<button class="btn btn-sm" id="nc-save-btn" onclick="saveNotifyChannel()">💾 저장</button>
</div>
<div id="notify-save-msg" style="margin-top:10px;font-size:.85rem;text-align:right;"></div>
<input type="hidden" id="nc-editing-id" value=""/>
</div>
<!-- 우측: 채널 리스트 -->
<div class="table-wrapper" style="overflow:hidden;">
<div style="padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
<h3 style="font-size:1rem;font-weight:700;">등록된 채널 목록</h3>
<span id="nc-count" style="font-size:.8rem;color:var(--muted);"></span>
</div>
<div id="notify-channel-list" style="max-height:520px;overflow-y:auto;">
<div style="padding:40px;text-align:center;color:var(--muted);">등록된 채널이 없습니다.</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- MODALS -->
<div id="page-modal" class="modal-overlay">
<div class="modal">
<h3 id="page-modal-title">페이지 추가</h3>
<div class="form-group"><label>페이지 이름 *</label><input type="text" id="pm-name" placeholder="예: Jenkins CI"/></div>
<div class="form-group"><label>URL *</label><input type="text" id="pm-url" placeholder="예: http://192.168.1.10:8080"/></div>
<div class="form-group"><label>설명</label><input type="text" id="pm-desc" placeholder="짧은 설명 (선택)"/></div>
<div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('page-modal')">취소</button>
<button class="btn btn-sm" onclick="savePage()">저장</button>
</div>
</div>
</div>
<div id="user-modal" class="modal-overlay">
<div class="modal">
<h3>사용자 추가</h3>
<div class="form-group"><label>사용자명 *</label><input type="text" id="um-username" placeholder="예: john"/></div>
<div class="form-group"><label>초기 비밀번호 *</label><input type="password" id="um-password" placeholder="6자 이상"/></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="um-admin" style="width:16px;height:16px;accent-color:var(--admin)"> 관리자 권한 부여</label></div>
<p style="font-size:.8rem;color:var(--muted)">※ 신규 사용자는 최초 로그인 시 비밀번호 변경이 강제됩니다.</p>
<div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('user-modal')">취소</button>
<button class="btn btn-sm" onclick="saveUser()">저장</button>
</div>
</div>
</div>
<div id="access-modal" class="modal-overlay">
<div class="modal">
<h3 id="access-modal-title">접근 권한 설정</h3>
<p style="font-size:.85rem;color:var(--muted);margin-bottom:14px">접근 허용할 페이지를 선택하세요.</p>
<div id="access-checkbox-list" class="checkbox-list"></div>
<div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('access-modal')">취소</button>
<button class="btn btn-sm btn-purple" onclick="saveAccess()">저장</button>
</div>
</div>
</div>
<div id="admin-pw-modal" class="modal-overlay">
<div class="modal">
<h3 id="admin-pw-modal-title">비밀번호 변경</h3>
<div class="form-group"><label>새 비밀번호 *</label><input type="password" id="apm-password" placeholder="6자 이상 입력"/></div>
<div class="form-group"><label>새 비밀번호 확인 *</label><input type="password" id="apm-confirm" placeholder="새 비밀번호 재입력"/></div>
<p style="font-size:.8rem;color:var(--muted)">※ 변경 후 해당 사용자는 다음 로그인 시 비밀번호 변경이 강제됩니다.</p>
<div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('admin-pw-modal')">취소</button>
<button class="btn btn-sm btn-warning" onclick="saveAdminPw()">변경</button>
</div>
</div>
</div>
<div id="reset-pw-modal" class="modal-overlay">
<div class="modal">
<h3>임시 비밀번호 발급 완료</h3>
<div class="temp-pw-box">
<p>아래 임시 비밀번호를 사용자에게 전달하세요.</p>
<strong id="temp-pw-display"></strong>
<small>⚠️ 이 창을 닫으면 다시 확인할 수 없습니다!</small>
</div>
<div class="modal-footer"><button class="btn btn-sm" onclick="closeModal('reset-pw-modal');loadAdminUsers()">확인</button></div>
</div>
</div>
<script>
const API='/api';
let token=localStorage.getItem('portal_token');
let currentUser=null;
let editingPageId=null;
let editingUserId=null;
let currentPostId=null;
async function apiFetch(path,opts={}){
const res=await fetch(API+path,{headers:{'Content-Type':'application/json',...(token?{Authorization:'Bearer '+token}:{})}, ...opts});
if(res.status===401){doLogout();return;}
if(!res.ok){const e=await res.json().catch(()=>({}));throw new Error(e.detail||'Error');}
return res.json();
}
function showPage(name){
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));
document.getElementById('page-'+name).classList.add('active');
document.querySelectorAll('.nav-btn').forEach(b=>{
if(b.getAttribute('onclick')&&b.getAttribute('onclick').includes("'"+name+"'"))b.classList.add('active');
});
if(name==='my-pages')loadMyPages();
if(name==='notice')loadNotice();
if(name==='board')loadBoard();
if(name==='admin-pages')loadAdminPages();
if(name==='admin-users')loadAdminUsers();
if(name==='admin-notify')loadNotifyConfig();
}
function closeModal(id){document.getElementById(id).classList.remove('open');}
function openModal(id){document.getElementById(id).classList.add('open');}
// ── Login ─────────────────────────────────────────────────
async function doLogin(){
const username=document.getElementById('login-user').value.trim();
const password=document.getElementById('login-pass').value;
document.getElementById('login-error').textContent='';
try{
const data=await apiFetch('/auth/login',{method:'POST',body:JSON.stringify({username,password})});
token=data.token;
localStorage.setItem('portal_token',token);
currentUser={username:data.username,is_admin:data.is_admin,must_change_password:data.must_change_password};
if(data.must_change_password)showForceChangePw();
else showApp();
}catch(e){
document.getElementById('login-error').textContent=e.message;
document.getElementById('login-pass').value='';
}
}
function doLogout(){
token=null;currentUser=null;
localStorage.removeItem('portal_token');
document.getElementById('app-page').style.display='none';
document.getElementById('change-pw-page').style.display='none';
document.getElementById('login-page').style.display='flex';
document.getElementById('login-user').value='';
document.getElementById('login-pass').value='';
}
function showForceChangePw(){
document.getElementById('login-page').style.display='none';
document.getElementById('change-pw-page').style.display='flex';
}
async function doForceChangePw(){
const current=document.getElementById('force-current-pw').value;
const newPw=document.getElementById('force-new-pw').value;
const confirm=document.getElementById('force-confirm-pw').value;
const errEl=document.getElementById('force-pw-error');
errEl.textContent='';
if(newPw.length<6){errEl.textContent='새 비밀번호는 6자 이상이어야 합니다.';return;}
if(newPw!==confirm){errEl.textContent='새 비밀번호가 일치하지 않습니다.';return;}
try{
const data=await apiFetch('/auth/change-password',{method:'POST',body:JSON.stringify({current_password:current,new_password:newPw})});
token=data.token;localStorage.setItem('portal_token',token);
currentUser.must_change_password=false;
document.getElementById('change-pw-page').style.display='none';
showApp();
}catch(e){errEl.textContent=e.message;}
}
function showApp(){
document.getElementById('login-page').style.display='none';
document.getElementById('change-pw-page').style.display='none';
document.getElementById('app-page').style.display='flex';
document.getElementById('header-username').textContent=currentUser.username;
document.getElementById('admin-badge').style.display=currentUser.is_admin?'inline':'none';
document.querySelectorAll('.admin-only').forEach(el=>{el.style.display=currentUser.is_admin?'inline-block':'none';});
showPage('my-pages');
}
function togglePw(inputId,iconId){
const input=document.getElementById(inputId);
const icon=document.getElementById(iconId);
if(input.type==='password'){
input.type='text';
icon.innerHTML='<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/>';
}else{
input.type='password';
icon.innerHTML='<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>';
}
}
// ── My Password ───────────────────────────────────────────
async function doChangePw(){
const current=document.getElementById('my-current-pw').value;
const newPw=document.getElementById('my-new-pw').value;
const confirm=document.getElementById('my-confirm-pw').value;
const msgEl=document.getElementById('my-pw-msg');
msgEl.style.color='var(--danger)';msgEl.textContent='';
if(newPw.length<6){msgEl.textContent='새 비밀번호는 6자 이상이어야 합니다.';return;}
if(newPw!==confirm){msgEl.textContent='새 비밀번호가 일치하지 않습니다.';return;}
try{
const data=await apiFetch('/auth/change-password',{method:'POST',body:JSON.stringify({current_password:current,new_password:newPw})});
token=data.token;localStorage.setItem('portal_token',token);
msgEl.style.color='var(--success)';msgEl.textContent='✅ 비밀번호가 성공적으로 변경되었습니다.';
document.getElementById('my-current-pw').value='';
document.getElementById('my-new-pw').value='';
document.getElementById('my-confirm-pw').value='';
}catch(e){msgEl.textContent=e.message;}
}
// ── MY Page List ──────────────────────────────────────────
async function loadMyPages(){
const pages=await apiFetch('/my-pages');
const grid=document.getElementById('my-pages-grid');
if(!pages||pages.length===0){
grid.innerHTML='<div class="empty-state" style="grid-column:1/-1"><p>접근 가능한 페이지가 없습니다.<br>관리자에게 문의하세요.</p></div>';return;
}
grid.innerHTML=pages.map(p=>{
const faviconUrl=getFaviconUrl(p.url);
return `<a class="card" href="${escHtml(p.url)}" target="_blank" rel="noopener">
<div class="card-icon">
<img src="${faviconUrl}" alt=""
onload="this.style.padding='4px'"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"
style="width:100%;height:100%;object-fit:contain;"/>
<svg style="display:none;width:100%;height:100%;align-items:center;justify-content:center" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 010 20M12 2a15 15 0 000 20"/></svg>
</div>
<h3>${escHtml(p.name)}</h3>
<p>${escHtml(p.description||'설명 없음')}</p>
</a>`;
}).join('');
}
function getFaviconUrl(url){
try{
const u=new URL(url);
return u.origin+'/favicon.ico';
}catch{return '';}
}
// ── Notice ───────────────────────────────────────────────
let currentNoticeId=null;
async function loadNotice(){
// 관리자면 작성 버튼 표시
const btn=document.getElementById('notice-write-btn');
if(btn)btn.style.display=currentUser&&currentUser.is_admin?'inline-block':'none';
const posts=await apiFetch('/notice/posts');
const tbody=document.getElementById('notice-list-body');
if(!posts||posts.length===0){
tbody.innerHTML='<tr><td colspan="4" style="text-align:center;padding:40px;color:var(--muted)">등록된 공지가 없습니다.</td></tr>';return;
}
tbody.innerHTML=posts.map((p,i)=>`
<tr onclick="openNotice(${p.id})">
<td class="num">${posts.length-i}</td>
<td class="title-cell">📢 ${escHtml(p.title)}</td>
<td class="author">${escHtml(p.author_name)}</td>
<td class="date">${fmtDate(p.created_at)}</td>
</tr>`).join('');
}
function showNoticeWrite(){
document.getElementById('notice-write-title').value='';
document.getElementById('notice-write-content').value='';
showPage('notice-write');
}
async function submitNotice(){
const title=document.getElementById('notice-write-title').value.trim();
const content=document.getElementById('notice-write-content').value.trim();
if(!title){alert('제목을 입력해주세요.');return;}
if(!content){alert('내용을 입력해주세요.');return;}
try{
await apiFetch('/notice/posts',{method:'POST',body:JSON.stringify({title,content})});
showPage('notice');
}catch(e){alert(e.message);}
}
async function openNotice(id){
currentNoticeId=id;
const data=await apiFetch('/notice/posts/'+id);
const p=data.post;
document.getElementById('notice-detail-title').textContent=p.title;
document.getElementById('notice-detail-author').textContent=p.author_name;
document.getElementById('notice-detail-date').textContent=fmtDate(p.created_at);
document.getElementById('notice-detail-body').textContent=p.content;
document.getElementById('notice-reply-input').value='';
const actions=document.getElementById('notice-detail-actions');
actions.innerHTML=currentUser&&currentUser.is_admin
?`<button class="btn btn-sm btn-danger" onclick="deleteNotice(${p.id})">공지 삭제</button>`:'';
renderNoticeReplies(data.replies);
showPage('notice-detail');
}
function renderNoticeReplies(replies){
const el=document.getElementById('notice-replies-list');
if(!replies||replies.length===0){el.innerHTML='<p style="color:var(--muted);font-size:.85rem;padding:8px 0">아직 댓글이 없습니다.</p>';return;}
el.innerHTML=replies.map(r=>{
const canDel=currentUser&&(currentUser.is_admin||(r.author_name===currentUser.username));
return `<div class="reply-item">
<div class="reply-meta">
<span class="reply-author">💬 ${escHtml(r.author_name)}</span>
<span style="display:flex;align-items:center;gap:8px">
<span class="reply-date">${fmtDate(r.created_at)}</span>
${canDel?`<button class="reply-del" onclick="deleteNoticeReply(${r.id})">삭제</button>`:''}
</span>
</div>
<div class="reply-content">${escHtml(r.content)}</div>
</div>`;
}).join('');
}
async function submitNoticeReply(){
const content=document.getElementById('notice-reply-input').value.trim();
if(!content){alert('댓글 내용을 입력해주세요.');return;}
try{
await apiFetch('/notice/posts/'+currentNoticeId+'/replies',{method:'POST',body:JSON.stringify({content})});
document.getElementById('notice-reply-input').value='';
const data=await apiFetch('/notice/posts/'+currentNoticeId);
renderNoticeReplies(data.replies);
}catch(e){alert(e.message);}
}
async function deleteNotice(id){
if(!confirm('공지를 삭제하시겠습니까?'))return;
try{await apiFetch('/notice/posts/'+id,{method:'DELETE'});showPage('notice');}
catch(e){alert(e.message);}
}
async function deleteNoticeReply(id){
if(!confirm('댓글을 삭제하시겠습니까?'))return;
try{
await apiFetch('/notice/replies/'+id,{method:'DELETE'});
const data=await apiFetch('/notice/posts/'+currentNoticeId);
renderNoticeReplies(data.replies);
}catch(e){alert(e.message);}
}
// ── Board ─────────────────────────────────────────────────
async function loadBoard(){
const posts=await apiFetch('/board/posts');
const tbody=document.getElementById('board-list-body');
if(!posts||posts.length===0){
tbody.innerHTML='<tr><td colspan="4" style="text-align:center;padding:40px;color:var(--muted)">등록된 게시글이 없습니다.</td></tr>';return;
}
tbody.innerHTML=posts.map((p,i)=>`
<tr onclick="openPost(${p.id})">
<td class="num">${posts.length-i}</td>
<td class="title-cell">${escHtml(p.title)}</td>
<td class="author">${escHtml(p.author_name)}</td>
<td class="date">${fmtDate(p.created_at)}</td>
</tr>`).join('');
}
function showBoardWrite(){
document.getElementById('write-title').value='';
document.getElementById('write-content').value='';
showPage('board-write');
}
async function submitPost(){
const title=document.getElementById('write-title').value.trim();
const content=document.getElementById('write-content').value.trim();
if(!title){alert('제목을 입력해주세요.');return;}
if(!content){alert('내용을 입력해주세요.');return;}
try{
await apiFetch('/board/posts',{method:'POST',body:JSON.stringify({title,content})});
showPage('board');
}catch(e){alert(e.message);}
}
async function openPost(id){
currentPostId=id;
const data=await apiFetch('/board/posts/'+id);
const p=data.post;
document.getElementById('detail-title').textContent=p.title;
document.getElementById('detail-author').textContent=p.author_name;
document.getElementById('detail-date').textContent=fmtDate(p.created_at);
document.getElementById('detail-body').textContent=p.content;
document.getElementById('reply-input').value='';
// 삭제 버튼 (본인 or 관리자)
const actions=document.getElementById('detail-actions');
const canDelete=currentUser.is_admin||(p.author_name===currentUser.username);
actions.innerHTML=canDelete?`<button class="btn btn-sm btn-danger" onclick="deletePost(${p.id})">게시글 삭제</button>`:'';
// 답글 렌더링
renderReplies(data.replies);
showPage('board-detail');
}
function renderReplies(replies){
const el=document.getElementById('replies-list');
if(!replies||replies.length===0){el.innerHTML='<p style="color:var(--muted);font-size:.85rem;padding:8px 0">아직 답글이 없습니다.</p>';return;}
el.innerHTML=replies.map(r=>{
const canDel=currentUser.is_admin||(r.author_name===currentUser.username);
return `<div class="reply-item">
<div class="reply-meta">
<span class="reply-author">💬 ${escHtml(r.author_name)}</span>
<span style="display:flex;align-items:center;gap:8px">
<span class="reply-date">${fmtDate(r.created_at)}</span>
${canDel?`<button class="reply-del" onclick="deleteReply(${r.id})">삭제</button>`:''}
</span>
</div>
<div class="reply-content">${escHtml(r.content)}</div>
</div>`;
}).join('');
}
async function submitReply(){
const content=document.getElementById('reply-input').value.trim();
if(!content){alert('답글 내용을 입력해주세요.');return;}
try{
await apiFetch('/board/posts/'+currentPostId+'/replies',{method:'POST',body:JSON.stringify({content})});
document.getElementById('reply-input').value='';
const data=await apiFetch('/board/posts/'+currentPostId);
renderReplies(data.replies);
}catch(e){alert(e.message);}
}
async function deletePost(id){
if(!confirm('게시글을 삭제하시겠습니까?'))return;
try{await apiFetch('/board/posts/'+id,{method:'DELETE'});showPage('board');}
catch(e){alert(e.message);}
}
async function deleteReply(id){
if(!confirm('답글을 삭제하시겠습니까?'))return;
try{
await apiFetch('/board/replies/'+id,{method:'DELETE'});
const data=await apiFetch('/board/posts/'+currentPostId);
renderReplies(data.replies);
}catch(e){alert(e.message);}
}
// ── Admin: Pages ──────────────────────────────────────────
async function loadAdminPages(){
const pages=await apiFetch('/admin/webpages');
const tbody=document.getElementById('pages-table-body');
tbody.innerHTML=pages.map(p=>`
<tr>
<td><strong>${escHtml(p.name)}</strong></td>
<td><a href="${escHtml(p.url)}" target="_blank" style="color:var(--primary)">${escHtml(p.url)}</a></td>
<td style="color:var(--muted)">${escHtml(p.description||'-')}</td>
<td><div class="actions">
<button class="btn btn-sm btn-gray" onclick="openPageModal(${p.id},'${escJs(p.name)}','${escJs(p.url)}','${escJs(p.description||'')}')">수정</button>
<button class="btn btn-sm btn-danger" onclick="deletePage(${p.id})">삭제</button>
</div></td>
</tr>`).join('')||'<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:40px">등록된 페이지가 없습니다.</td></tr>';
}
function openPageModal(id,name,url,desc){
editingPageId=id||null;
document.getElementById('page-modal-title').textContent=id?'페이지 수정':'페이지 추가';
document.getElementById('pm-name').value=name||'';
document.getElementById('pm-url').value=url||'';
document.getElementById('pm-desc').value=desc||'';
openModal('page-modal');
}
async function savePage(){
const body={name:document.getElementById('pm-name').value.trim(),url:document.getElementById('pm-url').value.trim(),description:document.getElementById('pm-desc').value.trim()};
if(!body.name||!body.url){alert('이름과 URL은 필수입니다.');return;}
try{
if(editingPageId)await apiFetch('/admin/webpages/'+editingPageId,{method:'PUT',body:JSON.stringify(body)});
else await apiFetch('/admin/webpages',{method:'POST',body:JSON.stringify(body)});
closeModal('page-modal');loadAdminPages();
}catch(e){alert(e.message);}
}
async function deletePage(id){
if(!confirm('이 페이지를 삭제하시겠습니까?'))return;
await apiFetch('/admin/webpages/'+id,{method:'DELETE'});loadAdminPages();
}
// ── Admin: Users ──────────────────────────────────────────
async function loadAdminUsers(){
const users=await apiFetch('/admin/users');
const tbody=document.getElementById('users-table-body');
tbody.innerHTML=users.map(u=>{
const tags=[];
if(u.is_locked)tags.push('<span class="tag tag-locked">🔒 잠김</span>');
else tags.push('<span class="tag tag-ok">정상</span>');
if(u.must_change_password)tags.push('<span class="tag tag-warning">초기PW</span>');
if(u.password_change_requested)tags.push('<span class="tag tag-locked">변경요청</span>');
const acts=[];
if(!u.is_admin)acts.push(`<button class="btn btn-sm btn-purple" onclick="openAccessModal(${u.id},'${escJs(u.username)}')">권한</button>`);
if(u.username!=='admin'){
acts.push(`<button class="btn btn-sm btn-warning" onclick="openAdminPwModal(${u.id},'${escJs(u.username)}')">PW변경</button>`);
acts.push(`<button class="btn btn-sm btn-gray" onclick="resetPassword(${u.id},'${escJs(u.username)}')">임시PW</button>`);
}
if(u.is_locked)acts.push(`<button class="btn btn-sm btn-success" onclick="unlockUser(${u.id})">잠금해제</button>`);
if(u.username!=='admin')acts.push(`<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">삭제</button>`);
return `<tr><td><strong>${escHtml(u.username)}</strong></td>
<td><span class="tag ${u.is_admin?'tag-admin':'tag-user'}">${u.is_admin?'관리자':'일반사용자'}</span></td>
<td>${tags.join(' ')}</td>
<td><div class="actions">${acts.join('')}</div></td></tr>`;
}).join('')||'<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:40px">사용자가 없습니다.</td></tr>';
}
function openUserModal(){
document.getElementById('um-username').value='';
document.getElementById('um-password').value='';
document.getElementById('um-admin').checked=false;
openModal('user-modal');
}
async function saveUser(){
const body={username:document.getElementById('um-username').value.trim(),password:document.getElementById('um-password').value,is_admin:document.getElementById('um-admin').checked};
if(!body.username||!body.password){alert('사용자명과 비밀번호는 필수입니다.');return;}
try{await apiFetch('/admin/users',{method:'POST',body:JSON.stringify(body)});closeModal('user-modal');loadAdminUsers();}
catch(e){alert(e.message);}
}
async function deleteUser(id){
if(!confirm('이 사용자를 삭제하시겠습니까?'))return;
await apiFetch('/admin/users/'+id,{method:'DELETE'});loadAdminUsers();
}
function openAdminPwModal(userId,username){
editingUserId=userId;
document.getElementById('admin-pw-modal-title').textContent=`'${username}' 비밀번호 변경`;
document.getElementById('apm-password').value='';
document.getElementById('apm-confirm').value='';
openModal('admin-pw-modal');
}
async function saveAdminPw(){
const pw=document.getElementById('apm-password').value;
const confirm=document.getElementById('apm-confirm').value;
if(pw.length<6){alert('비밀번호는 6자 이상이어야 합니다.');return;}
if(pw!==confirm){alert('비밀번호가 일치하지 않습니다.');return;}
try{
await apiFetch('/admin/users/'+editingUserId+'/password',{method:'PUT',body:JSON.stringify({new_password:pw})});
closeModal('admin-pw-modal');alert('비밀번호가 변경되었습니다.');loadAdminUsers();
}catch(e){alert(e.message);}
}
async function resetPassword(userId,username){
if(!confirm(`'${username}' 의 임시 비밀번호를 발급하시겠습니까?`))return;
try{
const data=await apiFetch('/admin/users/'+userId+'/reset-password',{method:'POST'});
document.getElementById('temp-pw-display').textContent=data.temp_password;
openModal('reset-pw-modal');
}catch(e){alert(e.message);}
}
async function unlockUser(userId){
if(!confirm('이 계정의 잠금을 해제하시겠습니까?'))return;
await apiFetch('/admin/users/'+userId+'/unlock',{method:'POST'});loadAdminUsers();
}
async function openAccessModal(userId,username){
editingUserId=userId;
document.getElementById('access-modal-title').textContent=`'${username}' 접근 권한 설정`;
const pages=await apiFetch('/admin/users/'+userId+'/pages');
const list=document.getElementById('access-checkbox-list');
list.innerHTML=pages.map(p=>`
<label class="checkbox-item">
<input type="checkbox" value="${p.id}" ${p.has_access?'checked':''}>
<div class="item-info"><strong>${escHtml(p.name)}</strong><span>${escHtml(p.url)}</span></div>
</label>`).join('')||'<div style="padding:20px;text-align:center;color:var(--muted)">등록된 페이지가 없습니다.</div>';
openModal('access-modal');
}
async function saveAccess(){
const checked=[...document.querySelectorAll('#access-checkbox-list input:checked')].map(i=>parseInt(i.value));
await apiFetch('/admin/users/'+editingUserId+'/pages',{method:'PUT',body:JSON.stringify({webpage_ids:checked})});
closeModal('access-modal');alert('권한이 저장되었습니다.');
}
// ── 알림 채널 관리 ─────────────────────────────────────────
let notifyChannels = [];
function onNotifyTypeChange(){
const type = document.getElementById('nc-type').value;
document.getElementById('nc-discord-section').style.display = (type==='email') ? 'none' : '';
document.getElementById('nc-email-section').style.display = (type==='discord') ? 'none' : '';
}
async function loadNotifyConfig(){
try{
notifyChannels = await apiFetch('/admin/notify-channels');
renderNotifyChannelList();
document.getElementById('nc-count').textContent = `${notifyChannels.length}`;
}catch(e){ console.error('알림 채널 로드 실패:', e.message); }
}
function renderNotifyChannelList(){
const el = document.getElementById('notify-channel-list');
if(!notifyChannels.length){
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--muted);">등록된 채널이 없습니다.</div>';
return;
}
const typeLabel = {both:'Discord+Gmail', discord:'Discord', email:'Gmail'};
const typeColor = {both:'#7c3aed', discord:'#5865f2', email:'#ea4335'};
el.innerHTML = notifyChannels.map(ch => `
<div id="nc-row-${ch.id}" style="padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;cursor:pointer;transition:background .15s;"
onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background=''"
onclick="selectNotifyChannel(${ch.id})">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span style="font-weight:600;font-size:.9rem;">${escHtml(ch.name)}</span>
<span style="background:${typeColor[ch.type]};color:#fff;border-radius:4px;padding:1px 7px;font-size:.72rem;">${typeLabel[ch.type]||ch.type}</span>
${ch.enabled ? '' : '<span style="background:#94a3b8;color:#fff;border-radius:4px;padding:1px 7px;font-size:.72rem;">비활성</span>'}
</div>
<div style="font-size:.78rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
${ch.discord_webhook_url ? '🔗 Webhook 설정됨' : ''}
${ch.discord_webhook_url && ch.gmail_user ? ' · ' : ''}
${ch.gmail_user ? `📧 ${escHtml(ch.gmail_user)}${escHtml(ch.alert_email_to)}` : ''}
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0;">
<button class="btn btn-sm btn-gray" style="padding:5px 10px;" onclick="event.stopPropagation();selectNotifyChannel(${ch.id})">✏️</button>
<button class="btn btn-sm btn-danger" style="padding:5px 10px;" onclick="event.stopPropagation();deleteNotifyChannel(${ch.id})">🗑️</button>
</div>
</div>
`).join('');
}
function selectNotifyChannel(id){
const ch = notifyChannels.find(c => c.id === id);
if(!ch) return;
// 기존 선택 해제
document.querySelectorAll('[id^=nc-row-]').forEach(el => el.style.background = '');
document.getElementById('nc-row-'+id).style.background = '#eff6ff';
document.getElementById('nc-editing-id').value = ch.id;
document.getElementById('nc-name').value = ch.name;
document.getElementById('nc-type').value = ch.type;
document.getElementById('nc-discord-url').value = ch.discord_webhook_url || '';
document.getElementById('nc-gmail-user').value = ch.gmail_user || '';
document.getElementById('nc-gmail-pw').value = ''; // 보안상 비움
document.getElementById('nc-alert-to').value = ch.alert_email_to || '';
document.getElementById('nc-enabled').checked = ch.enabled;
document.getElementById('notify-form-title').textContent = '✏️ 채널 편집';
document.getElementById('nc-save-btn').textContent = '💾 수정 저장';
document.getElementById('notify-save-msg').textContent = '';
onNotifyTypeChange();
}
function resetNotifyForm(){
document.getElementById('nc-editing-id').value = '';
document.getElementById('nc-name').value = '';
document.getElementById('nc-type').value = 'both';
document.getElementById('nc-discord-url').value = '';
document.getElementById('nc-gmail-user').value = '';
document.getElementById('nc-gmail-pw').value = '';
document.getElementById('nc-alert-to').value = '';
document.getElementById('nc-enabled').checked = true;
document.getElementById('notify-form-title').textContent = ' 채널 추가';
document.getElementById('nc-save-btn').textContent = '💾 저장';
document.getElementById('notify-save-msg').textContent = '';
document.querySelectorAll('[id^=nc-row-]').forEach(el => el.style.background = '');
onNotifyTypeChange();
}
async function saveNotifyChannel(){
const msgEl = document.getElementById('notify-save-msg');
const editingId = document.getElementById('nc-editing-id').value;
const name = document.getElementById('nc-name').value.trim();
if(!name){ msgEl.style.color='var(--danger)'; msgEl.textContent='❌ 채널 이름을 입력하세요.'; return; }
const payload = {
name,
type: document.getElementById('nc-type').value,
discord_webhook_url: document.getElementById('nc-discord-url').value.trim(),
gmail_user: document.getElementById('nc-gmail-user').value.trim(),
gmail_app_password: document.getElementById('nc-gmail-pw').value,
alert_email_to: document.getElementById('nc-alert-to').value.trim(),
enabled: document.getElementById('nc-enabled').checked,
};
try{
if(editingId){
await apiFetch(`/admin/notify-channels/${editingId}`, {method:'PUT', body:JSON.stringify(payload)});
msgEl.style.color='var(--success)'; msgEl.textContent='✅ 수정되었습니다.';
} else {
await apiFetch('/admin/notify-channels', {method:'POST', body:JSON.stringify(payload)});
msgEl.style.color='var(--success)'; msgEl.textContent='✅ 채널이 추가되었습니다.';
}
await loadNotifyConfig();
resetNotifyForm();
}catch(e){
msgEl.style.color='var(--danger)'; msgEl.textContent='❌ 저장 실패: '+e.message;
}
}
async function deleteNotifyChannel(id){
if(!confirm('이 채널을 삭제하시겠습니까?')) return;
try{
await apiFetch(`/admin/notify-channels/${id}`, {method:'DELETE'});
await loadNotifyConfig();
resetNotifyForm();
}catch(e){ alert('삭제 실패: '+e.message); }
}
async function testNotify(){
try{
await apiFetch('/admin/notify-test');
alert('✅ 테스트 알림이 발송되었습니다.\nDiscord와 이메일을 확인해주세요.');
}catch(e){alert('테스트 실패: '+e.message);}
}
// ── Utils ─────────────────────────────────────────────────
function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function escJs(s){return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'");}
function fmtDate(s){
if(!s)return '';
const d=new Date(s);
return d.toLocaleDateString('ko-KR',{year:'numeric',month:'2-digit',day:'2-digit'})+' '+
d.toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit'});
}
// ── Init ──────────────────────────────────────────────────
(async function init(){
if(token){
try{
currentUser=await apiFetch('/auth/me');
if(currentUser.must_change_password)showForceChangePw();
else showApp();
}catch{doLogout();}
}
})();
</script>
</body>
</html>