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:
parent
b9bfecd1c2
commit
6927cb0b2d
7 changed files with 51 additions and 3 deletions
|
|
@ -4,6 +4,17 @@ import { useAuth } from '../../context/AuthContext.jsx';
|
||||||
|
|
||||||
const emptyForm = { username: '', password: '', is_super_admin: false, permissions: [], group_ids: [] };
|
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() {
|
export default function AdminUsers() {
|
||||||
const { user: currentUser, hasPermission } = useAuth();
|
const { user: currentUser, hasPermission } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
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">Benutzername</th>
|
||||||
<th className="px-4 py-2">Rolle</th>
|
<th className="px-4 py-2">Rolle</th>
|
||||||
<th className="px-4 py-2">Berechtigungsgruppen</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>
|
<th className="px-4 py-2 text-right">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -126,6 +138,9 @@ export default function AdminUsers() {
|
||||||
{u.groups.length === 0 && '–'}
|
{u.groups.length === 0 && '–'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<td className="px-4 py-2 text-right">
|
||||||
<button onClick={() => startEdit(u)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
|
<button onClick={() => startEdit(u)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
|
||||||
{u.id !== currentUser?.id && (
|
{u.id !== currentUser?.id && (
|
||||||
|
|
@ -135,7 +150,7 @@ export default function AdminUsers() {
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{users.length === 0 && (
|
{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
9
hifi/api/database/migration_last_active.sql
Normal file
9
hifi/api/database/migration_last_active.sql
Normal 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;
|
||||||
|
|
@ -9,6 +9,7 @@ CREATE TABLE admin_users (
|
||||||
two_factor_secret VARCHAR(64) NULL,
|
two_factor_secret VARCHAR(64) NULL,
|
||||||
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
two_factor_recovery_codes TEXT NULL,
|
two_factor_recovery_codes TEXT NULL,
|
||||||
|
last_active_at DATETIME NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ class AdminUserController
|
||||||
{
|
{
|
||||||
$db = Database::connection();
|
$db = Database::connection();
|
||||||
$users = $db->query(
|
$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();
|
)->fetchAll();
|
||||||
|
|
||||||
$permStmt = $db->prepare('SELECT permission FROM admin_user_permissions WHERE admin_user_id = ?');
|
$permStmt = $db->prepare('SELECT permission FROM admin_user_permissions WHERE admin_user_id = ?');
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,13 @@ class AuthController
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['admin_id'] = $id;
|
$_SESSION['admin_id'] = $id;
|
||||||
$_SESSION['admin_username'] = $username;
|
$_SESSION['admin_username'] = $username;
|
||||||
|
$_SESSION['last_active_touched_at'] = time();
|
||||||
unset($_SESSION['pending_2fa_user_id']);
|
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));
|
Http::send(self::userPayload($id, $username));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,23 @@ class AuthMiddleware
|
||||||
if (empty($_SESSION['admin_id'])) {
|
if (empty($_SESSION['admin_id'])) {
|
||||||
Http::error('Nicht angemeldet', 401);
|
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
|
public static function requirePermission(string $permission): int
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class Schema
|
||||||
two_factor_secret VARCHAR(64) NULL,
|
two_factor_secret VARCHAR(64) NULL,
|
||||||
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
two_factor_recovery_codes TEXT NULL,
|
two_factor_recovery_codes TEXT NULL,
|
||||||
|
last_active_at DATETIME NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB",
|
) ENGINE=InnoDB",
|
||||||
|
|
||||||
|
|
@ -270,6 +271,7 @@ class Schema
|
||||||
'two_factor_secret' => 'VARCHAR(64) NULL',
|
'two_factor_secret' => 'VARCHAR(64) NULL',
|
||||||
'two_factor_enabled' => 'TINYINT(1) NOT NULL DEFAULT 0',
|
'two_factor_enabled' => 'TINYINT(1) NOT NULL DEFAULT 0',
|
||||||
'two_factor_recovery_codes' => 'TEXT NULL',
|
'two_factor_recovery_codes' => 'TEXT NULL',
|
||||||
|
'last_active_at' => 'DATETIME NULL',
|
||||||
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||||
],
|
],
|
||||||
'app_settings' => [
|
'app_settings' => [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue