Files
nginx-portal/frontend/index.html
qorgh529 3f7166f89e
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
feat: 비밀번호 관리 기능 추가
2026-04-10 17:59:44 +09:00

599 lines
33 KiB
HTML
Executable File

<!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: #ffffff; --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 0%, #2563eb 100%); }
.login-box { background: white; border-radius: 16px; padding: 48px 40px; width: 400px; box-shadow: 0 25px 60px rgba(0,0,0,0.3); }
.login-logo { text-align: center; margin-bottom: 32px; }
.login-logo svg { width: 56px; height: 56px; }
.login-logo h1 { font-size: 1.5rem; font-weight: 700; color: var(--primary); margin-top: 12px; }
.login-logo p { color: var(--muted); font-size: 0.875rem; margin-top: 4px; }
.form-group { margin-bottom: 18px; }
.form-group label { display: block; font-size: 0.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: 0.95rem; transition: border-color .2s; }
.form-group input:focus { outline: none; border-color: var(--primary); }
.btn { width: 100%; padding: 12px; background: var(--primary); color: white; 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: 0.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: white; } .btn-warning:hover { background: #d97706; }
.btn-gray { background: #94a3b8; } .btn-gray:hover { background: #64748b; }
.btn-purple { background: var(--admin); } .btn-purple:hover { background: #6d28d9; }
.error-msg { color: var(--danger); font-size: 0.85rem; margin-top: 10px; text-align: center; }
.warning-msg { color: var(--warning); font-size: 0.85rem; margin-top: 10px; text-align: center; }
/* ── Force Change Password Page ── */
#change-pw-page { display: none; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%); }
.change-pw-box { background: white; border-radius: 16px; padding: 48px 40px; width: 400px; box-shadow: 0 25px 60px rgba(0,0,0,0.3); }
.change-pw-box h2 { font-size: 1.3rem; font-weight: 700; margin-bottom: 8px; color: var(--text); }
.change-pw-box p { color: var(--muted); font-size: 0.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: 0.85rem; color: #92400e; }
/* ── App Layout ── */
#app-page { display: none; min-height: 100vh; flex-direction: column; }
header { background: white; 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,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: white; font-size: 0.7rem; font-weight: 700; padding: 2px 8px; border-radius: 20px; }
.header-right { display: flex; align-items: center; gap: 14px; }
.user-chip { display: flex; align-items: center; gap: 7px; background: var(--bg); border-radius: 20px; padding: 5px 14px; font-size: 0.875rem; font-weight: 500; }
nav { background: white; 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: 0.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: 0.875rem; margin-top: 2px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
.card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.07); border: 1px solid var(--border); transition: transform .15s, box-shadow .15s; cursor: pointer; text-decoration: none; color: inherit; }
.card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
.card-icon { width: 48px; height: 48px; border-radius: 10px; background: linear-gradient(135deg, var(--primary), #60a5fa); display: flex; align-items: center; justify-content: center; margin-bottom: 14px; }
.card-icon svg { color: white; }
.card h3 { font-size: 1rem; font-weight: 600; margin-bottom: 6px; }
.card p { font-size: 0.825rem; color: var(--muted); line-height: 1.5; }
.card-url { font-size: 0.75rem; color: var(--primary); margin-top: 10px; word-break: break-all; }
.empty-state { text-align: center; padding: 80px 20px; color: var(--muted); }
/* ── Table ── */
.table-wrapper { background: white; 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: 0.8rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
td { padding: 13px 16px; font-size: 0.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: 0.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; }
/* ── Modal ── */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.open { display: flex; }
.modal { background: white; border-radius: 16px; padding: 32px; width: 480px; max-width: 95vw; box-shadow: 0 30px 80px rgba(0,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: 0.875rem; display: block; }
.checkbox-item .item-info span { font-size: 0.75rem; color: var(--muted); }
/* ── Temp PW Result ── */
.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: 0.85rem; color: var(--muted); margin-bottom: 8px; }
.temp-pw-box strong { font-size: 1.4rem; font-family: monospace; color: var(--text); letter-spacing: 2px; }
.temp-pw-box small { display: block; margin-top: 8px; font-size: 0.75rem; color: var(--danger); }
</style>
</head>
<body>
<!-- ══════════ LOGIN ══════════ -->
<div id="login-page">
<div class="login-box">
<div class="login-logo">
<svg viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<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><input type="password" id="login-pass" placeholder="비밀번호 입력" onkeydown="if(event.key==='Enter')doLogin()"/></div>
<button class="btn" onclick="doLogin()">로그인</button>
<div id="login-error" class="error-msg"></div>
</div>
</div>
<!-- ══════════ 강제 비밀번호 변경 ══════════ -->
<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-gray" onclick="showPage('my-password')">🔑 비밀번호 변경</button>
<button class="btn btn-sm btn-gray" onclick="doLogout()">로그아웃</button>
</div>
</header>
<nav>
<button class="nav-btn active" onclick="showPage('my-pages')">🏠 내 페이지</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>
</nav>
<main>
<!-- My Pages -->
<div id="page-my-pages" class="page active">
<div class="page-header"><div><h2>내 페이지 목록</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:white;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>
<!-- 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>
</main>
</div>
<!-- ══════════ MODALS ══════════ -->
<!-- Page Modal -->
<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>
<!-- User Modal -->
<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:0.8rem;color:var(--muted);margin-top:-8px">※ 신규 사용자는 최초 로그인 시 비밀번호 변경이 강제됩니다.</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>
<!-- Access Modal -->
<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>
<!-- Admin Change Password Modal -->
<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:0.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>
<!-- Reset Password Result Modal -->
<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;
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 === 'admin-pages') loadAdminPages();
if (name === 'admin-users') loadAdminUsers();
}
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
function openModal(id) { document.getElementById(id).classList.add('open'); }
// ── Login / Logout ───────────────────────────────────────
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;
}
}
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';
document.getElementById('force-current-pw').value = '';
document.getElementById('force-new-pw').value = '';
document.getElementById('force-confirm-pw').value = '';
}
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');
}
// ── My Password Change ───────────────────────────────────
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 Pages ─────────────────────────────────────────────
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 => `
<a class="card" href="${escHtml(p.url)}" target="_blank" rel="noopener">
<div class="card-icon"><svg 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>
<div class="card-url">${escHtml(p.url)}</div>
</a>`).join('');
}
// ── 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 statusTags = [];
if (u.is_locked) statusTags.push(`<span class="tag tag-locked">🔒 잠김</span>`);
else statusTags.push(`<span class="tag tag-ok">정상</span>`);
if (u.must_change_password) statusTags.push(`<span class="tag tag-warning">초기PW</span>`);
if (u.password_change_requested) statusTags.push(`<span class="tag tag-locked">변경요청</span>`);
const actions = [];
if (!u.is_admin) actions.push(`<button class="btn btn-sm btn-purple" onclick="openAccessModal(${u.id},'${escJs(u.username)}')">권한</button>`);
if (u.username !== 'admin') {
actions.push(`<button class="btn btn-sm btn-warning" onclick="openAdminPwModal(${u.id},'${escJs(u.username)}')">PW변경</button>`);
actions.push(`<button class="btn btn-sm btn-gray" onclick="resetPassword(${u.id},'${escJs(u.username)}')">임시PW</button>`);
}
if (u.is_locked) actions.push(`<button class="btn btn-sm btn-success" onclick="unlockUser(${u.id})">잠금해제</button>`);
if (u.username !== 'admin') actions.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>${statusTags.join(' ')}</td>
<td><div class="actions">${actions.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();
}
// ── Admin: 비밀번호 변경 ─────────────────────────────────
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); }
}
// ── Admin: 임시 비밀번호 발급 ────────────────────────────
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); }
}
// ── Admin: 잠금 해제 ─────────────────────────────────────
async function unlockUser(userId) {
if (!confirm('이 계정의 잠금을 해제하시겠습니까?')) return;
await apiFetch('/admin/users/' + userId + '/unlock', { method: 'POST' });
loadAdminUsers();
}
// ── Admin: Access ────────────────────────────────────────
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('권한이 저장되었습니다.');
}
// ── 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,"\\'"); }
// ── 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>