fix(account): one form and one Save for your account; the same for an account's page in Users
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m8s
CI / unit (push) Successful in 1m36s
CI / publish (push) Skipped

Operator review of the Account page: two sections with two different
save buttons, one stuck to the next card and one beside a squeezed hint.
- Account: time zone and Email are one form; POST /account/ saves both,
  nothing unless all of it is valid. /account/email is gone.
- Users › account: Name and Email are one form; the rename route also
  saves the email switches. /settings/users/<id>/email is gone.
- The Email section is a plain section now (email_section); its hint sits
  under the switches, names the zone as the picker does, and links
  Notifications only for an admin.
- apply_email_form leaves a switch the form doesn't carry as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 16:05:41 -04:00
co-authored by Claude Opus 5.5
parent 31e32c173c
commit beae8a33f3
8 changed files with 102 additions and 106 deletions
+12 -19
View File
@@ -156,7 +156,6 @@ async def user_create():
_DONE = {"created": "Account added.", "saved": "Saved.", "role": "Role changed.",
"email": "Email settings saved.",
"password": "Password set. It works from their next sign-in.",
"disabled": "Account disabled. They are signed out at their next click.",
"enabled": "Account enabled."}
@@ -190,18 +189,27 @@ async def user_detail(user_id: str):
@users_bp.post("/<user_id>")
@require_role(UserRole.admin)
async def user_save(user_id: str):
"""Rename: the username and email, for any account."""
"""The page's one form, for any account: its username and email, and
whether it is emailed alerts and the weekly report (milestone 495). The
address may be a provider's placeholder, so it isn't held to mail's test
here; the account just gets no email until it is real."""
form = await request.form
username = (form.get("username") or "").strip()
email = (form.get("email") or "").strip()
if not username or not email or len(username) > 64 or len(email) > 255:
return await _user_page(user_id, "A username (up to 64 characters) and an email are both needed.", 400)
switches = {k: v for k, v in form.items() if k != "email"}
try:
async with current_app.db_sessionmaker() as db:
async with db.begin():
user = await _target(db, user_id)
emailed, error = mail.apply_email_form(user, switches)
if error:
raise ValueError(error)
before = {"username": user.username, "email": user.email}
user.username, user.email = username, email
except ValueError as exc:
return await _user_page(user_id, str(exc), 400)
except IntegrityError:
return await _user_page(user_id, f"Another account already uses the username {username} or that email.", 400)
after = {"username": username, "email": email}
@@ -211,6 +219,8 @@ async def user_save(user_id: str):
| {f"old_{k}": before[k] for k in after if before[k] != after[k]})
if user_id == session.get("user_id"):
session["username"] = username
if emailed:
await audit_request("user.email_settings", "user", user_id, {"username": username} | emailed)
return redirect(f"/settings/users/{user_id}?done=saved")
@@ -240,23 +250,6 @@ async def user_role(user_id: str):
return redirect(f"/settings/users/{user_id}?done=role")
@users_bp.post("/<user_id>/email")
@require_role(UserRole.admin)
async def user_email(user_id: str):
"""Whether this account is emailed alerts and the weekly report (milestone
495); its address is edited with its name."""
form = {k: v for k, v in (await request.form).items() if k != "email"}
async with current_app.db_sessionmaker() as db:
async with db.begin():
user = await _target(db, user_id)
changes, error = mail.apply_email_form(user, form)
if error:
return await _user_page(user_id, error, 400)
if changes:
await audit_request("user.email_settings", "user", user_id, {"username": user.username} | changes)
return redirect(f"/settings/users/{user_id}?done=email")
@users_bp.post("/<user_id>/password")
@require_role(UserRole.admin)
async def user_password(user_id: str):