461 lines
15 KiB
PHP
461 lines
15 KiB
PHP
<?php
|
|
ini_set('display_errors', 1);
|
|
error_reporting(E_ALL);
|
|
|
|
require_once "config/session.php";
|
|
require_once "config/config.php";
|
|
|
|
if (!isset($_SESSION["loggedin"]) || $_SESSION["loggedin"] !== true) {
|
|
header("location: index.php");
|
|
exit;
|
|
}
|
|
if(($_SESSION["role"] ?? "user") !== "admin"){
|
|
header("location: dashboard.php"); exit;
|
|
}
|
|
|
|
// CSRF Token (für öffentliche Seite: MUSS)
|
|
if (empty($_SESSION['csrf'])) {
|
|
$_SESSION['csrf'] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
include('navbar.php');
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Users • ANJUMA Dashboard</title>
|
|
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.8/css/dataTables.bootstrap5.min.css" rel="stylesheet">
|
|
</head>
|
|
|
|
<body>
|
|
<div class="container my-4">
|
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
|
<div>
|
|
<h3 class="mb-0">Users</h3>
|
|
<div class="text-muted small">Benutzer verwalten (Admin-only)</div>
|
|
</div>
|
|
<button class="btn btn-primary" id="btnNewUser">
|
|
<i class="bi bi-person-plus"></i> Neuer User
|
|
</button>
|
|
</div>
|
|
|
|
<div class="card shadow-sm border-0">
|
|
<div class="card-body">
|
|
|
|
<!-- Mobile Cards -->
|
|
<div id="mobileCards" class="d-md-none"></div>
|
|
|
|
<!-- Desktop Table -->
|
|
<div class="table-responsive d-none d-md-block">
|
|
<table id="tblUsers" class="table table-hover align-middle w-100 mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Username</th>
|
|
<th>Role</th>
|
|
<th>First Login</th>
|
|
<th>Last Login</th>
|
|
<th style="width:260px;">Aktion</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast -->
|
|
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1080;">
|
|
<div id="appToast" class="toast align-items-center text-bg-dark border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
|
<div class="d-flex">
|
|
<div class="toast-body" id="toastBody">…</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- User Modal -->
|
|
<div class="modal fade" id="userModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<form class="modal-content" id="userForm">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="userModalTitle">User</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
|
|
<div class="modal-body">
|
|
<input type="hidden" id="u_id" value="">
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label mb-1">Name *</label>
|
|
<input class="form-control" id="u_name" required maxlength="32" placeholder="z.B. Justin">
|
|
</div>
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label mb-1">Username *</label>
|
|
<input class="form-control" id="u_username" required maxlength="32" placeholder="z.B. justin">
|
|
</div>
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label mb-1">Role</label>
|
|
<select class="form-select" id="u_role">
|
|
<option value="user">user</option>
|
|
<option value="admin">admin</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label mb-1">Passwort (optional)</label>
|
|
<input class="form-control" id="u_password" type="password" placeholder="leer lassen = First Login setzt User selbst">
|
|
<div class="text-muted small mt-1">
|
|
Wenn leer: <b>first_login=1</b> und User setzt sein Passwort beim ersten Login.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert alert-secondary small mb-0">
|
|
Tipp: Passwort resetten geht auch über den Button in der Liste.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
|
<button type="submit" class="btn btn-success" id="btnSaveUser">
|
|
<i class="bi bi-check2"></i> Speichern
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Confirm Delete Modal -->
|
|
<div class="modal fade" id="confirmDeleteModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header bg-danger text-white">
|
|
<h5 class="modal-title">User löschen?</h5>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div>Willst du diesen User wirklich löschen?</div>
|
|
<div class="text-muted small mt-2" id="delInfo"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
|
<button class="btn btn-danger" id="btnConfirmDelete"><i class="bi bi-trash"></i> Löschen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/datatables.net@1.13.8/js/jquery.dataTables.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.8/js/dataTables.bootstrap5.min.js"></script>
|
|
|
|
<script>
|
|
const API_LIST = '/api/users_list.php';
|
|
const API_SAVE = '/api/users_save.php';
|
|
const API_DEL = '/api/users_delete.php';
|
|
const API_RESET = '/api/users_reset_pw.php';
|
|
const API_LOGOUTALL = '/api/users_logout_all.php';
|
|
|
|
const CSRF = <?= json_encode($_SESSION['csrf']) ?>;
|
|
const meId = <?= (int)($_SESSION['id'] ?? 0) ?>;
|
|
|
|
const userModal = new bootstrap.Modal(document.getElementById('userModal'));
|
|
const delModal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
|
|
|
|
const toastEl = document.getElementById('appToast');
|
|
const toastBody = document.getElementById('toastBody');
|
|
const toast = new bootstrap.Toast(toastEl, { delay: 2600 });
|
|
|
|
function showToast(msg){
|
|
toastBody.textContent = msg;
|
|
toast.show();
|
|
}
|
|
|
|
let dt = null;
|
|
let deleteId = null;
|
|
let deleteLabel = '';
|
|
let cacheUsers = []; // ✅ kein reload bei edit click
|
|
|
|
function fmtTs(ts){
|
|
ts = parseInt(ts || 0, 10);
|
|
if (!ts) return '—';
|
|
const d = new Date(ts * 1000);
|
|
const pad = n => String(n).padStart(2,'0');
|
|
return `${pad(d.getDate())}.${pad(d.getMonth()+1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
function roleBadge(role){
|
|
if (role === 'admin') return '<span class="badge bg-dark">admin</span>';
|
|
return '<span class="badge bg-secondary">user</span>';
|
|
}
|
|
|
|
function yesNoBadge(v){
|
|
return String(v) === '1'
|
|
? '<span class="badge bg-warning text-dark">yes</span>'
|
|
: '<span class="badge bg-success">no</span>';
|
|
}
|
|
|
|
function actionsHtml(u){
|
|
const disableSelf = (parseInt(u.id,10) === meId);
|
|
|
|
return `
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
<button class="btn btn-outline-primary actEdit" data-id="${u.id}" title="Bearbeiten">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
|
|
<button class="btn btn-outline-warning actReset" data-id="${u.id}" ${disableSelf ? 'disabled title="Nicht für dich selbst"' : 'title="Passwort resetten"'}>
|
|
<i class="bi bi-key"></i>
|
|
</button>
|
|
|
|
<button class="btn btn-outline-secondary actLogoutAll" data-id="${u.id}" ${disableSelf ? 'disabled title="Nicht für dich selbst"' : 'title="Alle Geräte abmelden"'}>
|
|
<i class="bi bi-box-arrow-right"></i>
|
|
</button>
|
|
|
|
<button class="btn btn-outline-danger actDel" data-id="${u.id}" data-label="${u.name} (${u.username})"
|
|
${disableSelf ? 'disabled title="Du kannst dich nicht selbst löschen"' : 'title="Löschen"'}>
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function apiPost(url, formData){
|
|
formData.append('csrf', CSRF);
|
|
const res = await fetch(url, { method:'POST', body: formData });
|
|
const json = await res.json().catch(() => ({}));
|
|
if (!json || json.ok !== true) throw new Error(json?.error || 'Request failed');
|
|
return json;
|
|
}
|
|
|
|
async function loadUsers(){
|
|
const res = await fetch(API_LIST);
|
|
const json = await res.json();
|
|
if (!json.ok) throw new Error(json.error || 'Load failed');
|
|
return json.items || [];
|
|
}
|
|
|
|
function renderMobileCards(items){
|
|
const wrap = document.getElementById('mobileCards');
|
|
if (!wrap) return;
|
|
|
|
if (!items.length){
|
|
wrap.innerHTML = `<div class="text-muted">Keine User gefunden.</div>`;
|
|
return;
|
|
}
|
|
|
|
wrap.innerHTML = items.map(u => `
|
|
<div class="card mb-2">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<div class="fw-bold">${u.name}</div>
|
|
<div class="text-muted small">@${u.username}</div>
|
|
</div>
|
|
<div>${roleBadge(u.role)}</div>
|
|
</div>
|
|
|
|
<div class="mt-2 small">
|
|
<div>First Login: ${yesNoBadge(u.first_login)}</div>
|
|
<div>Last Login: <span class="text-muted">${fmtTs(u.last_login)}</span></div>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
${actionsHtml(u)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function initDataTable(items){
|
|
if (dt) {
|
|
dt.clear().rows.add(items).draw();
|
|
return;
|
|
}
|
|
|
|
dt = $('#tblUsers').DataTable({
|
|
data: items,
|
|
pageLength: 10,
|
|
responsive: false,
|
|
dom: '<"row align-items-center mb-2"<"col-md-6"l><"col-md-6 text-md-end"f>>rt<"row align-items-center mt-2"<"col-md-6"i><"col-md-6 text-md-end"p>>',
|
|
columns: [
|
|
{ data: 'name' },
|
|
{ data: 'username', render: (d) => '@' + d },
|
|
{ data: 'role', render: (d) => roleBadge(d) },
|
|
{ data: 'first_login', render: (d) => yesNoBadge(d) },
|
|
{ data: 'last_login', render: (d) => `<span class="text-muted">${fmtTs(d)}</span>` },
|
|
{ data: null, orderable: false, render: (row) => actionsHtml(row) },
|
|
],
|
|
language: {
|
|
search: "Suche:",
|
|
lengthMenu: "_MENU_ pro Seite",
|
|
info: "_START_ bis _END_ von _TOTAL_",
|
|
paginate: { previous: "Zurück", next: "Weiter" },
|
|
emptyTable: "Keine Daten"
|
|
}
|
|
});
|
|
}
|
|
|
|
async function refresh(){
|
|
cacheUsers = await loadUsers(); // ✅ einmal holen, dann lokal nutzen
|
|
renderMobileCards(cacheUsers);
|
|
initDataTable(cacheUsers);
|
|
}
|
|
|
|
function openNew(){
|
|
document.getElementById('userModalTitle').textContent = 'Neuer User';
|
|
document.getElementById('u_id').value = '';
|
|
document.getElementById('u_name').value = '';
|
|
document.getElementById('u_username').value = '';
|
|
document.getElementById('u_role').value = 'user';
|
|
document.getElementById('u_password').value = '';
|
|
userModal.show();
|
|
}
|
|
|
|
function openEdit(u){
|
|
document.getElementById('userModalTitle').textContent = 'User bearbeiten';
|
|
document.getElementById('u_id').value = u.id;
|
|
document.getElementById('u_name').value = u.name;
|
|
document.getElementById('u_username').value = u.username;
|
|
document.getElementById('u_role').value = u.role;
|
|
document.getElementById('u_password').value = '';
|
|
userModal.show();
|
|
}
|
|
|
|
document.getElementById('btnNewUser').addEventListener('click', openNew);
|
|
|
|
document.getElementById('userForm').addEventListener('submit', async (ev) => {
|
|
ev.preventDefault();
|
|
|
|
const id = document.getElementById('u_id').value.trim();
|
|
const name = document.getElementById('u_name').value.trim();
|
|
const username = document.getElementById('u_username').value.trim();
|
|
const role = document.getElementById('u_role').value;
|
|
const password = document.getElementById('u_password').value;
|
|
|
|
const fd = new FormData();
|
|
if (id) fd.append('id', id);
|
|
fd.append('name', name);
|
|
fd.append('username', username);
|
|
fd.append('role', role);
|
|
if (password) fd.append('password', password);
|
|
|
|
const btn = document.getElementById('btnSaveUser');
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
await apiPost(API_SAVE, fd);
|
|
userModal.hide();
|
|
showToast('User gespeichert ✅');
|
|
await refresh();
|
|
} catch (e) {
|
|
showToast('Fehler: ' + e.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', async (ev) => {
|
|
const editBtn = ev.target.closest('.actEdit');
|
|
if (editBtn){
|
|
const id = editBtn.dataset.id;
|
|
const u = cacheUsers.find(x => String(x.id) === String(id));
|
|
if (u) openEdit(u);
|
|
return;
|
|
}
|
|
|
|
const delBtn = ev.target.closest('.actDel');
|
|
if (delBtn){
|
|
deleteId = delBtn.dataset.id;
|
|
deleteLabel = delBtn.dataset.label || '';
|
|
document.getElementById('delInfo').textContent = deleteLabel;
|
|
delModal.show();
|
|
return;
|
|
}
|
|
|
|
const resetBtn = ev.target.closest('.actReset');
|
|
if (resetBtn){
|
|
const id = resetBtn.dataset.id;
|
|
if (!confirm('Passwort zurücksetzen?\nUser muss beim nächsten Login ein neues Passwort setzen.\n(Optional: Wir loggen ihn auch auf allen Geräten aus)')) return;
|
|
|
|
const fd = new FormData();
|
|
fd.append('id', id);
|
|
|
|
try {
|
|
await apiPost(API_RESET, fd);
|
|
showToast('Passwort-Reset gesetzt 🔑');
|
|
await refresh();
|
|
} catch (e) {
|
|
showToast('Fehler: ' + e.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const loBtn = ev.target.closest('.actLogoutAll');
|
|
if (loBtn){
|
|
const id = loBtn.dataset.id;
|
|
if (!confirm('Alle Geräte abmelden?\n(Das löscht alle Remember-Me Tokens dieses Users)')) return;
|
|
|
|
const fd = new FormData();
|
|
fd.append('id', id);
|
|
|
|
try {
|
|
await apiPost(API_LOGOUTALL, fd);
|
|
showToast('Alle Geräte abgemeldet ✅');
|
|
await refresh();
|
|
} catch (e) {
|
|
showToast('Fehler: ' + e.message);
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
document.getElementById('btnConfirmDelete').addEventListener('click', async () => {
|
|
if (!deleteId) return;
|
|
|
|
const fd = new FormData();
|
|
fd.append('id', deleteId);
|
|
|
|
try {
|
|
await apiPost(API_DEL, fd);
|
|
delModal.hide();
|
|
deleteId = null;
|
|
showToast('User gelöscht 🗑️');
|
|
await refresh();
|
|
} catch (e) {
|
|
showToast('Fehler: ' + e.message);
|
|
}
|
|
});
|
|
|
|
refresh().catch(e => showToast('Fehler: ' + e.message));
|
|
</script>
|
|
|
|
<style>
|
|
div.dataTables_wrapper .dataTables_length select,
|
|
div.dataTables_wrapper .dataTables_filter input {
|
|
border-radius: .5rem;
|
|
border: 1px solid #dee2e6;
|
|
padding: .375rem .5rem;
|
|
}
|
|
div.dataTables_wrapper .dataTables_filter input { margin-left: .5rem; }
|
|
div.dataTables_wrapper .dataTables_length label,
|
|
div.dataTables_wrapper .dataTables_filter label {
|
|
font-size: .9rem;
|
|
color: #6c757d;
|
|
}
|
|
table.dataTable thead th { border-bottom: 1px solid #e9ecef !important; }
|
|
</style>
|
|
</body>
|
|
</html>
|