Skip to content

Add a staff user editor and revamp the profile editor - #107

Merged
carlbennett merged 29 commits into
developfrom
user-admin-edit
Nov 4, 2026
Merged

carlbennett merged 29 commits into
developfrom
user-admin-edit

Conversation

@carlbennett

Copy link
Copy Markdown
Member

Adds a staff page for editing users at /user/edit, revamps the self-service profile editor at /user/update, and fixes several account and session bugs found along the way. Each commit stands alone so the branch can be fast-forwarded onto develop.

Staff user editor (/user/edit)

  • Account: username, display name, and email address.
  • Status: Disabled and Verified, with a button to resend the verification email.
  • Public profile: the same fields as /user/update, from a shared template.
  • Permissions: a grid of the options bitmask with None, Default, and Staff presets and a hex readout. Cells without an option read Public or Automatic.
  • Reset password: clears the password, expires every session of the user, and emails a link to choose a new one. A confirmation box must be ticked first.
  • Access rules:
    • The page requires OPTION_ACL_USER_MODIFY.
    • Staff can only grant or revoke permissions they hold.
    • Staff cannot edit an account that holds permissions they lack, since changing its email would allow taking it over.
    • On your own account you cannot disable yourself or remove your own user-modify permission. You also cannot reset your own password there; use Change password.
  • Email changes: changing a user's email unverifies the account and sends a verification link to the new address. A change of letter case alone does not.
  • Logging: every edit is logged as USER_EDITED by the logged-in staff member, with the changed fields and the permissions granted or revoked. Resets are logged as USER_PASSWORD_RESET. The Discord embed says "Email: Changed" without the address.
  • Links: every link to the page uses the 🛡️ emoji, including from the user list and profile pages.

Profile editor (/user/update)

  • All-or-nothing saving: if any field is invalid, nothing is saved, and errors show under each field.
    • A taken username or email now shows an error instead of a 500.
    • The minimum username length is now enforced.
    • The page no longer returns 500 for new users.
  • Email changes need a confirmation field, shown only once the address is edited. They also require verifying the new address; the session continues, but logging in again waits for verification.
  • Resending verification: unverified users get a button to resend the email. Self-sent verification emails are limited to one every 2 minutes.
  • Staff redirect: ?id=N for another user redirects staff to /user/edit.
  • Help text: the username and email help texts now say correctly which one is used to log in.

Fixes

  • Disabled users: existing sessions now end on their next request.
  • Logging out never ended the session server-side: the delete compared the binary session id with its hex form. Logging out now sets the session's expiry to the current time. Session records are no longer deleted by the site; removing them is left to a separate cleanup job.
  • Session lookup used expires_datetime = NULL, so sessions without an expiry date were rejected. It now uses IS NULL.
  • Password reset links could be reused after a successful reset by an already verified user. They now work once.
  • Accounts without a password were told their password was incorrect at login. They are now told to reset it.
  • Verify links:
    • Following one again shows a friendly 409 page instead of an error.
    • Array query parameters no longer cause a 500.
    • Verification without a pending token is refused.
    • Staff toggling Verified keeps the verified date and token consistent.
  • new Profile(null) left its fields uninitialized.

Also

  • The verification and password reset emails moved into the VerificationEmail and ResetPasswordEmail libraries. Their wording now depends on why they were sent: registration, a resend, an email change, or a staff action.
  • The emails no longer claim verification links expire after 24 hours.
  • Social link bubbles on profiles are colored by platform.
  • The profile page's password button is captioned "Change password".
  • Everything works without JavaScript; the permission presets, which need it, are hidden unless scripts run.

Testing

  • Lint, PHPStan, and PHPUnit (847 tests) pass locally and in CI.
  • Every flow was exercised live on dev.bnetdocs.org with throwaway accounts, which were then deleted with their events and sessions.
  • Email sending was tested against a local SMTP sink, and the failure path with the relay blocked.
  • Not yet tested: a real delivery through the production relay of the email-change verification email and the staff password reset email.

🤖 Generated with Claude Code

carlbennett and others added 29 commits November 4, 2026 00:55
OPTION_DISABLED was documented as force-expiring active sessions, but only
the login controller checked it. A user disabled while logged in stayed
logged in until their session expired on its own. Authentication::verify()
now discards the session as soon as the user is found to be disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The USER_EDITED event and its Discord embed were only emitted when a public
profile field changed, so changing only the username, email address, or
display name left no audit trail. Log the event whenever any account or
profile field was saved, and record which of the two changed.

