user administration

This commit is contained in:
roman
2026-06-11 19:33:03 +02:00
parent 639baafe4a
commit 31800eceda
12 changed files with 1031 additions and 9 deletions
+435 -3
View File
@@ -102,6 +102,114 @@
}
.btn-logout:hover { border-color: #f85149; color: #f85149; }
/* ── AVATAR DROPDOWN ── */
.avatar-wrap { position: relative; }
.topbar-avatar { cursor: pointer; transition: box-shadow 0.15s; }
.topbar-avatar:hover { box-shadow: 0 0 0 2px #388bfd; }
.avatar-menu {
display: none; position: absolute; top: calc(100% + 8px); right: 0;
background: #1c1f2e; border: 1px solid #30363d; border-radius: 9px;
min-width: 180px; z-index: 500;
box-shadow: 0 8px 24px rgba(0,0,0,0.55);
overflow: hidden;
}
.avatar-menu.open { display: block; }
.avatar-menu-item {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.62rem 1rem; font-size: 0.85rem; color: #c9d1d9;
cursor: pointer; transition: background 0.12s; border: none;
background: none; width: 100%; text-align: left;
}
.avatar-menu-item:hover { background: #30363d; }
.avatar-menu-item .mi-icon { font-size: 1rem; }
.avatar-menu-sep { border: none; border-top: 1px solid #30363d; margin: 3px 0; }
/* ── ADMIN PANEL ── */
.admin-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.7); z-index: 600;
align-items: flex-start; justify-content: center;
overflow-y: auto; padding: 2rem 1rem;
}
.admin-overlay.open { display: flex; }
.admin-panel {
background: #161b22; border: 1px solid #30363d; border-radius: 14px;
width: 100%; max-width: 820px;
box-shadow: 0 12px 48px rgba(0,0,0,0.7);
display: flex; flex-direction: column;
min-height: 0;
}
.admin-head {
display: flex; align-items: center; gap: 1rem;
padding: 1rem 1.5rem; border-bottom: 1px solid #30363d; flex-shrink: 0;
}
.admin-head h2 { font-size: 1rem; color: #c9d1d9; margin: 0; flex: 1; }
.btn-admin-close {
background: none; border: none; color: #6e7681; cursor: pointer;
font-size: 1.2rem; line-height: 1; padding: 2px;
transition: color 0.12s;
}
.btn-admin-close:hover { color: #f85149; }
.admin-tabs {
display: flex; gap: 0; border-bottom: 1px solid #30363d; flex-shrink: 0;
}
.admin-tab {
padding: 0.62rem 1.25rem; font-size: 0.85rem; color: #8b949e;
cursor: pointer; background: none; border: none;
border-bottom: 2px solid transparent; transition: all 0.12s;
font-weight: 500;
}
.admin-tab:hover { color: #c9d1d9; }
.admin-tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.admin-body { padding: 1.25rem 1.5rem; flex: 1; }
.admin-section { display: none; }
.admin-section.active { display: block; }
.admin-toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1rem;
}
.admin-toolbar h3 { font-size: 0.9rem; color: #8b949e; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin: 0; }
.btn-admin-add {
padding: 0.38rem 0.9rem; background: #238636; border: none; border-radius: 6px;
color: #fff; font-size: 0.82rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
}
.btn-admin-add:hover { background: #2ea043; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.admin-table th {
text-align: left; color: #6e7681; font-size: 0.74rem; text-transform: uppercase;
letter-spacing: 0.4px; padding: 0.4rem 0.75rem; border-bottom: 1px solid #21262d;
}
.admin-table td { padding: 0.55rem 0.75rem; border-bottom: 1px solid #21262d; color: #c9d1d9; vertical-align: middle; }
.admin-table tr:last-child td { border-bottom: none; }
.admin-table tr:hover td { background: #1c1f2e; }
.td-actions { display: flex; gap: 0.4rem; }
.btn-tbl { padding: 0.22rem 0.6rem; border-radius: 5px; font-size: 0.76rem; cursor: pointer; border: 1px solid; transition: all 0.12s; }
.btn-tbl-edit { border-color: #1f6feb55; color: #58a6ff; background: #1f6feb10; }
.btn-tbl-edit:hover { background: #1f6feb33; }
.btn-tbl-del { border-color: #f8514955; color: #f85149; background: #f8514910; }
.btn-tbl-del:hover { background: #f8514930; }
.badge-admin { padding: 1px 7px; border-radius: 8px; font-size: 0.72rem; font-weight: 700; background: #1f6feb22; color: #388bfd; }
.badge-mustchange { padding: 1px 7px; border-radius: 8px; font-size: 0.72rem; font-weight: 700; background: #e3b34122; color: #e3b341; }
/* Mini form in admin */
.admin-form { background: #0d1117; border: 1px solid #30363d; border-radius: 9px; padding: 1rem; margin-bottom: 1.25rem; }
.admin-form h4 { font-size: 0.8rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 0.75rem; }
.admin-form .row { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.admin-form .row > * { flex: 1 1 140px; }
.admin-form input, .admin-form select {
width: 100%; padding: 0.48rem 0.65rem;
background: #161b22; border: 1px solid #30363d; border-radius: 6px;
color: #e1e4e8; font-size: 0.85rem; outline: none; font-family: inherit;
transition: border-color 0.15s;
}
.admin-form input:focus, .admin-form select:focus { border-color: #58a6ff; }
.admin-form select option { background: #161b22; }
.admin-form label { display: block; font-size: 0.75rem; color: #8b949e; margin-bottom: 0.28rem; text-transform: uppercase; letter-spacing: 0.3px; }
.admin-form-actions { display: flex; gap: 0.6rem; margin-top: 0.75rem; justify-content: flex-end; }
.btn-admin-save { padding: 0.42rem 1rem; background: #1f6feb; border: none; border-radius: 6px; color: #fff; cursor: pointer; font-weight: 600; font-size: 0.84rem; }
.btn-admin-cancel { padding: 0.42rem 0.9rem; background: transparent; border: 1px solid #30363d; border-radius: 6px; color: #8b949e; cursor: pointer; font-size: 0.84rem; }
.admin-err { color: #f85149; font-size: 0.8rem; min-height: 1.1rem; margin-top: 0.35rem; }
/* ── APP BODY ── */
.app-body {
flex: 1;
@@ -404,9 +512,17 @@
</div>
<div class="topbar-spacer"></div>
<div class="topbar-user">
<div class="topbar-avatar" id="hdr-avatar">?</div>
<span class="topbar-username" id="hdr-user"></span>
<button class="btn-logout" onclick="doLogout()">Odhlásiť sa</button>
<div class="avatar-wrap" id="avatar-wrap">
<div class="topbar-avatar" id="hdr-avatar" onclick="toggleAvatarMenu()">?</div>
<div class="avatar-menu" id="avatar-menu">
<button class="avatar-menu-item" id="mi-users" onclick="openAdmin('users')" style="display:none"><span class="mi-icon">👥</span> Správca užívateľov</button>
<button class="avatar-menu-item" id="mi-families" onclick="openAdmin('families')" style="display:none"><span class="mi-icon">🏠</span> Správca rodín</button>
<button class="avatar-menu-item" id="mi-settings" onclick="openAdmin('settings')" style="display:none"><span class="mi-icon">⚙️</span> Nastavenia</button>
<hr class="avatar-menu-sep" id="mi-sep" style="display:none" />
<button class="avatar-menu-item" onclick="doLogout()"><span class="mi-icon">🚪</span> Odhlásiť sa</button>
</div>
</div>
</div>
</div>
@@ -502,6 +618,103 @@
</div>
</div>
<!-- ══════════ ADMIN PANEL ══════════ -->
<div class="admin-overlay" id="admin-overlay" onclick="onAdminOverlayClick(event)">
<div class="admin-panel">
<div class="admin-head">
<h2>⚙️ Administrácia</h2>
<button class="btn-admin-close" onclick="closeAdmin()"></button>
</div>
<div class="admin-tabs">
<button class="admin-tab" data-tab="users" onclick="switchAdminTab('users')">👥 Správca užívateľov</button>
<button class="admin-tab" data-tab="families" onclick="switchAdminTab('families')">🏠 Správca rodín</button>
<button class="admin-tab" data-tab="settings" onclick="switchAdminTab('settings')">⚙️ Nastavenia</button>
</div>
<div class="admin-body">
<!-- USERS TAB -->
<div class="admin-section" id="tab-users">
<div id="user-form-wrap" style="display:none">
<div class="admin-form">
<h4 id="user-form-title">Nový používateľ</h4>
<input type="hidden" id="uf-id" value="" />
<div class="row">
<div><label>Meno</label><input id="uf-username" placeholder="johndoe" /></div>
<div><label>Email</label><input id="uf-email" type="email" placeholder="john@example.com" /></div>
<div><label>Rodina</label>
<select id="uf-family"><option value="">— bez rodiny —</option></select>
</div>
</div>
<div class="admin-err" id="uf-err"></div>
<div class="admin-form-actions">
<button class="btn-admin-cancel" onclick="hideUserForm()">Zrušiť</button>
<button class="btn-admin-save" onclick="saveUser()">Uložiť</button>
</div>
</div>
</div>
<div class="admin-toolbar">
<h3>Používatelia</h3>
<button class="btn-admin-add" onclick="showUserForm()">+ Pridať</button>
</div>
<table class="admin-table">
<thead><tr><th>Meno</th><th>Email</th><th>Rodina</th><th>Roly</th><th></th></tr></thead>
<tbody id="admin-users-tbody"></tbody>
</table>
</div>
<!-- FAMILIES TAB -->
<div class="admin-section" id="tab-families">
<div id="family-form-wrap" style="display:none">
<div class="admin-form">
<h4 id="fam-form-title">Nová rodina</h4>
<input type="hidden" id="ff-id" value="" />
<div class="row" style="max-width:340px">
<div><label>Názov rodiny</label><input id="ff-name" placeholder="Moja rodina" /></div>
</div>
<div class="admin-err" id="ff-err"></div>
<div class="admin-form-actions">
<button class="btn-admin-cancel" onclick="hideFamilyForm()">Zrušiť</button>
<button class="btn-admin-save" onclick="saveFamily()">Uložiť</button>
</div>
</div>
</div>
<div class="admin-toolbar">
<h3>Rodiny</h3>
<button class="btn-admin-add" onclick="showFamilyForm()">+ Pridať</button>
</div>
<table class="admin-table">
<thead><tr><th>Názov</th><th>Počet členov</th><th></th></tr></thead>
<tbody id="admin-families-tbody"></tbody>
</table>
</div>
<!-- SETTINGS TAB -->
<div class="admin-section" id="tab-settings">
<p style="color:#8b949e;font-size:0.88rem;margin-top:0.5rem">Nastavenia budú dostupné čoskoro.</p>
</div>
</div>
</div>
</div>
<!-- MUST-CHANGE-PASSWORD MODAL -->
<div class="modal-overlay" id="mcp-modal">
<div class="modal" style="max-width:380px">
<div class="modal-head">
<h2>🔐 Zmeň si heslo</h2>
</div>
<p style="font-size:0.84rem;color:#8b949e;margin-bottom:1rem">
Tvoj účet bol vytvorený administrátorom.<br>Prosím zmeň si heslo pred pokračovaním.
</p>
<div class="fg"><label>Nové heslo</label><input type="password" id="mcp-pass" placeholder="min. 6 znakov" /></div>
<div class="fg"><label>Potvrď heslo</label><input type="password" id="mcp-pass2" placeholder="zopakuj heslo" onkeydown="if(event.key==='Enter')doMcpChange()" /></div>
<div class="err" id="mcp-err"></div>
<div class="modal-actions">
<button class="btn-save" onclick="doMcpChange()">Zmeniť heslo</button>
</div>
</div>
</div>
<script>
const API = '/api';
const v = id => document.getElementById(id).value;
@@ -621,10 +834,13 @@ function saveAuth(d) {
token = d.token;
}
let isAdmin = false;
function doLogout() {
localStorage.clear(); token = null; myId = 0;
localStorage.clear(); token = null; myId = 0; isAdmin = false;
document.getElementById('auth-screen').style.display = 'flex';
document.getElementById('app-screen').style.display = 'none';
document.getElementById('avatar-menu').classList.remove('open');
}
function showApp() {
@@ -632,8 +848,23 @@ function showApp() {
document.getElementById('app-screen').style.display = 'flex';
const u = JSON.parse(localStorage.getItem('user') || '{}');
const name = u.username || '';
isAdmin = !!u.isAdmin;
document.getElementById('hdr-user').textContent = name;
document.getElementById('hdr-avatar').textContent = name ? name[0].toUpperCase() : '?';
// Admin menu items
['mi-users','mi-families','mi-settings','mi-sep'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = isAdmin ? '' : 'none';
});
// MustChangePassword check
if (u.mustChangePassword) {
setTimeout(() => {
document.getElementById('mcp-modal').classList.add('open');
}, 400);
}
loadReminders();
}
@@ -1006,12 +1237,213 @@ function removeUser(id) { selectedUsers = selectedUsers.filter(u=>u.id!==id); re
document.addEventListener('click', e => {
if (!document.getElementById('user-picker').contains(e.target))
document.getElementById('picker-dropdown').classList.remove('open');
if (!document.getElementById('avatar-wrap').contains(e.target))
document.getElementById('avatar-menu').classList.remove('open');
});
window.addEventListener('resize', () => {
requestAnimationFrame(() => trimCalendarCells());
});
// ═══ AVATAR MENU ═══
function toggleAvatarMenu() {
document.getElementById('avatar-menu').classList.toggle('open');
}
// ═══ MUST-CHANGE-PASSWORD ═══
async function doMcpChange() {
const err = document.getElementById('mcp-err');
err.textContent = '';
const pass = document.getElementById('mcp-pass').value;
const pass2 = document.getElementById('mcp-pass2').value;
const u = JSON.parse(localStorage.getItem('user') || '{}');
if (pass.length < 6) { err.textContent = 'Heslo musí mať aspoň 6 znakov.'; return; }
if (pass !== pass2) { err.textContent = 'Heslá sa nezhodujú.'; return; }
const res = await fetch(`${API}/auth/reset-password`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ email: u.email, newPassword: pass, code: null })
});
if (res.ok) {
// Update stored user to clear mustChangePassword flag
u.mustChangePassword = false;
localStorage.setItem('user', JSON.stringify(u));
document.getElementById('mcp-modal').classList.remove('open');
} else {
err.textContent = await res.text();
}
}
// ═══ ADMIN PANEL ═══
let adminFamilies = [];
function openAdmin(tab) {
document.getElementById('avatar-menu').classList.remove('open');
document.getElementById('admin-overlay').classList.add('open');
switchAdminTab(tab || 'users');
}
function closeAdmin() {
document.getElementById('admin-overlay').classList.remove('open');
}
function onAdminOverlayClick(e) {
if (e.target === document.getElementById('admin-overlay')) closeAdmin();
}
function switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.admin-section').forEach(s => s.classList.toggle('active', s.id === `tab-${tab}`));
if (tab === 'users') loadAdminData();
if (tab === 'families') loadAdminFamilies();
}
async function loadAdminData() {
const [uRes, fRes] = await Promise.all([
fetch(`${API}/admin/users`, { headers: auth() }),
fetch(`${API}/admin/families`, { headers: auth() })
]);
const users = uRes.ok ? await uRes.json() : [];
adminFamilies = fRes.ok ? await fRes.json() : [];
renderAdminUsers(users);
populateFamilySelect('uf-family', null);
}
async function loadAdminFamilies() {
const res = await fetch(`${API}/admin/families`, { headers: auth() });
adminFamilies = res.ok ? await res.json() : [];
renderAdminFamilies(adminFamilies);
}
function renderAdminUsers(users) {
const tbody = document.getElementById('admin-users-tbody');
if (!users.length) { tbody.innerHTML = '<tr><td colspan="5" style="color:#484f58;text-align:center;padding:1.5rem">Žiadni používatelia</td></tr>'; return; }
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.username}</td>
<td style="color:#8b949e">${u.email}</td>
<td>${u.familyName || '<span style="color:#484f58">—</span>'}</td>
<td>
${u.isAdmin ? '<span class="badge-admin">Admin</span>' : ''}
${u.mustChangePassword ? '<span class="badge-mustchange">Zmeniť heslo</span>' : ''}
</td>
<td><div class="td-actions">
<button class="btn-tbl btn-tbl-edit" onclick="editUser(${u.id})">✏️ Upraviť</button>
${u.isAdmin ? '' : `<button class="btn-tbl btn-tbl-del" onclick="deleteUser(${u.id},'${u.username}')">🗑 Zmazať</button>`}
</div></td>
</tr>`).join('');
}
function renderAdminFamilies(families) {
const tbody = document.getElementById('admin-families-tbody');
if (!families.length) { tbody.innerHTML = '<tr><td colspan="3" style="color:#484f58;text-align:center;padding:1.5rem">Žiadne rodiny</td></tr>'; return; }
tbody.innerHTML = families.map(f => `
<tr>
<td>${f.name}</td>
<td style="color:#8b949e">${f.memberCount}</td>
<td><div class="td-actions">
<button class="btn-tbl btn-tbl-edit" onclick="editFamily(${f.id},'${f.name.replace(/'/g,"\\'")}')">✏️ Upraviť</button>
<button class="btn-tbl btn-tbl-del" onclick="deleteFamily(${f.id},'${f.name.replace(/'/g,"\\'")}')">🗑 Zmazať</button>
</div></td>
</tr>`).join('');
}
function populateFamilySelect(selectId, selectedId) {
const sel = document.getElementById(selectId);
sel.innerHTML = '<option value="">— bez rodiny —</option>' +
adminFamilies.map(f => `<option value="${f.id}" ${f.id == selectedId ? 'selected' : ''}>${f.name}</option>`).join('');
}
// USER FORM
function showUserForm(clearId) {
document.getElementById('uf-id').value = '';
document.getElementById('uf-username').value = '';
document.getElementById('uf-email').value = '';
document.getElementById('uf-err').textContent = '';
document.getElementById('user-form-title').textContent = 'Nový používateľ';
populateFamilySelect('uf-family', null);
document.getElementById('user-form-wrap').style.display = '';
document.getElementById('uf-username').focus();
}
function hideUserForm() { document.getElementById('user-form-wrap').style.display = 'none'; }
function editUser(id) {
// Find from table — reload users first
fetch(`${API}/admin/users`, { headers: auth() }).then(r => r.json()).then(users => {
const u = users.find(x => x.id === id); if (!u) return;
document.getElementById('uf-id').value = u.id;
document.getElementById('uf-username').value = u.username;
document.getElementById('uf-email').value = u.email;
document.getElementById('uf-err').textContent = '';
document.getElementById('user-form-title').textContent = 'Upraviť používateľa';
populateFamilySelect('uf-family', u.familyId);
document.getElementById('user-form-wrap').style.display = '';
document.getElementById('uf-username').focus();
});
}
async function saveUser() {
const id = document.getElementById('uf-id').value;
const body = {
username: document.getElementById('uf-username').value.trim(),
email: document.getElementById('uf-email').value.trim(),
familyId: document.getElementById('uf-family').value ? parseInt(document.getElementById('uf-family').value) : null
};
const err = document.getElementById('uf-err');
err.textContent = '';
if (!body.username || !body.email) { err.textContent = 'Meno a email sú povinné.'; return; }
const url = id ? `${API}/admin/users/${id}` : `${API}/admin/users`;
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { ...auth(), 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (res.ok) {
hideUserForm();
loadAdminData();
} else {
err.textContent = await res.text();
}
}
async function deleteUser(id, name) {
if (!confirm(`Zmazať používateľa „${name}"?`)) return;
const res = await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: auth() });
if (res.ok) loadAdminData();
else alert(await res.text());
}
// FAMILY FORM
function showFamilyForm() {
document.getElementById('ff-id').value = '';
document.getElementById('ff-name').value = '';
document.getElementById('ff-err').textContent = '';
document.getElementById('fam-form-title').textContent = 'Nová rodina';
document.getElementById('family-form-wrap').style.display = '';
document.getElementById('ff-name').focus();
}
function hideFamilyForm() { document.getElementById('family-form-wrap').style.display = 'none'; }
function editFamily(id, name) {
document.getElementById('ff-id').value = id;
document.getElementById('ff-name').value = name;
document.getElementById('ff-err').textContent = '';
document.getElementById('fam-form-title').textContent = 'Upraviť rodinu';
document.getElementById('family-form-wrap').style.display = '';
document.getElementById('ff-name').focus();
}
async function saveFamily() {
const id = document.getElementById('ff-id').value;
const name = document.getElementById('ff-name').value.trim();
const err = document.getElementById('ff-err');
err.textContent = '';
if (!name) { err.textContent = 'Názov je povinný.'; return; }
const url = id ? `${API}/admin/families/${id}` : `${API}/admin/families`;
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { ...auth(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
if (res.ok) {
hideFamilyForm();
loadAdminFamilies();
} else {
err.textContent = await res.text();
}
}
async function deleteFamily(id, name) {
if (!confirm(`Zmazať rodinu „${name}"?`)) return;
const res = await fetch(`${API}/admin/families/${id}`, { method: 'DELETE', headers: auth() });
if (res.ok) loadAdminFamilies();
else alert(await res.text());
}
</script>
</body>
</html>