mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Managing users was reachable and worked — list, search, create, promote, demote, remove — but three things around it did not, and one of them was load-bearing. ── You could not change your own password ────────────────────────────────── Nowhere. Not in Settings, not by mail, not at all; only an administrator setting one for you. Settings → Security now leads with a Password card that asks for the current password (an unmapped UserPassword constraint, so it is checked against the signed-in token rather than against anything the form was handed) and takes the new one twice. The route carries no id and the form has no data_class, so it structurally cannot become the admin form by accident. The floor those passwords are held to was three literal 12s in three files that happened to agree. It is now User::PASSWORD_MIN_LENGTH, read by all three. ── An account could only be removed, never switched off ──────────────────── Nothing sat between "leave it alone" and "destroy it", so the answer to "this person has left, keep their mail" was to delete them. There is a deactivated_at now, and a UserChecker registered on BOTH firewalls — the JMAP one included, because an app password outlives the browser session it was minted from and a check only on `main` would shut somebody out of the web UI while every mail client they had already connected kept syncing. The refusal is in checkPostAuth rather than checkPreAuth on purpose. checkPreAuth runs before the password is verified, so refusing there would answer "is this address a suspended account here?" to anyone who types an address — the question hide_user_not_found exists to keep unanswerable. After verification, the only person told is the one who could otherwise have signed in. Symfony runs user checkers at authentication only, so a session already open would have carried on until it happened to end. UserProvider::refreshUser() refuses the same two states on every request. Suspension also travels through config backup, which would otherwise have handed a suspended account its sign-in back on restore. ── Two smaller lies ──────────────────────────────────────────────────────── A search under three characters silently returned the unfiltered list, which a QueryBuilder has no way to complain about, so the floor was dropped rather than kept as something that could only mislead. And an admin declining to demote themselves got a success toast for a change that never happened; the refusal now says so, while the rest of the form still saves. ── Restoring a removed user is deliberately absent ───────────────────────── Removal overwrites the address, the name and the hash specifically to free the unique address for reuse, so a restore would have to invent all three and the original address may by then belong to somebody else. Doing it properly means snapshot columns and a guided flow that asks for a fresh address — a different change, which alters what removal means. Documented as absent on the screen an administrator would look for the button on, because switching an account off is the answer to the question people are actually asking. Also fixed en route: the modal form theme dropped help_translation_parameters, so the first field to use one rendered "at least %count% characters" on screen. And GermanIsInformalTest only ever globbed messages.de.yaml, leaving validators and the new security catalogue — exactly where a translator reaches for "Sie" — outside the test that exists to catch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
48 lines
2.1 KiB
Twig
48 lines
2.1 KiB
Twig
{#
|
|
Settings → Security, first card: changing your own password.
|
|
|
|
Above the two-factor card and not inside it, because they are peers rather than
|
|
one subject with two halves — the rule settings/index.html.twig states for when
|
|
a section splits. It comes first because it is the one every account has: the
|
|
second factor is optional and most installs never switch it on, while a password
|
|
is what every single person here signs in with.
|
|
|
|
Accepts:
|
|
passwordForm a ChangePasswordType view, from ChangePasswordFormFactory
|
|
|
|
`padded`, like the profile card: this body is a bare form rather than a list
|
|
that pads its own rows.
|
|
|
|
The form posts as a Turbo Stream (`data-turbo-stream`) and the reply replaces
|
|
the <div id="settings-password"> this sits inside — see
|
|
PasswordController::streamResponse(). It must NOT be `data-turbo="false"`: that
|
|
would navigate to the endpoint, and the endpoint answers with a stream fragment
|
|
rather than a page.
|
|
#}
|
|
{% embed 'settings/_card.html.twig' with {
|
|
heading: 'settings.password.heading'|trans,
|
|
subheading: 'settings.password.subheading'|trans,
|
|
padded: true,
|
|
} %}
|
|
{% block body %}
|
|
<div class="max-w-md">
|
|
{{ form_start(passwordForm, { attr: { 'data-turbo-stream': true } }) }}
|
|
{{ form_widget(passwordForm) }}
|
|
|
|
<button type="submit"
|
|
class="mt-6 inline-flex items-center justify-center h-9 px-4 rounded-lg text-sm font-medium
|
|
text-accent-ink bg-accent hover:bg-accent-strong active:bg-blue-800
|
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1
|
|
transition-colors duration-100">
|
|
{{ 'settings.password.submit'|trans }}
|
|
</button>
|
|
{{ form_end(passwordForm) }}
|
|
|
|
<p class="mt-4 text-[11px] text-ink-muted leading-relaxed">
|
|
<i class="fa-solid fa-circle-info mr-1" aria-hidden="true"></i>
|
|
{{ 'settings.password.remember_me_note'|trans }}
|
|
</p>
|
|
</div>
|
|
{% endblock %}
|
|
{% endembed %}
|