Files
nginx-portal/frontend/index.html
qorgh529 5e7e245858
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
init: web portal
2026-04-06 21:16:17 +09:00

517 lines
23 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;
--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: 380px;
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; color: var(--text); }
.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-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; }
/* ── 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; letter-spacing: .5px;
}
.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;
}
.user-chip svg { color: var(--muted); }
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); }
.empty-state svg { opacity: .3; margin-bottom: 16px; }
.empty-state p { font-size: 1rem; }
/* ── Admin 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: 8px; }
.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); }
/* ── 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); }
.section-divider { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
</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>
<!-- ══════════ 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="doLogout()">로그아웃</button>
</div>
</header>
<nav id="main-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>
<!-- 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></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="비밀번호 입력"></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>
<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>
<script>
const API = '/api';
let token = localStorage.getItem('portal_token');
let currentUser = null;
let editingPageId = null;
let editingUserId = null;
// ── Helpers ─────────────────────────────────────────────
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');
const btns = document.querySelectorAll('.nav-btn');
btns.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 };
showApp();
} catch (e) {
document.getElementById('login-error').textContent = '아이디 또는 비밀번호가 올바르지 않습니다.';
}
}
function doLogout() {
token = null; currentUser = null;
localStorage.removeItem('portal_token');
document.getElementById('app-page').style.display = 'none';
document.getElementById('login-page').style.display = 'flex';
document.getElementById('login-user').value = '';
document.getElementById('login-pass').value = '';
}
function showApp() {
document.getElementById('login-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 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">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
<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 => `
<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><div class="actions">
${!u.is_admin ? `<button class="btn btn-sm btn-purple" onclick="openAccessModal(${u.id},'${escJs(u.username)}')">권한 설정</button>` : ''}
${u.username !== 'admin' ? `<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">삭제</button>` : ''}
</div></td>
</tr>`).join('') || '<tr><td colspan="3" 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();
}
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');
showApp();
} catch { doLogout(); }
}
})();
</script>
</body>
</html>