v2.6.1c (Lyra): Admin gebruikersbeheer opnieuw ontworpen + CLI reset

Nieuwe features:
- Admin gebruikerslijst met zoeken/filter op gebruikersnaam en rol
- Profiel bewerken pagina met wachtwoord wijzigen en rol wijzigen
- Nieuwe gebruiker aanmaken via aparte pagina
- CLI commando cli/reset-admin-password.php voor admin wachtword reset + lockout reset

Architectuur:
- /admin/users: lijst met zoekveld, rol filter, bewerk/verwijder acties
- /admin/users-edit?user=<naam>: profiel, wachtwoord, rol, verwijderen
- /admin/users-new: nieuwe gebruiker aanmaken
- AdminAuth::clearLockout() public methode voor CLI gebruik

Documentatie:
- guide/nl/en/admin-beheerder/gebruikers.md herschreven
- 15 nieuwe admin vertaalkeys in NL/EN/DE
- Release notes: docs/release-notes/v2.6.1c.md

Tests:
- Pentest: 30/30 geslaagd, 0 vulnerabilities
- WCAG 2.1 AA: 25/25 geslaagd, 100% compliance
This commit is contained in:
root
2026-08-17 15:10:40 +00:00
parent 2d9ffaa942
commit 10c72de859
14 changed files with 767 additions and 220 deletions
+9
View File
@@ -49,6 +49,15 @@
- [x] Pentest controles uitgevoerd (30/30 tests geslaagd — 0 vulnerabilities) - [x] Pentest controles uitgevoerd (30/30 tests geslaagd — 0 vulnerabilities)
- [x] WCAG 2.1 AA accessibility tests (25/25 tests geslaagd — 100% compliance, test-script verbeterd met min/max checks en grep -E) - [x] WCAG 2.1 AA accessibility tests (25/25 tests geslaagd — 100% compliance, test-script verbeterd met min/max checks en grep -E)
## v2.6.1c (2026-08-17) ✅
- [x] Pentest controles uitgevoerd (30/30)
- [x] WCAG 2.1 AA accessibility tests (25/25)
- [x] README.md en handleidingen in guide/ doorgeloken en bijgewerkt
- [x] Admin gebruikersbeheer opnieuw ontworpen (lijst + zoeken/filter + profiel + wachtwoord)
- [x] CLI commando voor admin wachtwoord reset (cli/reset-admin-password.php)
- [x] Verslag gemaakt (docs/release-notes/v2.6.1c.md)
- [x] Versie verhoogd naar 2.6.1c
## v2.6.1 (2026-08-17) ✅ ## v2.6.1 (2026-08-17) ✅
- [x] Pentest controles uitgevoerd (30/30) - [x] Pentest controles uitgevoerd (30/30)
- [x] WCAG 2.1 AA accessibility tests (25/25) - [x] WCAG 2.1 AA accessibility tests (25/25)
+8
View File
@@ -432,6 +432,14 @@ class AdminAuth
file_put_contents($this->lockFile, json_encode($attempts)); file_put_contents($this->lockFile, json_encode($attempts));
} }
/**
* Clear all failed login attempts for a user (public, used by CLI reset).
*/
public function clearLockout(string $username): void
{
$this->clearFailedAttempts($username);
}
private function getFailedAttempts(): array private function getFailedAttempts(): array
{ {
if (!file_exists($this->lockFile)) { if (!file_exists($this->lockFile)) {
@@ -0,0 +1,136 @@
{% extends "layouts/admin.twig" %}
{% block title %}{{ ta.edit_profile|default('Profiel bewerken') }}: {{ target_user.username }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-person-gear"></i> {{ ta.edit_profile|default('Profiel bewerken') }}</h2>
<a href="/admin/users" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> {{ ta.back_to_users|default('Terug naar gebruikers') }}
</a>
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="card shadow-sm mb-4">
<div class="card-header">
<i class="bi bi-person-circle"></i> {{ ta.profile_info|default('Profiel gegevens') }}
{% if is_self %}
<span class="badge bg-info ms-2">{{ ta.you|default('Jij') }}</span>
{% endif %}
</div>
<div class="card-body">
<form method="POST" action="/admin/users-edit?user={{ target_user.username }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="profile">
<div class="mb-3">
<label class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label>
<input type="text" class="form-control" value="{{ target_user.username }}" disabled>
</div>
<div class="mb-3">
<label for="profile_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label>
<input type="email" class="form-control" id="profile_email" name="profile_email" value="{{ target_user.email|default('') }}">
</div>
<div class="mb-3">
<label for="profile_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label>
<input type="text" class="form-control" id="profile_author_name" name="profile_author_name" value="{{ target_user.author_name|default('') }}">
<small class="form-text text-muted">{{ ta.author_name_help|default('Getoond als auteur op de website.') }}</small>
</div>
<div class="mb-3">
<label for="profile_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label>
<input type="email" class="form-control" id="profile_author_email" name="profile_author_email" value="{{ target_user.author_email|default('') }}">
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
</form>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header">
<i class="bi bi-key"></i> {{ ta.change_password|default('Wachtwoord wijzigen') }}
</div>
<div class="card-body">
<form method="POST" action="/admin/users-edit?user={{ target_user.username }}" onsubmit="return validatePassword();">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="change_password">
<div class="mb-3">
<label for="new_password" class="form-label">{{ ta.new_password|default('Nieuw wachtwoord') }}</label>
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
<small class="form-text text-muted">{{ ta.password_help|default('Minimaal 8 tekens.') }}</small>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">{{ ta.confirm_password|default('Bevestig wachtwoord') }}</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="8">
</div>
<button type="submit" class="btn btn-warning">
<i class="bi bi-key"></i> {{ ta.change_password|default('Wachtwoord wijzigen') }}
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm mb-4">
<div class="card-header">
<i class="bi bi-shield-lock"></i> {{ ta.col_role|default('Rol') }}
</div>
<div class="card-body">
<p class="mb-2"><strong>{{ ta.current_role|default('Huidige rol') }}</strong></p>
<p>
<span class="badge bg-{{ target_user.role == 'admin' ? 'danger' : (target_user.role == 'content-manager' ? 'primary' : (target_user.role == 'bi-manager' ? 'success' : 'warning')) }}">
{{ target_user.role_label|default(target_user.role) }}
</span>
</p>
<hr>
<form method="POST" action="/admin/users-edit?user={{ target_user.username }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="change_role">
<div class="mb-3">
<label for="new_role" class="form-label">{{ ta.new_role|default('Nieuwe rol') }}</label>
<select class="form-select" id="new_role" name="new_role">
{% for roleKey, roleLabel in roles %}
<option value="{{ roleKey }}" {{ target_user.role == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-check-lg"></i> {{ ta.change|default('Wijzigen') }}
</button>
</form>
</div>
</div>
{% if not is_self %}
<div class="card shadow-sm border-danger">
<div class="card-header bg-danger text-white">
<i class="bi bi-exclamation-triangle"></i> {{ ta.danger_zone|default('Gevarenzone') }}
</div>
<div class="card-body">
<form method="POST" action="/admin/users-edit?user={{ target_user.username }}" onsubmit="return confirm('{{ ta.confirm_delete_user|default('Weet je zeker dat je deze gebruiker wilt verwijderen?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-danger w-100">
<i class="bi bi-trash"></i> {{ ta.delete_user|default('Gebruiker verwijderen') }}
</button>
</form>
</div>
</div>
{% endif %}
</div>
</div>
<script>
function validatePassword() {
var p1 = document.getElementById('new_password').value;
var p2 = document.getElementById('confirm_password').value;
if (p1 !== p2) {
alert('{{ ta.passwords_no_match|default('Wachtwoorden komen niet overeen.') }}');
return false;
}
return true;
}
</script>
{% endblock %}
@@ -0,0 +1,65 @@
{% extends "layouts/admin.twig" %}
{% block title %}{{ ta.new_user|default('Nieuwe gebruiker') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-plus-circle"></i> {{ ta.new_user|default('Nieuwe gebruiker') }}</h2>
<a href="/admin/users" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> {{ ta.back_to_users|default('Terug naar gebruikers') }}
</a>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header">
<i class="bi bi-person-plus"></i> {{ ta.add_user|default('Gebruiker toevoegen') }}
</div>
<div class="card-body">
<form method="POST" action="/admin/users-new">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="add">
<div class="mb-3">
<label for="new_username" class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label>
<input type="text" class="form-control" id="new_username" name="new_username" required autofocus>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">{{ ta.password|default('Wachtwoord') }}</label>
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
<small class="form-text text-muted">{{ ta.password_help|default('Minimaal 8 tekens.') }}</small>
</div>
<div class="mb-3">
<label for="new_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label>
<input type="email" class="form-control" id="new_email" name="new_email">
</div>
<div class="mb-3">
<label for="new_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label>
<input type="text" class="form-control" id="new_author_name" name="new_author_name">
</div>
<div class="mb-3">
<label for="new_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label>
<input type="email" class="form-control" id="new_author_email" name="new_author_email">
</div>
<div class="mb-3">
<label for="new_role" class="form-label">{{ ta.col_role|default('Rol') }}</label>
<select class="form-select" id="new_role" name="new_role">
{% for roleKey, roleLabel in roles %}
<option value="{{ roleKey }}" {{ roleKey == 'content-manager' ? 'selected' : '' }}>{{ roleLabel }}</option>
{% endfor %}
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.add_user|default('Gebruiker toevoegen') }}
</button>
<a href="/admin/users" class="btn btn-outline-secondary">
{{ ta.cancel|default('Annuleren') }}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+78 -171
View File
@@ -5,182 +5,89 @@
{% block content %} {% block content %}
<h2 class="mb-4"><i class="bi bi-people"></i> {{ ta.users|default('Gebruikers') }}</h2> <h2 class="mb-4"><i class="bi bi-people"></i> {{ ta.users|default('Gebruikers') }}</h2>
<div class="row g-4"> <div class="card shadow-sm">
<div class="col-md-7"> <div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="card shadow-sm"> <div class="d-flex align-items-center gap-2">
<div class="card-header"> <i class="bi bi-list"></i> {{ ta.users_list|default('Gebruikerslijst') }}
<i class="bi bi-list"></i> {{ ta.users|default('Gebruikers') }}
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>{{ ta.username|default('Gebruikersnaam') }}</th>
<th>{{ ta.col_role|default('Rol') }}</th>
<th>{{ ta.col_created|default('Aangemaakt') }}</th>
<th>{{ ta.col_actions|default('Acties') }}</th>
</tr>
</thead>
<tbody>
{% for username, data in users %}
<tr>
<td>
<i class="bi bi-person-circle"></i>
{{ username }}
{% if username == user.username %}
<span class="badge bg-info">{{ ta.you|default('Jij') }}</span>
{% endif %}
</td>
<td>
<span class="badge bg-{{ data.role == 'admin' ? 'danger' : (data.role == 'content-manager' ? 'primary' : (data.role == 'bi-manager' ? 'success' : 'warning')) }}">
{{ data.role_label|default(data.role) }}
</span>
</td>
<td class="text-muted">{{ data.created|default(ta.unknown|default('Onbekend')) }}</td>
<td>
{% if username != user.username %}
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#roleModal-{{ username }}">
<i class="bi bi-person-gear"></i> {{ ta.col_role|default('Rol') }}
</button>
<form method="POST" action="/admin/users" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_user|default('Weet je zeker dat je deze gebruiker wilt verwijderen?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="delete_username" value="{{ username }}">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
<!-- Role change modal -->
<div class="modal fade" id="roleModal-{{ username }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/admin/users">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="change_role">
<input type="hidden" name="role_username" value="{{ username }}">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-person-gear"></i> {{ ta.change_role|default('Rol wijzigen: ') }}{{ username }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">{{ ta.current_role|default('Huidige rol') }}</label>
<p><span class="badge bg-secondary">{{ data.role_label|default(data.role) }}</span></p>
</div>
<div class="mb-3">
<label for="new_role-{{ username }}" class="form-label">{{ ta.new_role|default('Nieuwe rol') }}</label>
<select class="form-select" id="new_role-{{ username }}" name="new_role">
{% for roleKey, roleLabel in roles %}
<option value="{{ roleKey }}" {{ data.role == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ ta.cancel|default('Annuleren') }}</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ ta.change|default('Wijzigen') }}</button>
</div>
</form>
</div>
</div>
</div>
{% else %}
<button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#profileModal">
<i class="bi bi-pencil"></i> {{ ta.edit_profile|default('Profiel bewerken') }}
</button>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-muted text-center py-4">{{ ta.no_users|default('Geen gebruikers gevonden.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
<a href="/admin/users-new" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> {{ ta.new_user|default('Nieuwe gebruiker') }}
</a>
</div> </div>
<div class="card-body">
<div class="col-md-5"> <form method="GET" action="/admin/users" class="row g-2 mb-3">
<!-- Profile edit card for current user --> <div class="col-md-6">
<div class="card shadow-sm mb-4"> <div class="input-group">
<div class="card-header"> <span class="input-group-text"><i class="bi bi-search"></i></span>
<i class="bi bi-person-circle"></i> {{ ta.my_profile|default('Mijn profiel') }} <input type="text" class="form-control" name="search" value="{{ search|default('') }}" placeholder="{{ ta.search_users|default('Zoek op gebruikersnaam, e-mail of auteur naam...') }}">
</div>
</div> </div>
<div class="card-body"> <div class="col-md-4">
<form method="POST" action="/admin/users"> <select class="form-select" name="role">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <option value="">{{ ta.all_roles|default('Alle rollen') }}</option>
<input type="hidden" name="action" value="profile"> {% for roleKey, roleLabel in roles %}
<input type="hidden" name="profile_username" value="{{ user.username }}"> <option value="{{ roleKey }}" {{ role_filter == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
<div class="mb-3"> {% endfor %}
<label class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label> </select>
<input type="text" class="form-control" value="{{ user.username }}" disabled>
</div>
<div class="mb-3">
<label for="profile_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label>
<input type="email" class="form-control" id="profile_email" name="profile_email" value="{{ user.email|default('') }}">
</div>
<div class="mb-3">
<label for="profile_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label>
<input type="text" class="form-control" id="profile_author_name" name="profile_author_name" value="{{ user.author_name|default('') }}">
<small class="form-text text-muted">{{ ta.author_name_help|default('Getoond als auteur op de website.') }}</small>
</div>
<div class="mb-3">
<label for="profile_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label>
<input type="email" class="form-control" id="profile_author_email" name="profile_author_email" value="{{ user.author_email|default('') }}">
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
</form>
</div> </div>
</div> <div class="col-md-2">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-funnel"></i> {{ ta.filter|default('Filteren') }}
</button>
</div>
</form>
<!-- New user card --> <div class="table-responsive">
<div class="card shadow-sm"> <table class="table table-hover mb-0">
<div class="card-header"> <thead>
<i class="bi bi-plus-circle"></i> {{ ta.new_user|default('Nieuwe gebruiker') }} <tr>
</div> <th>{{ ta.username|default('Gebruikersnaam') }}</th>
<div class="card-body"> <th>{{ ta.col_role|default('Rol') }}</th>
<form method="POST" action="/admin/users"> <th>{{ ta.login_email|default('Login e-mail') }}</th>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <th>{{ ta.col_created|default('Aangemaakt') }}</th>
<input type="hidden" name="action" value="add"> <th>{{ ta.col_actions|default('Acties') }}</th>
<div class="mb-3"> </tr>
<label for="new_username" class="form-label">{{ ta.username|default('Gebruikersnaam') }}</label> </thead>
<input type="text" class="form-control" id="new_username" name="new_username" required autofocus> <tbody>
</div> {% for username, data in users %}
<div class="mb-3"> <tr>
<label for="new_password" class="form-label">{{ ta.password|default('Wachtwoord') }}</label> <td>
<input type="password" class="form-control" id="new_password" name="new_password" required> <i class="bi bi-person-circle"></i>
<small class="form-text text-muted">{{ ta.password_help|default('Minimaal 8 tekens.') }}</small> <a href="/admin/users-edit?user={{ username }}">{{ username }}</a>
</div> {% if username == user.username %}
<div class="mb-3"> <span class="badge bg-info">{{ ta.you|default('Jij') }}</span>
<label for="new_email" class="form-label">{{ ta.login_email|default('Login e-mail') }}</label> {% endif %}
<input type="email" class="form-control" id="new_email" name="new_email"> </td>
</div> <td>
<div class="mb-3"> <span class="badge bg-{{ data.role == 'admin' ? 'danger' : (data.role == 'content-manager' ? 'primary' : (data.role == 'bi-manager' ? 'success' : 'warning')) }}">
<label for="new_author_name" class="form-label">{{ ta.author_name|default('Auteur naam') }}</label> {{ data.role_label|default(data.role) }}
<input type="text" class="form-control" id="new_author_name" name="new_author_name"> </span>
</div> </td>
<div class="mb-3"> <td class="text-muted">{{ data.email|default('-') }}</td>
<label for="new_author_email" class="form-label">{{ ta.author_email|default('Auteur e-mail') }}</label> <td class="text-muted">{{ data.created|default(ta.unknown|default('Onbekend')) }}</td>
<input type="email" class="form-control" id="new_author_email" name="new_author_email"> <td>
</div> <a href="/admin/users-edit?user={{ username }}" class="btn btn-sm btn-outline-primary" title="{{ ta.edit_profile|default('Profiel bewerken') }}">
<div class="mb-3"> <i class="bi bi-pencil"></i>
<label for="new_role" class="form-label">{{ ta.col_role|default('Rol') }}</label> </a>
<select class="form-select" id="new_role" name="new_role"> {% if username != user.username %}
{% for roleKey, roleLabel in roles %} <form method="POST" action="/admin/users" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_user|default('Weet je zeker dat je deze gebruiker wilt verwijderen?') }}')">
<option value="{{ roleKey }}" {{ roleKey == 'content-manager' ? 'selected' : '' }}>{{ roleLabel }}</option> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% endfor %} <input type="hidden" name="action" value="delete">
</select> <input type="hidden" name="delete_username" value="{{ username }}">
</div> <button type="submit" class="btn btn-sm btn-outline-danger" title="{{ ta.delete|default('Verwijderen') }}">
<button type="submit" class="btn btn-primary w-100"> <i class="bi bi-trash"></i>
<i class="bi bi-check-lg"></i> {{ ta.add_user|default('Gebruiker toevoegen') }} </button>
</button> </form>
</form> {% endif %}
</div> </td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-muted text-center py-4">{{ ta.no_users|default('Geen gebruikers gevonden.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env php
<?php
/**
* CodePress CMS — Admin wachtwoord reset CLI
*
* Gebruik:
* php cli/reset-admin-password.php [gebruikersnaam] [nieuw_wachtwoord]
*
* Als geen wachtwoord opgegeven, wordt een random wachtwoord gegenereerd.
* Wisst ook brute-force lockout voor de gebruiker.
*
* Voorbeelden:
* php cli/reset-admin-password.php admin
* php cli/reset-admin-password.php admin MijnNieuweWachtwoord123
*/
if (php_sapi_name() !== 'cli') {
fwrite(STDERR, "Dit script kan alleen via de CLI uitgevoerd worden.\n");
exit(1);
}
$rootDir = dirname(__DIR__);
require_once $rootDir . '/admin/src/AdminAuth.php';
$appConfig = require $rootDir . '/admin/config/app.php';
// Argumenten parsen
$username = $argv[1] ?? '';
$password = $argv[2] ?? '';
if ($username === '') {
echo "CodePress CMS — Admin wachtwoord reset\n";
echo "========================================\n\n";
echo "Gebruik: php cli/reset-admin-password.php [gebruikersnaam] [nieuw_wachtwoord]\n\n";
echo "Als geen wachtwoord opgegeven, wordt een random wachtwoord gegenereerd.\n\n";
echo "Voorbeelden:\n";
echo " php cli/reset-admin-password.php admin\n";
echo " php cli/reset-admin-password.php admin MijnNieuweWachtwoord123\n";
exit(1);
}
// AdminAuth aanmaken (zonder sessie requirements)
$auth = new AdminAuth($appConfig);
// Controleren of de gebruiker bestaat
$users = $auth->getUsers();
if (!isset($users[$username])) {
fwrite(STDERR, "Fout: Gebruiker '$username' niet gevonden.\n");
fwrite(STDERR, "Beschikbare gebruikers: " . implode(', ', array_keys($users)) . "\n");
exit(1);
}
// Wachtwoord genereren als niet opgegeven
if ($password === '') {
$password = bin2hex(random_bytes(12));
$generated = true;
} else {
$generated = false;
}
// Wachtwoord wijzigen
$result = $auth->changePassword($username, $password);
if (!$result['success']) {
fwrite(STDERR, "Fout: " . $result['message'] . "\n");
exit(1);
}
// Lockout wissen
$auth->clearLockout($username);
echo "CodePress CMS — Admin wachtwoord reset\n";
echo "========================================\n\n";
echo "Gebruiker: $username\n";
echo "Wachtwoord: $password\n";
if ($generated) {
echo " (automatisch gegenereerd — bewaar dit veilig)\n";
}
echo "\n";
echo "Lockout voor '$username' is gewist.\n";
echo "Je kunt nu inloggen via /admin met het nieuwe wachtwoord.\n";
+100
View File
@@ -0,0 +1,100 @@
# CodePress CMS v2.6.1c — Release Notes
**Release datum:** 2026-08-17
**Codename:** Lyra
**Status:** stable
---
## Nieuwe features
### Admin gebruikersbeheer volledig opnieuw ontworpen
De admin/gebruikers pagina is volledig heringericht met drie aparte pagina's:
#### Gebruikerslijst (`/admin/users`)
- Lijst met alle gebruikers (gebruikersnaam, rol, login e-mail, aanmaakdatum, acties)
- **Zoekveld** — zoekt op gebruikersnaam, e-mail of auteur naam
- **Rol filter** — dropdown om op een specifieke rol te filteren
- **"Nieuwe gebruiker" knop** — linkt naar het aanmaakformulier
- **Bewerk knop** per gebruiker — linkt naar de profiel pagina
- **Verwijder knop** per gebruiker (bevestiging via JavaScript)
#### Profiel bewerken (`/admin/users-edit?user=<naam>`)
- **Profiel gegevens**: login e-mail, auteur naam, auteur e-mail wijzigen
- **Wachtwoord wijzigen**: nieuw wachtwoord + bevestiging met JavaScript validatie
- **Rol wijzigen**: dropdown met alle rollen, toont huidige rol met gekleurde badge
- **Gevarenzone**: gebruiker verwijderen (alleen voor andere gebruikers, niet jezelf)
#### Nieuwe gebruiker (`/admin/users-new`)
- Formulier met gebruikersnaam, wachtwoord, e-mail, auteur naam, auteur e-mail, rol
- Na aanmaken → automatische redirect naar profiel pagina van de nieuwe gebruiker
### CLI commando voor admin wachtwoord reset
Nieuw CLI script `cli/reset-admin-password.php` om een admin wachtwoord te resetten en brute-force lockout te wissen — handig bij lockout of vergeten wachtwoord:
```bash
# Met specifiek wachtwoord
php cli/reset-admin-password.php admin NieuwWachtwoord123
# Met automatisch gegenereerd wachtwoord
php cli/reset-admin-password.php admin
```
Het commando wijzigt het wachtwoord (als bcrypt hash), wist de lockout en toont het nieuwe wachtwoord.
---
## Verbeteringen
### AdminAuth uitbreiding
- Nieuwe public methode `clearLockout(string $username)` — wist failed login attempts (voor CLI gebruik)
### Handleidingen bijgewerkt
- `guide/nl/admin-beheerder/gebruikers.md` — volledig herschreven met nieuwe architectuur (lijst, zoeken/filter, profiel bewerken, CLI reset)
- `guide/en/admin-beheerder/gebruikers.md` — Engelse versie bijgewerkt
### Vertalingen
- 15 nieuwe admin vertaalkeys toegevoegd in NL, EN en DE (`users_list`, `search_users`, `all_roles`, `filter`, `back_to_users`, `profile_info`, `change_password`, `new_password`, `confirm_password`, `passwords_no_match`, `danger_zone`, `delete_user`, etc.)
---
## Beveiliging
- Pentest suite: 30/30 tests geslaagd, 0 vulnerabilities
- Security headers aanwezig: X-Frame-Options, Content-Security-Policy, X-Content-Type-Options
- Geen vulnerabilities gedetecteerd
---
## Accessibility
- WCAG 2.1 AA: 25/25 tests geslaagd (100%)
- ARIA landmarks aanwezig (24 per pagina)
- Semantic HTML structuur (header, nav, main, footer)
- Skip-to-content links, focus indicators, screen reader support
---
## Systeem vereisten
- PHP >= 8.0
- Extensies: json, mbstring
- Optioneel: opcache (aanbevolen voor performance)
- Git (optioneel, voor content versioning feature)
- ZipArchive (optioneel, voor ZIP backup/restore feature)
- Apache met `mod_rewrite` en `AllowOverride All` (voor clean URLs en asset-serving)
---
## Upgraden
1. Pull de nieuwe code
2. Test de website
3. Het admin wachtwoord kan gereset worden via `php cli/reset-admin-password.php admin <wachtwoord>` indien nodig
---
## Test resultaten samenvatting
| Test | Resultaat |
|------|----------|
| Pentest | 30/30 geslaagd, 0 vulnerabilities |
| WCAG 2.1 AA Accessibility | 25/25 geslaagd, 100% compliance |
+52 -12
View File
@@ -15,23 +15,63 @@ CodePress has four roles, defined in `AdminAuth::ROLE_PERMISSIONS`:
Roles are displayed with their label via `AdminAuth::ROLE_LABELS`. Roles are displayed with their label via `AdminAuth::ROLE_LABELS`.
## Adding a user ## Users list (`/admin/users`)
1. Go to **Users** The users list shows all users with their username, role, login email and creation date.
2. Enter username
3. Choose password (stored as bcrypt hash)
4. Select a role
5. Click **Add**
## Editing a user ### Search and filter
- Change password (new bcrypt hash) - **Search field**: search by username, email or author name
- Change role (immediately affects visible routes and sidebar items) - **Role filter**: filter by a specific role via the dropdown
- Click **Filter** to apply the results
## Deleting a user ### Adding a new user
- Not possible for own account 1. Click **New user** (top right of the list)
- Confirm with password 2. Enter username, password (minimum 8 characters), email, author name and author email
3. Select a role
4. Click **Add user**
5. You will be automatically redirected to the profile page of the new user
## Editing a profile (`/admin/users-edit?user=<name>`)
Click on a user in the list to edit their profile. The profile page contains three sections:
### Profile information
- Login email, author name and author email can be changed
- The username cannot be changed
### Change password
- Enter a new password (minimum 8 characters)
- Confirm the password
- The password is stored as a bcrypt hash
### Change role
- Shows the current role with a colored badge
- Select a new role from the dropdown
- The change immediately affects the visible admin routes and sidebar items
### Delete user (Danger zone)
- Only visible for other users (not for your own account)
- Confirmation via JavaScript dialog
- After deletion you return to the users list
## Admin password reset via CLI
If the admin is locked out (e.g. due to brute-force lockout or forgotten password), the password can be reset via the CLI:
```bash
# Reset with a specific password
php cli/reset-admin-password.php admin NewPassword123
# Reset with an automatically generated password
php cli/reset-admin-password.php admin
```
The command:
- Changes the password (as a bcrypt hash)
- Clears the brute-force lockout for the user
- Displays the new password in the terminal
## Access control ## Access control
+52 -12
View File
@@ -15,23 +15,63 @@ CodePress kent vier rollen, gedefinieerd in `AdminAuth::ROLE_PERMISSIONS`:
Rollen worden getoond met hun label via `AdminAuth::ROLE_LABELS`. Rollen worden getoond met hun label via `AdminAuth::ROLE_LABELS`.
## Gebruiker toevoegen ## Gebruikerslijst (`/admin/users`)
1. Ga naar **Gebruikers** De gebruikerslijst toont alle gebruikers met hun gebruikersnaam, rol, login e-mail en aanmaakdatum.
2. Vul gebruikersnaam in
3. Kies wachtwoord (opgeslagen als bcrypt hash)
4. Selecteer een rol
5. Klik **Toevoegen**
## Gebruiker bewerken ### Zoeken en filteren
- Wachtwoord wijzigen (nieuwe bcrypt hash) - **Zoekveld**: zoek op gebruikersnaam, e-mail of auteur naam
- Rol wijzigen (beïnvloedt direct zichtbare routes en sidebar items) - **Rol filter**: filter op een specifieke rol via de dropdown
- Klik op **Filteren** om de resultaten toe te passen
## Gebruiker verwijderen ### Nieuwe gebruiker aanmaken
- Kan niet voor eigen account 1. Klik op **Nieuwe gebruiker** (rechtsboven in de lijst)
- Bevestig met wachtwoord 2. Vul gebruikersnaam, wachtwoord (minimaal 8 tekens), e-mail, auteur naam en auteur e-mail in
3. Selecteer een rol
4. Klik **Gebruiker toevoegen**
5. Je wordt automatisch doorgestuurd naar de profiel pagina van de nieuwe gebruiker
## Profiel bewerken (`/admin/users-edit?user=<naam>`)
Klik op een gebruiker in de lijst om het profiel te bewerken. De profiel pagina bevat drie secties:
### Profiel gegevens
- Login e-mail, auteur naam en auteur e-mail kunnen worden gewijzigd
- De gebruikersnaam kan niet worden gewijzigd
### Wachtwoord wijzigen
- Vul een nieuw wachtwoord in (minimaal 8 tekens)
- Bevestig het wachtwoord
- Het wachtwoord wordt opgeslagen als bcrypt hash
### Rol wijzigen
- Toont de huidige rol met een gekleurde badge
- Selecteer een nieuwe rol uit de dropdown
- De wijziging beïnvloedt direct de zichtbare admin routes en sidebar items
### Gebruiker verwijderen (Gevarenzone)
- Alleen zichtbaar voor andere gebruikers (niet voor je eigen account)
- Bevestiging via JavaScript dialog
- Na verwijderen keer je terug naar de gebruikerslijst
## Admin wachtwoord reset via CLI
Als de admin is uitgesloten (bijv. door brute-force lockout of vergeten wachtwoord), kan het wachtwoord via de CLI worden gereset:
```bash
# Reset met een specifiek wachtwoord
php cli/reset-admin-password.php admin NieuwWachtwoord123
# Reset met een automatisch gegenereerd wachtwoord
php cli/reset-admin-password.php admin
```
Het commando:
- Wijzigt het wachtwoord (als bcrypt hash)
- Wist de brute-force lockout voor de gebruiker
- Toont het nieuwe wachtwoord in de terminal
## Toegangscontrole ## Toegangscontrole
+13
View File
@@ -242,6 +242,19 @@ return [
'no_users' => 'Keine Benutzer gefunden.', 'no_users' => 'Keine Benutzer gefunden.',
'new_user' => 'Neuer Benutzer', 'new_user' => 'Neuer Benutzer',
'add_user' => 'Benutzer hinzufügen', 'add_user' => 'Benutzer hinzufügen',
'users_list' => 'Benutzerliste',
'search_users' => 'Nach Benutzername, E-Mail oder Autor-Name suchen...',
'all_roles' => 'Alle Rollen',
'filter' => 'Filtern',
'delete' => 'Löschen',
'back_to_users' => 'Zurück zu Benutzer',
'profile_info' => 'Profilinformationen',
'change_password' => 'Passwort ändern',
'new_password' => 'Neues Passwort',
'confirm_password' => 'Passwort bestätigen',
'passwords_no_match' => 'Passwörter stimmen nicht überein.',
'danger_zone' => 'Gefahrenzone',
'delete_user' => 'Benutzer löschen',
// Login // Login
'login_title' => 'CodePress Admin - Anmeldung', 'login_title' => 'CodePress Admin - Anmeldung',
+13
View File
@@ -242,6 +242,19 @@ return [
'no_users' => 'No users found.', 'no_users' => 'No users found.',
'new_user' => 'New user', 'new_user' => 'New user',
'add_user' => 'Add user', 'add_user' => 'Add user',
'users_list' => 'User list',
'search_users' => 'Search by username, email or author name...',
'all_roles' => 'All roles',
'filter' => 'Filter',
'delete' => 'Delete',
'back_to_users' => 'Back to users',
'profile_info' => 'Profile information',
'change_password' => 'Change password',
'new_password' => 'New password',
'confirm_password' => 'Confirm password',
'passwords_no_match' => 'Passwords do not match.',
'danger_zone' => 'Danger zone',
'delete_user' => 'Delete user',
// Login // Login
'login_title' => 'CodePress Admin - Login', 'login_title' => 'CodePress Admin - Login',
+13
View File
@@ -242,6 +242,19 @@ return [
'no_users' => 'Geen gebruikers gevonden.', 'no_users' => 'Geen gebruikers gevonden.',
'new_user' => 'Nieuwe gebruiker', 'new_user' => 'Nieuwe gebruiker',
'add_user' => 'Gebruiker toevoegen', 'add_user' => 'Gebruiker toevoegen',
'users_list' => 'Gebruikerslijst',
'search_users' => 'Zoek op gebruikersnaam, e-mail of auteur naam...',
'all_roles' => 'Alle rollen',
'filter' => 'Filteren',
'delete' => 'Verwijderen',
'back_to_users' => 'Terug naar gebruikers',
'profile_info' => 'Profiel gegevens',
'change_password' => 'Wachtwoord wijzigen',
'new_password' => 'Nieuw wachtwoord',
'confirm_password' => 'Bevestig wachtwoord',
'passwords_no_match' => 'Wachtwoorden komen niet overeen.',
'danger_zone' => 'Gevarenzone',
'delete_user' => 'Gebruiker verwijderen',
// Login // Login
'login_title' => 'CodePress Admin - Login', 'login_title' => 'CodePress Admin - Login',
+147 -24
View File
@@ -328,6 +328,14 @@ switch ($route) {
handleUsers($auth, $appConfig, $twig, $user, $csrf); handleUsers($auth, $appConfig, $twig, $user, $csrf);
break; break;
case 'users-edit':
handleUsersEdit($auth, $appConfig, $twig, $user, $csrf);
break;
case 'users-new':
handleUsersNew($auth, $appConfig, $twig, $user, $csrf);
break;
case 'guide': case 'guide':
handleGuide($auth, $appConfig, $twig, $user, $csrf); handleGuide($auth, $appConfig, $twig, $user, $csrf);
break; break;
@@ -1575,35 +1583,15 @@ function handleUsers($auth, $config, $twig, $user, $csrf): void
{ {
$message = ''; $message = '';
$messageType = ''; $messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) { if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.'; $message = 'Ongeldige CSRF token.';
$messageType = 'danger'; $messageType = 'danger';
} else { } else {
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
if ($action === 'add') { if ($action === 'delete') {
$newUsername = trim($_POST['new_username'] ?? '');
$newPassword = $_POST['new_password'] ?? '';
$newRole = $_POST['new_role'] ?? 'content-manager';
$newEmail = trim($_POST['new_email'] ?? '');
$newAuthorName = trim($_POST['new_author_name'] ?? '');
$newAuthorEmail = trim($_POST['new_author_email'] ?? '');
$result = $auth->addUser($newUsername, $newPassword, $newRole, $newEmail, $newAuthorName, $newAuthorEmail);
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
} elseif ($action === 'profile') {
$profileUser = $_POST['profile_username'] ?? '';
$profileEmail = trim($_POST['profile_email'] ?? '');
$profileAuthorName = trim($_POST['profile_author_name'] ?? '');
$profileAuthorEmail = trim($_POST['profile_author_email'] ?? '');
$result = $auth->updateUserProfile($profileUser, $profileEmail, $profileAuthorName, $profileAuthorEmail);
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
} elseif ($action === 'delete') {
$deleteUser = $_POST['delete_username'] ?? ''; $deleteUser = $_POST['delete_username'] ?? '';
if ($deleteUser !== $user['username']) { if ($deleteUser !== $user['username']) {
$result = $auth->deleteUser($deleteUser); $result = $auth->deleteUser($deleteUser);
@@ -1622,17 +1610,152 @@ function handleUsers($auth, $config, $twig, $user, $csrf): void
} }
} }
} }
$route = 'users'; $route = 'users';
$users = $auth->getUsers(); $users = $auth->getUsers();
$roles = AdminAuth::getRoles(); $roles = AdminAuth::getRoles();
// Search and filter
$search = trim($_GET['search'] ?? '');
$roleFilter = $_GET['role'] ?? '';
if ($search !== '') {
$users = array_filter($users, function($u) use ($search) {
return stripos($u['username'], $search) !== false
|| stripos($u['email'] ?? '', $search) !== false
|| stripos($u['author_name'] ?? '', $search) !== false;
});
}
if ($roleFilter !== '') {
$users = array_filter($users, function($u) use ($roleFilter) {
return $u['role'] === $roleFilter;
});
}
echo $twig->render('pages/users.twig', [ echo $twig->render('pages/users.twig', [
'user' => $user, 'user' => $user,
'route' => $route, 'route' => $route,
'csrf_token' => $csrf, 'csrf_token' => $csrf,
'users' => $users, 'users' => $users,
'roles' => $roles, 'roles' => $roles,
'search' => $search,
'role_filter' => $roleFilter,
'sidebar_color' => getSidebarColor($config),
'needs_editor' => false,
'message' => $message,
'message_type' => $messageType,
]);
}
function handleUsersEdit($auth, $config, $twig, $user, $csrf): void
{
$targetUsername = $_GET['user'] ?? '';
$targetUsername = preg_replace('/[^a-zA-Z0-9_\-]/', '', $targetUsername);
if ($targetUsername === '') {
header('Location: /admin/users');
exit;
}
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$action = $_POST['action'] ?? '';
if ($action === 'profile') {
$profileEmail = trim($_POST['profile_email'] ?? '');
$profileAuthorName = trim($_POST['profile_author_name'] ?? '');
$profileAuthorEmail = trim($_POST['profile_author_email'] ?? '');
$result = $auth->updateUserProfile($targetUsername, $profileEmail, $profileAuthorName, $profileAuthorEmail);
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
} elseif ($action === 'change_role') {
$newRole = $_POST['new_role'] ?? '';
$result = $auth->changeRole($targetUsername, $newRole);
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
} elseif ($action === 'change_password') {
$newPassword = $_POST['new_password'] ?? '';
$result = $auth->changePassword($targetUsername, $newPassword);
$message = $result['message'];
$messageType = $result['success'] ? 'success' : 'danger';
} elseif ($action === 'delete') {
if ($targetUsername !== $user['username']) {
$result = $auth->deleteUser($targetUsername);
header('Location: /admin/users?msg=' . urlencode($result['message']));
exit;
} else {
$message = 'Je kunt jezelf niet verwijderen.';
$messageType = 'danger';
}
}
}
}
$users = $auth->getUsers();
if (!isset($users[$targetUsername])) {
header('Location: /admin/users');
exit;
}
$targetUser = $users[$targetUsername];
$roles = AdminAuth::getRoles();
$isSelf = ($targetUsername === $user['username']);
echo $twig->render('pages/users-edit.twig', [
'user' => $user,
'route' => 'users',
'csrf_token' => $csrf,
'target_user' => $targetUser,
'is_self' => $isSelf,
'roles' => $roles,
'sidebar_color' => getSidebarColor($config),
'needs_editor' => false,
'message' => $message,
'message_type' => $messageType,
]);
}
function handleUsersNew($auth, $config, $twig, $user, $csrf): void
{
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$newUsername = trim($_POST['new_username'] ?? '');
$newPassword = $_POST['new_password'] ?? '';
$newRole = $_POST['new_role'] ?? 'content-manager';
$newEmail = trim($_POST['new_email'] ?? '');
$newAuthorName = trim($_POST['new_author_name'] ?? '');
$newAuthorEmail = trim($_POST['new_author_email'] ?? '');
$result = $auth->addUser($newUsername, $newPassword, $newRole, $newEmail, $newAuthorName, $newAuthorEmail);
if ($result['success']) {
header('Location: /admin/users-edit?user=' . urlencode($newUsername) . '&msg=' . urlencode($result['message']));
exit;
}
$message = $result['message'];
$messageType = 'danger';
}
}
$roles = AdminAuth::getRoles();
echo $twig->render('pages/users-new.twig', [
'user' => $user,
'route' => 'users',
'csrf_token' => $csrf,
'roles' => $roles,
'sidebar_color' => getSidebarColor($config), 'sidebar_color' => getSidebarColor($config),
'needs_editor' => false, 'needs_editor' => false,
'message' => $message, 'message' => $message,
+1 -1
View File
@@ -4,7 +4,7 @@
*/ */
return [ return [
'version' => '2.6.1', 'version' => '2.6.1c',
'release_date' => '2026-08-17', 'release_date' => '2026-08-17',
'codename' => 'Lyra', 'codename' => 'Lyra',
'status' => 'stable', 'status' => 'stable',