The event is still attributed to the logged-in user making the change. The
Discord embed now links to the edited user instead of the editor, since the
editor is already shown as the embed author.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/user/edit?id=N lets a user with OPTION_ACL_USER_MODIFY edit another user's
username, display name, email address, and options bitmask, which until now
could only be changed in the database. Permissions are laid out as a
resource by create/view/modify/delete grid, with the disabled and verified
status flags shown separately, and presets for none, default, and staff.

To keep the page from being a privilege escalation path:
- Only permissions the editor holds can be granted or revoked.
- Users who hold permissions the editor lacks cannot be edited at all, since
  changing their email address would allow taking over their account.
- Editors cannot disable themselves or remove their own USER_MODIFY.
- Bits without a named option are preserved as-is.

Saves are validated as a whole and logged as USER_EDITED, attributed to the
editor, with the changed fields and the granted and revoked option names.
The profile page and user list now link to this page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Models default to a 500 response code and the update controller never set
another one for logged-in users, so every profile view and save was served
as an internal server error despite rendering normally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/user/update?id=N let staff edit other users through the same form users
edit their own profile with. Staff now edit other users at /user/edit, so
/user/update always edits the active user. Existing links with another
user's id redirect staff to /user/edit, without applying any posted form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grid showed an em dash where a resource has no option for a column,
which read as a locked checkbox: most of the View column looked like
permissions the editor was not allowed to grant. Those cells now say
Public (anyone can view the resource) or Automatic (event logs are
recorded by the site), with an explanation under the heading.

The note about greyed-out permissions is now only shown when some
permission is actually locked, and says why when editing yourself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since /user/update became self-only, staff had no way to edit another
user's biography or social profile fields, for example to remove spam.
/user/edit now has a public profile section with every profile field.

Profile fields are validated together with the account fields, so nothing
is saved unless all of them are valid. Changed fields are recorded in the
USER_EDITED event with their old and new values, and listed by name in the
Discord embed. A profile row is created for users who do not have one yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/user/verify compared the submitted token to the user's verifier token with
!==, so a user whose token was null could be verified by omitting the t
parameter entirely. Require a non-empty pending token and compare it in
constant time. Non-string query parameters are now treated as missing
instead of throwing a TypeError and returning 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user editor changed OPTION_VERIFIED as a plain bit, which left the
verified date and verifier token stale: unverifying a user left them
without any token for a verification link to use. Toggle it through
User::setVerified() instead, which issues a new token when unverifying and
records the date and clears the token when verifying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Registration built and sent the account verification email inline, so it
could not be sent from anywhere else. VerificationEmail::send() now does
this for any user with a pending verifier token, attributing the
EMAIL_SENT event to a given actor, and registration uses it unchanged.

It also takes a resend flag for emails sent after registration, which
replace the welcome line and the note about the link expiring 24 hours
after registering, neither of which applies to a resent link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The status section of /user/edit now has a button that sends an
unverified user a new verification email at their current address,
attributed to the staff member in the EMAIL_SENT event. A user missing a
verifier token is issued one first. The button is disabled for verified
users, with a hint to untick Verified and save to have them verify again.

The button submits its own form, so it never saves the main form, and it
asks for confirmation if the main form has unsaved changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A verified user has no pending token, so following their verification
link a second time showed the manual user id and token form with an
"expired or invalid" error. Respond with 409 Conflict and a short page
saying the account is already verified, with a link to log in, or to
their profile if they are already logged in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The edit controller narrowed $this->model to EditModel with inline @var
tags, which only inform static analysis. Narrow it in one helper with an
instanceof check that throws InvalidModelException, as the views already
do, so a wrong model fails loudly instead of being trusted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The verification email said its link would only be available for 24
hours after registering, but nothing enforces that. Say instead that the
link is only kept for a limited time, and that a password reset still
verifies the account if it stops working. Resent emails now include the
same note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user list's edit button used 📝, the same as profile and content edit
links, while the profile page's Edit user button used 🛡️. Use 🛡️ for both
links to /user/edit, which marks it as a staff tool apart from editing a
profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each social link on a profile gets a dot, border, and background tint in
its platform's brand color, with the service name in a lightened brand
color that keeps at least 4.5:1 contrast on the dark theme. GitHub uses
its light dark-mode foreground since its black brand color would not show,
Instagram's dot uses its gradient, and websites stay neutral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Profile::FIELDS lists every editable profile field with its getter and
setter, and getField() and setField() access a field by name, so editors
no longer keep their own copies of that mapping. The user editor now uses
them.

