Files
Paul LütznerandClaude Opus 5 755f3cf257 feat(users): an administrator can run the whole life of an account, and you can change your own password
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>
2026-08-31 16:02:12 +02:00

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 %}