Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite
Konten gab es bisher nur über die Konsole. Unter "Benutzer" legt ein Administrator jetzt Konten an, ändert Rollen, setzt Passwörter und löscht Konten; unter "Mein Konto" ändert jeder sein eigenes Passwort, aber nur mit dem aktuellen in der Hand. Eine Rolle je Konto: Administrator, Veröffentlicher, Redakteur. Ein Passwort oder eine Rolle zu ändern meldet jede offene Sitzung dieses Kontos ab - dafür war nichts zu bauen, CmsUser::isEqualTo vergleicht Hash und Rollen mit der Datenbank. Genau eine Sitzung überlebt: die, die ihr eigenes Passwort geändert hat. "Jemanden sofort aussperren" ist damit dasselbe wie "Passwort setzen". Es bleibt immer ein Administrator übrig, nicht über eine Zählung, sondern weil niemand sein eigenes Konto löschen oder herabstufen kann und jede Änderung die Administratorrolle voraussetzt. Die zusätzliche Prüfung auf einen zweiten Administrator war damit unerreichbar und ist wieder heraus. Mediathek (Umut: "das sollte direkt laden in pagination"): /admin/media zeigt die Bilder jetzt selbst, 24 je Seite, neueste zuerst, mit Blättern und einem Schalter für archivierte. Hochladen und Bildangaben laufen über gewöhnliche Formulare ohne JavaScript; der Dialog bleibt der Weg, beim Bearbeiten ein Bild auszuwählen. Die Bild-ID steht in jeder Karte, weil Beitragsbild und Teilen-Vorschau genau diese ID erwarten. Die Migrationsliste der Testfixtures stand dreimal da und steht jetzt einmal in tests/TestSchema.php. 217 PHP-Tests / 845 Assertions unter PHP 8.2.12 und 8.4.22. axe deckt jetzt elf Bildschirme ab statt drei - alle Verwaltungsseiten, nicht nur Anmeldung, Seitenliste und Editor -, null Verstöße. Nicht geprüft: kein Screenreader; Blättern und Upload sind mit zwei Bildern geprüft, nicht mit einer großen Bibliothek. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4841bf65bc
commit
14031a5880
23 files changed
+1281
-101
No files matched your search
@@ -0,0 +1,43 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'account' %}
|
||||
{% block title %}Mein Konto{% endblock %}
|
||||
{% block heading %}Mein Konto{% endblock %}
|
||||
{% block lead %}Dein Passwort kannst du hier selbst ändern. Deine Rolle kann nur ein Administrator ändern.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>{{ app.user.userIdentifier }}</h2><span class="muted">{{ account ? roles[account.role] : 'Rolle unbekannt' }}</span></div>
|
||||
<div class="card-body">
|
||||
{% if account %}
|
||||
<p class="muted">{{ roleHelp[account.role] }}</p>
|
||||
<p class="field-help">
|
||||
{% if account.lastLoginAt %}Zuletzt angemeldet am {{ account.lastLoginAt|date('d.m.Y H:i') }} UTC{% else %}Dies ist deine erste Sitzung{% endif %}.
|
||||
{% if account.passwordChangedAt %} Passwort zuletzt geändert am {{ account.passwordChangedAt|date('d.m.Y') }}.{% endif %}
|
||||
{% if account.createdAt %} Konto angelegt am {{ account.createdAt|date('d.m.Y') }}.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Passwort ändern</h2><span class="muted">Andere Sitzungen werden abgemeldet</span></div>
|
||||
<form action="{{ path('admin_account_password') }}" method="post" class="card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('account') }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="current">Aktuelles Passwort</label>
|
||||
<input id="current" name="current" type="password" required autocomplete="current-password" maxlength="{{ maxPassword }}">
|
||||
<p class="field-help">Nötig, damit eine vergessene offene Sitzung nicht ausreicht, um dein Konto zu übernehmen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input id="password" name="password" type="password" required autocomplete="new-password" minlength="{{ minPassword }}" maxlength="{{ maxPassword }}">
|
||||
<p class="field-help">Mindestens {{ minPassword }} Zeichen, höchstens {{ maxPassword }} Bytes. Diese Sitzung bleibt angemeldet, alle anderen dieses Kontos nicht.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" type="submit">Passwort ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="footnote">Kant CMS · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -1,16 +1,82 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'media' %}
|
||||
{% block title %}Mediathek{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media.js"></script>{% endblock %}
|
||||
{% block heading %}Mediathek{% endblock %}
|
||||
{% block lead %}Bilder hochladen, Angaben pflegen und in deinen Seiten wiederverwenden. Dieselbe Mediathek öffnet sich auch im Editor, wenn du ein Bild auswählst.{% endblock %}
|
||||
{% block actions %}<button id="open-media-library" class="primary">Mediathek öffnen</button>{% endblock %}
|
||||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Bilder verwalten</h2><span class="muted">Hochladen, benennen, Alternativtext pflegen</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Größen abgelegt; die Seite liefert dann die passende aus. Ein Alternativtext beschreibt, was zu sehen ist – er ist das, was Menschen mit Screenreader vom Bild haben.</p>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<form action="{{ path('admin_media_upload') }}" method="post" enctype="multipart/form-data" class="card card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="files">Bilder hochladen</label>
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp" multiple required>
|
||||
<p class="field-help">JPEG, PNG oder WebP, höchstens 8 MB je Bild. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen.</p>
|
||||
<button class="primary" type="submit">Hochladen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-head">
|
||||
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
|
||||
<span class="muted">
|
||||
{% if archived %}<a href="{{ path('admin_media') }}">Archivierte ausblenden</a>{% else %}<a href="{{ path('admin_media', {archiv: 1}) }}">Archivierte anzeigen</a>{% endif %}
|
||||
{% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if library.items is empty %}
|
||||
<div class="empty">
|
||||
<h3>{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}</h3>
|
||||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="library-grid">
|
||||
{% for item in library.items %}
|
||||
<li class="library-tile">
|
||||
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
|
||||
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
|
||||
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
|
||||
</a>
|
||||
<div class="library-body">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
|
||||
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
|
||||
<code>{{ item.id }}</code>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="title-{{ item.id }}">Bildtitel</label>
|
||||
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<label for="alt-{{ item.id }}">Alternativtext</label>
|
||||
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
|
||||
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
|
||||
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if library.pages > 1 %}
|
||||
<nav class="pager" aria-label="Seiten der Mediathek">
|
||||
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
||||
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
|
||||
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<p class="footnote">Kant CMS · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -23,12 +23,13 @@
|
||||
<ul class="nav-list" aria-labelledby="nav-site">
|
||||
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">◐</span>Design</a></li>
|
||||
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⚙</span>Einstellungen</a></li>
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">☺</span>Benutzer</a></li>{% endif %}
|
||||
<li><a class="nav-item nav-out" href="/">Website ansehen</a></li>
|
||||
<li><a class="nav-item nav-out" href="/beitraege">Beitragsübersicht</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="shell-foot">
|
||||
<p class="shell-user"><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></p>
|
||||
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
|
||||
<form action="{{ path('admin_logout') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
||||
<button class="subtle" type="submit">Abmelden</button>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'users' %}
|
||||
{% block title %}Benutzer{% endblock %}
|
||||
{% block heading %}Benutzer{% endblock %}
|
||||
{% block lead %}Jedes Konto hat genau eine Rolle. Ein Passwort oder eine Rolle zu ändern meldet alle offenen Sitzungen dieses Kontos ab – das ist der Weg, jemanden sofort auszusperren.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<details class="card"{% if app.flashes is not empty %} open{% endif %}>
|
||||
<summary>Neues Konto anlegen</summary>
|
||||
<form action="{{ path('admin_user_create') }}" method="post" class="card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="new-username">Benutzername</label>
|
||||
<input id="new-username" name="username" maxlength="100" required autocomplete="off" pattern="[a-z0-9][a-z0-9._-]{2,99}">
|
||||
<p class="field-help">3 bis 100 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Unterstrich, Bindestrich.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password">Passwort</label>
|
||||
<input id="new-password" name="password" type="password" required autocomplete="new-password" minlength="{{ minPassword }}" maxlength="{{ maxPassword }}">
|
||||
<p class="field-help">Mindestens {{ minPassword }} Zeichen, höchstens {{ maxPassword }} Bytes. Gib es der Person auf einem anderen Weg als per E-Mail weiter; sie kann es unter „Mein Konto“ selbst ändern.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-role">Rolle</label>
|
||||
<select id="new-role" name="role">
|
||||
{% for value, label in roles %}<option value="{{ value }}"{{ value == 'ROLE_EDITOR' ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">{% for value, help in roleHelp %}<strong>{{ roles[value] }}:</strong> {{ help }}{% if not loop.last %}<br>{% endif %}{% endfor %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" type="submit">Konto anlegen</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<div class="section-head"><h2>Konten <span class="count">{{ users|length }}</span></h2><span class="muted">Zeiten in UTC.</span></div>
|
||||
<div class="rows">
|
||||
{% for account in users %}
|
||||
<article class="row row-stack">
|
||||
<div class="row-line">
|
||||
<span class="row-icon" aria-hidden="true">☺</span>
|
||||
<div class="row-main">
|
||||
<h3>{{ account.username }}{% if account.id == me %} <span class="pill">dein Konto</span>{% endif %}</h3>
|
||||
<p>{{ account.roleName }}
|
||||
· {% if account.lastLoginAt %}zuletzt angemeldet {{ account.lastLoginAt|date('d.m.Y H:i') }}{% else %}noch nie angemeldet{% endif %}
|
||||
{% if account.passwordChangedAt %} · Passwort vom {{ account.passwordChangedAt|date('d.m.Y') }}{% endif %}
|
||||
{% if account.createdAt %} · angelegt {{ account.createdAt|date('d.m.Y') }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<span class="pill {{ 'ROLE_ADMIN' == account.role ? 'pill-live' : ('ROLE_PUBLISHER' == account.role ? 'pill-draft' : 'pill-off') }}">{{ account.roleName }}</span>
|
||||
</div>
|
||||
<details class="sheet">
|
||||
<summary aria-label="Einstellungen von {{ account.username }}">Einstellungen</summary>
|
||||
<div class="sheet-body">
|
||||
<div class="sheet-part">
|
||||
<h4>Rolle</h4>
|
||||
{% if account.id == me %}
|
||||
<p>Die eigene Rolle lässt sich hier nicht ändern. Das soll ein zweiter Administrator tun – sonst kann sich der letzte Administrator selbst die Rechte nehmen.</p>
|
||||
{% else %}
|
||||
<form action="{{ path('admin_user_role', {id: account.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
|
||||
<div class="field">
|
||||
<label for="role-{{ account.id }}">Rolle von {{ account.username }}</label>
|
||||
<select id="role-{{ account.id }}" name="role">
|
||||
{% for value, label in roles %}<option value="{{ value }}"{{ value == account.role ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Offene Sitzungen dieses Kontos werden dabei abgemeldet.</p>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Rolle ändern</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="sheet-part">
|
||||
<h4>Passwort setzen</h4>
|
||||
<p>Setzt ein neues Passwort, ohne das alte zu kennen – für den Fall, dass jemand nicht mehr hineinkommt. Alle offenen Sitzungen dieses Kontos werden abgemeldet.</p>
|
||||
<form action="{{ path('admin_user_password', {id: account.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
|
||||
<div class="field">
|
||||
<label for="password-{{ account.id }}">Neues Passwort für {{ account.username }}</label>
|
||||
<input id="password-{{ account.id }}" name="password" type="password" required autocomplete="new-password" minlength="{{ minPassword }}" maxlength="{{ maxPassword }}">
|
||||
</div>
|
||||
<button class="secondary" type="submit">Passwort setzen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if account.id != me %}
|
||||
<div class="sheet-part">
|
||||
<h4>Konto löschen</h4>
|
||||
<p>Entfernt „{{ account.username }}“. Gespeicherte Inhalte bleiben unberührt – Seiten und Fassungen hängen nicht am Konto. Das eigene Konto lässt sich nicht löschen; deshalb bleibt immer mindestens ein Administrator übrig.</p>
|
||||
<form action="{{ path('admin_user_delete', {id: account.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
|
||||
<button class="subtle danger-text" type="submit">Konto löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Wenn kein Konto mehr funktioniert</h2><span class="muted">Zugang über die Konsole</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Auf dem Server legt <code>php bin/console cms:user:create name --role=admin</code> ein Konto an; das Passwort wird verdeckt abgefragt und ein vorhandenes Konto nie überschrieben. Das ist der Weg zurück, wenn niemand mehr hineinkommt – er braucht Dateizugriff auf den Server und lässt sich hier nicht ersetzen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footnote">Kant CMS · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user