A new Profile also now starts with every field set to null. allocate()
returned before initializing them when there was no user id to look up,
so reading a field of a new profile threw an uninitialized property error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/user/update now matches the staff user editor: account, avatar, and
public profile cards, errors shown under each field, and one summary
alert instead of a success or error alert per field. The confirm email
field only appears once the email address is changed.

Saving validates every field first, then saves all of them or none.
Before, each field was committed as it was checked, so one bad field
could leave a save half-applied. It also fixes:
- Taken usernames and email addresses are now reported on the field,
  instead of failing the database insert with a 500.
- The minimum username length was never enforced, because ?? bound to
  the whole comparison.
- USER_EDITED now records only the changed fields with their old and new
  values, instead of every field and an error for each.

The public profile inputs are now a template shared with the staff user
editor, with their labels kept in Profile::FIELDS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changing your email address on /user/update kept the account verified,
so it could move to an address nobody had confirmed. It now unverifies
the account and emails a verification link to the new address. The
current session continues, so a mistyped address can still be fixed, but
logging in again requires verifying it.

While the account is unverified, the page says so and offers a button to
resend the verification email. Verification emails a user sends
themselves, by changing their email or resending, are limited to one per
VerificationEmail::COOLDOWN seconds, so the page cannot be used to send
mail to arbitrary addresses in bulk.

VerificationEmail::send() now takes a reason instead of a resend flag,
and the email's opening line says why it was sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The None, Default, and Staff preset buttons only work through the page's
script, so without JavaScript they were buttons that did nothing. They
and their note are now hidden until the script shows them. Every form on
the page still works without JavaScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When staff change a user's email address on /user/edit, the account is
now unverified, whatever the Verified box says, and a verification link
is sent to the new address, as on /user/update. A change of letter case
alone is still treated as the same address. A failed send is reported
on the page and the resend button can be used to try again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The button on your profile page read "Password" while the same link on
the profile update page reads "Change password". Both now use the
latter, matching the verb-first captions of the buttons beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reset password email was built and sent inside the ResetPassword
controller. It now lives in ResetPasswordEmail, like VerificationEmail,
so staff can send it too. It takes a reason that will change its
wording; the controller sends the existing email unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finishing a password reset called setVerified(true) to clear the token,
but setVerified() does nothing for an account that is already verified,
so the token was kept and the same link could reset the password again.
The token is now cleared explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When sent with REASON_STAFF_RESET, the email says a staff member reset
the password and logged the user out, and points to Recover Password if
the link stops working, instead of telling them to ignore it if they
did not ask. The plain text version also gets blank lines around the
link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An account with no password hash can never log in, and was told its
password was incorrect. It is now told the password must be reset, and
how to do it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Staff can reset another user's password from the Status card on
/user/edit. It clears the password so it stops working immediately,
logs the user out of every session, and emails them a link to choose a
new one. A confirmation box must be ticked first, which works without
JavaScript. The reset is logged as USER_PASSWORD_RESET by the staff
member, with how many sessions were expired and whether the email was
sent. Staff cannot reset their own password here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logging out tried to delete the session record, but compared the binary
session id with its hexadecimal form, so nothing was ever removed and
the session stayed valid server-side until it expired a month later.
Logging out now sets the session's expiry to the current time, and
staff password resets do the same for every active session of the user.
Session records are no longer deleted by the site; removing expired
ones is left to a separate cleanup job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session lookup compared expires_datetime = NULL, which is never
true in SQL, so a session with no expiry date was treated as invalid.
It now uses IS NULL, matching how expiring a session already treats a
missing date as still active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@carlbennett carlbennett added the bug label Nov 4, 2026
@carlbennett carlbennett added enhancement bnetdocs-phoenix php Pull requests that update php code labels Nov 4, 2026
@carlbennett carlbennett self-assigned this Nov 4, 2026
@carlbennett
carlbennett merged commit 812fbc8 into develop Nov 4, 2026
20 checks passed
@carlbennett
carlbennett deleted the user-admin-edit branch November 4, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bnetdocs-phoenix bug enhancement php Pull requests that update php code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant