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
+22 -29
View File
@@ -1,5 +1,6 @@
"""The signed-in user's own account (milestone 490): the time zone every
page writes its times in. Any role reaches it, for themselves only."""
page writes its times in, and their email (milestone 495). Any role reaches
it, for themselves only."""
from __future__ import annotations
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
@@ -26,48 +27,34 @@ async def _me(db) -> User:
@account_bp.get("/")
@require_role(UserRole.viewer)
async def account(error: str | None = None, status: int = 200, email_error: str | None = None):
async def account(error: str | None = None, status: int = 200):
from steward.core.mail import role_defaults
from steward.core.settings import get_all_settings
async with current_app.db_sessionmaker() as db:
user = await _me(db)
defaults = role_defaults(user, await get_all_settings(db))
tz = formatting.zone(user.time_zone)
zone_name = user.time_zone or "UTC"
return await render_template(
"account/index.html", user=user, source=_SOURCES.get(user.auth_source, user.auth_source),
zone_name=user.time_zone or "UTC", zone_label=formatting.tz_label(tz),
zones=formatting.zones_by_region(), defaults=defaults, error=error,
email_error=email_error), status
zone_name=zone_name, zone_title=zone_name.split("/", 1)[-1].replace("_", " "),
zone_label=formatting.tz_label(tz), zones=formatting.zones_by_region(),
defaults=defaults, error=error), status
@account_bp.post("/")
@require_role(UserRole.viewer)
async def account_save():
from steward.core.audit import log_audit
name = ((await request.form).get("time_zone") or "").strip()
if name not in formatting.zone_names():
return await account(f"{name or 'That'} is not a time zone Steward knows.", 400)
async with current_app.db_sessionmaker() as db:
async with db.begin():
user = await _me(db)
before = user.time_zone
user.time_zone = name
session["tz"] = name
if before != name:
await log_audit(current_app, user.id, user.username, "account.time_zone",
detail={"time_zone": name, "was": before or "UTC"})
return redirect("/account/")
@account_bp.post("/email")
@require_role(UserRole.viewer)
async def account_email():
"""Your address and whether you're emailed alerts and the weekly report
(milestone 495)."""
"""The page's one form: your time zone, your address and whether you're
emailed alerts and the weekly report (milestone 495). Nothing is saved
unless all of it is valid."""
from sqlalchemy.exc import IntegrityError
from steward.core.audit import log_audit
from steward.core.mail import apply_email_form
form = await request.form
name = (form.get("time_zone") or "").strip()
if name not in formatting.zone_names():
return await account(f"{name or 'That'} is not a time zone Steward knows.", 400)
try:
async with current_app.db_sessionmaker() as db:
async with db.begin():
@@ -75,13 +62,19 @@ async def account_email():
changes, error = apply_email_form(user, form)
if error:
raise ValueError(error)
before = user.time_zone
user.time_zone = name
except ValueError as exc:
return await account(status=400, email_error=str(exc))
return await account(str(exc), 400)
except IntegrityError:
return await account(status=400, email_error="Another account already uses that address.")
return await account("Another account already uses that address.", 400)
session["tz"] = name
if before != name:
await log_audit(current_app, user.id, user.username, "account.time_zone",
detail={"time_zone": name, "was": before or "UTC"})
if changes:
await log_audit(current_app, user.id, user.username, "account.email", detail=changes)
return redirect("/account/#email")
return redirect("/account/")
@account_bp.post("/time-zone/adopt")
+5 -3
View File
@@ -113,8 +113,8 @@ def role_defaults(user, settings: dict) -> dict[str, bool]:
def apply_email_form(user, form) -> tuple[dict, str | None]:
"""Apply an email form to `user`: "email" (when the form has it) and
"notify_alerts" / "notify_report" ("", "on" or "off"). Returns
"""Apply an email form to `user`: "email" and "notify_alerts" /
"notify_report" ("", "on" or "off"), each only when the form has it. Returns
({field: [before, after]} for what changed, None), or ({}, why) having
changed nothing."""
wanted = {}
@@ -124,7 +124,9 @@ def apply_email_form(user, form) -> tuple[dict, str | None]:
return {}, "That isn't an email address mail can reach."
wanted["email"] = address
for kind in KINDS:
value = form.get(f"notify_{kind}", "")
if f"notify_{kind}" not in form:
continue
value = form.get(f"notify_{kind}")
if value not in CHOICES:
return {}, "Pick On, Off or the role's default."
wanted[f"notify_{kind}"] = CHOICES[value]
+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):
+33 -31
View File
@@ -1,34 +1,36 @@
{# The email form for one account (milestone 495): its address, and whether
it gets alert emails and the weekly report. Used by the Account page (your
own) and an account's page in Settings › Users (an admin's view).
`defaults` is {kind: bool}, the account's role defaults. #}
{% macro email_choices(user, defaults, action, error=none, with_address=true) -%}
<form method="post" action="{{ action }}" class="form-page" id="email">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="email-title">
<h2 id="email-title" class="section-title">Email</h2>
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
{% if with_address %}
{# The Email section for one account (milestone 495): its address, and whether
it gets alert emails and the weekly report. A section of the page's one
form, saved by that form's Save: the Account page (your own) and an
account's page in Settings › Users (an admin's view, whose address sits
with its name). `defaults` is {kind: bool}, the account's role defaults;
`zone_note` says whose zone the emails' times are in. #}
{% macro email_section(user, defaults, zone_note, with_address=true, unreachable=false) -%}
<section class="card form-section" aria-labelledby="email-title" id="email">
<h2 id="email-title" class="section-title">Email</h2>
{% if with_address %}
<div class="field">
<label class="field-label" for="email-address">Address</label>
<input class="input" id="email-address" type="email" name="email" maxlength="255" value="{{ user.email }}" required>
{% if user.auth_source != "local" %}<span class="field-hint">Your sign-in provider set this first; change it if mail doesn't reach it.</span>{% endif %}
</div>
{% elif unreachable %}
<p class="field-hint tone-warn">{{ user.email }} isn't an address mail can reach, so this account gets no email. Set a real one above.</p>
{% endif %}
<div class="form-pair">
{% for kind, label in (("alerts", "Alert emails"), ("report", "Weekly report")) %}
{% set choice = user["notify_" ~ kind] %}
<div class="field">
<label class="field-label" for="email-address">Address</label>
<input class="input" id="email-address" type="email" name="email" maxlength="255" value="{{ user.email }}" required>
{% if user.auth_source != "local" %}<span class="field-hint">Your sign-in provider set this first; change it if mail doesn't reach it.</span>{% endif %}
<label class="field-label" for="email-{{ kind }}">{{ label }}</label>
<select class="input" id="email-{{ kind }}" name="notify_{{ kind }}">
<option value=""{% if choice is none %} selected{% endif %}>As for {{ user.role.value }}s ({{ "on" if defaults[kind] else "off" }})</option>
<option value="on"{% if choice is sameas true %} selected{% endif %}>On</option>
<option value="off"{% if choice is sameas false %} selected{% endif %}>Off</option>
</select>
</div>
{% endif %}
<div class="form-pair">
{% for kind, label in (("alerts", "Alert emails"), ("report", "Weekly report")) %}
{% set choice = user["notify_" ~ kind] %}
<div class="field">
<label class="field-label" for="email-{{ kind }}">{{ label }}</label>
<select class="input" id="email-{{ kind }}" name="notify_{{ kind }}">
<option value=""{% if choice is none %} selected{% endif %}>As for {{ user.role.value }}s ({{ "on" if defaults[kind] else "off" }})</option>
<option value="on"{% if choice is sameas true %} selected{% endif %}>On</option>
<option value="off"{% if choice is sameas false %} selected{% endif %}>Off</option>
</select>
</div>
{% endfor %}
</div>
<span class="field-hint">Times in these emails are in {{ user.time_zone or "UTC" }}. A role's default is set in Settings › Notifications.</span>
<button type="submit" class="btn btn-ghost">Save email settings</button>
</section>
</form>
{% endfor %}
</div>
<p class="field-hint">Times in these emails are in {{ zone_note }}.
{% if session.user_role == "admin" %}Each role's default is set in Settings › <a href="/settings/notifications/">Notifications</a>.
{%- else %}An admin sets each role's default.{% endif %}</p>
</section>
{%- endmacro %}
+10 -7
View File
@@ -1,29 +1,32 @@
{# account/index.html — the signed-in user's own account (milestone 490): who
they are, and the time zone every page writes its times in. #}
they are, the time zone every page writes its times in, and their email
(milestone 495). One form, one Save. #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, zone_select %}
{% from "account/_email_choices.html" import email_choices with context %}
{% from "account/_email_choices.html" import email_section with context %}
{% block title %}Your account — Steward{% endblock %}
{% block content %}
{{ page_header("Your account") }}
<p class="page-verdict">
Signed in as <strong>{{ user.username }}</strong> ({{ user.role.value }}) with {{ source }}
· times show in <strong>{{ zone_name }}</strong> ({{ zone_label }})
· times show in <strong>{{ zone_title }}</strong> ({{ zone_label }})
</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" action="/account/" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="acct-time">
<h2 id="acct-time" class="section-title">Time zone</h2>
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<div class="field">
<label class="field-label" for="acct-tz">Show times in</label>
{{ zone_select("time_zone", "acct-tz", zone_name, zones) }}
<span class="field-hint">Every page writes its times in this zone, on a 24-hour clock.</span>
</div>
</section>
<button type="submit" class="btn">Save</button>
</form>
{{ email_choices(user, defaults, "/account/email", email_error) }}
{{ email_section(user, defaults, "your time zone") }}
<div class="form-actions"><button type="submit" class="btn">Save</button></div>
</form>
{% endblock %}
+6 -5
View File
@@ -5,7 +5,7 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge %}
{% from "settings/_tabs.html" import settings_tabs %}
{% from "account/_email_choices.html" import email_choices with context %}
{% from "account/_email_choices.html" import email_section with context %}
{% block title %}{{ user.username }} — Users — Steward{% endblock %}
{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "/settings/users/"), (user.username, "")]) }}{% endblock %}
@@ -38,12 +38,13 @@
</div>
</div>
{% if not local %}<p class="field-hint">{{ source }} finds this account by its own id, not by name, so renaming it here does not stop it signing in.</p>{% endif %}
<button type="submit" class="btn">Save</button>
</section>
</form>
{% if not reachable %}<p class="field-hint tone-warn">{{ user.email }} isn't an address mail can reach, so this account gets no email. Set a real one above.</p>{% endif %}
{{ email_choices(user, defaults, "/settings/users/" ~ user.id ~ "/email", with_address=false) }}
{% set zone = (user.time_zone or "UTC").split("/", 1)[-1]|replace("_", " ") %}
{{ email_section(user, defaults, "this account's own zone (" ~ zone ~ ")", with_address=false, unreachable=not reachable) }}
<div class="form-actions"><button type="submit" class="btn">Save</button></div>
</form>
<section class="card form-section" aria-labelledby="u-role">
<h2 id="u-role" class="section-title">Role</h2>
+1 -1
View File
@@ -73,7 +73,7 @@ def test_the_browser_zone_is_adopted_once_and_the_account_page_changes_it(app):
assert out["adopt"] == {"adopted": True} and out["after_adopt"] == "America/New_York"
assert out["again"] == {"adopted": False}
page = out["page"]
assert "times show in <strong>America/New_York</strong> (E" in page # EDT or EST
assert "times show in <strong>New York</strong> (E" in page # EDT or EST
assert '<option value="America/New_York" selected>New York</option>' in page
assert "/account/time-zone/adopt" not in page # nothing left to adopt
assert out["save"] == (302, "/account/") and out["after_save"] == "Europe/London"
+13 -11
View File
@@ -174,25 +174,27 @@ def test_an_account_sets_its_own_address_and_switches_and_an_admin_can_too(app):
async def _go():
viewer, vid = await _client(app, role="viewer")
page = await (await viewer.get("/account/")).get_data(as_text=True)
assert 'action="/account/email"' in page and "As for viewers (on)" in page
assert page.count("<form") == 1 and 'name="notify_alerts"' in page and "As for viewers (on)" in page
resp = await viewer.post("/account/email", headers=CSRF, form={
"email": "nope@oidc", "notify_alerts": "off", "notify_report": ""})
mine = {"time_zone": "Europe/Paris", "notify_alerts": "off", "notify_report": ""}
resp = await viewer.post("/account/", headers=CSRF, form=mine | {"email": "nope@oidc"})
assert resp.status_code == 400 and "mail can reach" in await resp.get_data(as_text=True)
assert (await _user(app, vid)).time_zone is None # nothing saved from a bad form
address = f"v-{vid[:8]}@example.com"
assert (await viewer.post("/account/email", headers=CSRF, form={
"email": address, "notify_alerts": "off", "notify_report": ""})).status_code == 302
assert (await viewer.post("/account/", headers=CSRF, form=mine | {"email": address})).status_code == 302
me = await _user(app, vid)
assert (me.email, me.notify_alerts, me.notify_report) == (address, False, None)
assert [a for a, _ in await _audits(app, vid)] == ["account.email"]
assert (me.email, me.notify_alerts, me.notify_report, me.time_zone) == (address, False, None, "Europe/Paris")
assert [a for a, _ in await _audits(app, vid)] == ["account.time_zone", "account.email"]
admin, aid = await _client(app)
resp = await admin.post(f"/settings/users/{vid}/email", headers=CSRF,
form={"email": "ignored@example.com", "notify_alerts": "", "notify_report": "off"})
page = await (await admin.get(f"/settings/users/{vid}")).get_data(as_text=True)
assert 'name="notify_report"' in page and "own zone (Paris)" in page
resp = await admin.post(f"/settings/users/{vid}", headers=CSRF, form={
"username": me.username, "email": address, "notify_alerts": "", "notify_report": "off"})
assert resp.status_code == 302
me = await _user(app, vid)
assert (me.email, me.notify_alerts, me.notify_report) == (address, None, False) # address kept
assert [a for a, _ in await _audits(app, aid)] == ["user.email_settings"]
assert (me.email, me.notify_alerts, me.notify_report) == (address, None, False)
assert [a for a, _ in await _audits(app, aid)] == ["user.email_settings"] # not renamed
page = await (await admin.get("/settings/notifications/")).get_data(as_text=True)
assert "Who gets email" in page and 'name="notify.alerts.viewer"' in page and "Also send to" in page