Track when each admin user was last active

Adds admin_users.last_active_at, touched on login and throttled to
once per minute via AuthMiddleware::requireAdmin, and shows it in the
Benutzer admin page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-08-29 22:14:06 +02:00
parent b9bfecd1c2
commit 6927cb0b2d
7 changed files with 51 additions and 3 deletions

View file

@ -4,6 +4,17 @@ import { useAuth } from '../../context/AuthContext.jsx';
const emptyForm = { username: '', password: '', is_super_admin: false, permissions: [], group_ids: [] };
function formatLastActive(value) {
if (!value) return 'Noch nie';
return new Date(value.replace(' ', 'T')).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export default function AdminUsers() {
const { user: currentUser, hasPermission } = useAuth();
const [users, setUsers] = useState([]);
@ -97,6 +108,7 @@ export default function AdminUsers() {
<th className="px-4 py-2">Benutzername</th>
<th className="px-4 py-2">Rolle</th>
<th className="px-4 py-2">Berechtigungsgruppen</th>
<th className="px-4 py-2">Zuletzt aktiv</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
@ -126,6 +138,9 @@ export default function AdminUsers() {
{u.groups.length === 0 && ''}
</div>
</td>
<td className="px-4 py-2 text-neutral-500 dark:text-neutral-400">
{formatLastActive(u.last_active_at)}
</td>
<td className="px-4 py-2 text-right">
<button onClick={() => startEdit(u)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
{u.id !== currentUser?.id && (
@ -135,7 +150,7 @@ export default function AdminUsers() {
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-neutral-400">Noch keine Benutzer angelegt.</td></tr>
<tr><td colSpan={5} className="px-4 py-6 text-center text-neutral-400">Noch keine Benutzer angelegt.</td></tr>
)}
</tbody>
</table>

View file

@ -0,0 +1,9 @@
USE hifi_shop;
-- Zeitpunkt der letzten authentifizierten Admin-Aktion, fuer die Anzeige
-- "zuletzt aktiv" in der Benutzerverwaltung.
-- Hinweis: Auf der Live-Seite reicht stattdessen ein Klick auf "Datenbankstruktur aktualisieren"
-- unter Admin-Panel -> Einstellungen -> Datenbank.
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS last_active_at DATETIME NULL AFTER two_factor_recovery_codes;

View file

@ -9,6 +9,7 @@ CREATE TABLE admin_users (
two_factor_secret VARCHAR(64) NULL,
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
two_factor_recovery_codes TEXT NULL,
last_active_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

View file

@ -13,7 +13,7 @@ class AdminUserController
{
$db = Database::connection();
$users = $db->query(
'SELECT id, username, is_super_admin, created_at FROM admin_users ORDER BY username'
'SELECT id, username, is_super_admin, last_active_at, created_at FROM admin_users ORDER BY username'
)->fetchAll();
$permStmt = $db->prepare('SELECT permission FROM admin_user_permissions WHERE admin_user_id = ?');

View file

@ -169,8 +169,13 @@ class AuthController
session_regenerate_id(true);
$_SESSION['admin_id'] = $id;
$_SESSION['admin_username'] = $username;
$_SESSION['last_active_touched_at'] = time();
unset($_SESSION['pending_2fa_user_id']);
Database::connection()
->prepare('UPDATE admin_users SET last_active_at = NOW() WHERE id = ?')
->execute([$id]);
Http::send(self::userPayload($id, $username));
}

View file

@ -12,7 +12,23 @@ class AuthMiddleware
if (empty($_SESSION['admin_id'])) {
Http::error('Nicht angemeldet', 401);
}
return (int) $_SESSION['admin_id'];
$userId = (int) $_SESSION['admin_id'];
self::touchLastActive($userId);
return $userId;
}
// Auf einmal pro Minute gedrosselt (Zeitstempel in der Session), damit nicht
// bei jedem einzelnen Admin-Request ein UPDATE laeuft.
private static function touchLastActive(int $userId): void
{
$now = time();
if (($_SESSION['last_active_touched_at'] ?? 0) > $now - 60) {
return;
}
$_SESSION['last_active_touched_at'] = $now;
Database::connection()
->prepare('UPDATE admin_users SET last_active_at = NOW() WHERE id = ?')
->execute([$userId]);
}
public static function requirePermission(string $permission): int

View file

@ -20,6 +20,7 @@ class Schema
two_factor_secret VARCHAR(64) NULL,
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
two_factor_recovery_codes TEXT NULL,
last_active_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB",
@ -270,6 +271,7 @@ class Schema
'two_factor_secret' => 'VARCHAR(64) NULL',
'two_factor_enabled' => 'TINYINT(1) NOT NULL DEFAULT 0',
'two_factor_recovery_codes' => 'TEXT NULL',
'last_active_at' => 'DATETIME NULL',
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
],
'app_settings' => [