Sign-in page redesign and Portal roles Super Admin / Admin / Viewer
Sign-in: brand panel, show / hide password, Caps Lock warning, moving background, tab icon. Roles: Super Admin manages Admins and Viewers, Admin manages Viewers only (and can Start / Stop, see Sign-ins), Viewer sees claims and Runs only. create_tables.sql moves an old table. 71 test files, 1220 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
120fd6808a
commit
ce9bdbd974
11 files changed
+408
-99
No files matched your search
+71
-24
@@ -12,8 +12,10 @@ user store and the fake repository / runner (no SQL Server).
|
||||
* Wrong password / lock / disabled read as such; 30 minutes idle signs
|
||||
out (and is recorded).
|
||||
* A user an Admin made must change the password before anything else.
|
||||
* Roles: a Viewer sees no Start / Stop and is refused them; only an
|
||||
Admin reaches Portal Users and the Sign-in history.
|
||||
* Roles (2026-10-27): a Viewer sees no Start / Stop and is refused them;
|
||||
only a Super Admin or an Admin reaches Portal Users and the Sign-in
|
||||
history; an Admin adds and changes Viewers only, a Super Admin Admins
|
||||
and Viewers, and nobody changes a Super Admin from the Portal.
|
||||
* Started By / Stopped By reach the runner as the signed-in username.
|
||||
* Admin changes take effect on the user's open session at once.
|
||||
"""
|
||||
@@ -47,11 +49,11 @@ def case(fn):
|
||||
|
||||
|
||||
def _portal(runner=None, clock=None):
|
||||
"""(app, store, runner) with users asha (Admin), olu (Operator) and
|
||||
"""(app, store, runner) with users sam (Super Admin), asha (Admin) and
|
||||
vee (Viewer), all able to sign in straight away."""
|
||||
store = FakeUserStore()
|
||||
acc = portal_users.Accounts(store)
|
||||
for name, role in (("asha", "Admin"), ("olu", "Operator"),
|
||||
for name, role in (("sam", "Super Admin"), ("asha", "Admin"),
|
||||
("vee", "Viewer")):
|
||||
acc.create_user(name, name.title(), role, PW)
|
||||
runner = runner or FakeRunner()
|
||||
@@ -136,7 +138,7 @@ def a_wrong_password_says_so_and_five_lock():
|
||||
@case
|
||||
def sign_out_ends_the_session():
|
||||
app, store, _ = _portal()
|
||||
c, _ = _sign_in(app, "olu")
|
||||
c, _ = _sign_in(app, "asha")
|
||||
assert c.post("/signout", headers=ORIGIN,
|
||||
data={"token": "wrong"}).status_code == 403
|
||||
r = c.post("/signout", headers=ORIGIN, data=_form(app),
|
||||
@@ -144,7 +146,7 @@ def sign_out_ends_the_session():
|
||||
assert r.status_code == 303 and r.headers["location"] == "/signin"
|
||||
c.cookies.clear()
|
||||
assert c.get("/days", follow_redirects=False).status_code == 303
|
||||
assert ("olu", "sign_out", "") in store.events
|
||||
assert ("asha", "sign_out", "") in store.events
|
||||
|
||||
|
||||
@case
|
||||
@@ -203,24 +205,27 @@ def a_viewer_sees_no_start_and_is_refused_it():
|
||||
@case
|
||||
def started_by_and_stopped_by_are_the_signed_in_user():
|
||||
app, _, runner = _portal()
|
||||
c, _ = _sign_in(app, "olu")
|
||||
c, _ = _sign_in(app, "asha")
|
||||
assert "const CAN_RUN = true;" in c.get("/").text
|
||||
h = {**ORIGIN, "X-Run-Token": app.state.run_token}
|
||||
assert c.post("/api/bot/start", headers=h).status_code == 200
|
||||
assert runner.started_by == "olu"
|
||||
assert runner.started_by == "asha"
|
||||
assert c.post("/api/bot/stop", headers=h).status_code == 200
|
||||
assert runner.stopped_by == "olu"
|
||||
assert c.get("/admin/users").status_code == 403, "Operator is no Admin"
|
||||
assert runner.stopped_by == "asha"
|
||||
|
||||
|
||||
@case
|
||||
def the_header_names_the_user_and_admin_links_are_admin_only():
|
||||
app, _, _ = _portal()
|
||||
a, _ = _sign_in(app, "asha")
|
||||
page = a.get("/runs").text
|
||||
assert "<b>Asha</b> · Admin" in page and 'href="/admin/users"' in page
|
||||
o, _ = _sign_in(app, "olu")
|
||||
assert 'href="/admin/users"' not in o.get("/runs").text
|
||||
for name, role in (("sam", "Super Admin"), ("asha", "Admin")):
|
||||
a, _ = _sign_in(app, name)
|
||||
page = a.get("/runs").text
|
||||
assert f"<b>{name.title()}</b> · {role}" in page, name
|
||||
assert 'href="/admin/users"' in page and 'href="/admin/signins"' in page
|
||||
v, _ = _sign_in(app, "vee")
|
||||
page = v.get("/runs").text
|
||||
assert 'href="/admin/users"' not in page
|
||||
assert 'href="/admin/signins"' not in page
|
||||
|
||||
|
||||
@case
|
||||
@@ -228,9 +233,13 @@ def an_admin_adds_a_user_who_then_must_change():
|
||||
app, store, _ = _portal()
|
||||
c, _ = _sign_in(app, "asha")
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
app, username="Ravi", display_name="Ravi K", role="Operator",
|
||||
app, username="Ravi", display_name="Ravi K", role="Viewer",
|
||||
password="temp-password-1"))
|
||||
assert r.status_code == 200 and "ravi added" in r.text, r.text[:300]
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
app, username="kim", display_name="", role="Admin",
|
||||
password="temp-password-1"))
|
||||
assert r.status_code == 400 and "An Admin can add or change Viewer only" in r.text and "kim" not in store.users
|
||||
assert store.users["ravi"]["must_change"] is True
|
||||
assert ("ravi", "user_created", "asha") in store.events
|
||||
r = c.post("/admin/users", headers=ORIGIN, data=_form(
|
||||
@@ -242,20 +251,42 @@ def an_admin_adds_a_user_who_then_must_change():
|
||||
@case
|
||||
def admin_changes_reach_an_open_session_at_once():
|
||||
app, store, _ = _portal()
|
||||
admin, _ = _sign_in(app, "asha")
|
||||
olu, _ = _sign_in(app, "olu")
|
||||
admin, _ = _sign_in(app, "sam")
|
||||
olu, _ = _sign_in(app, "asha")
|
||||
vee, _ = _sign_in(app, "vee")
|
||||
admin.post("/admin/users/olu/role", headers=ORIGIN,
|
||||
admin.post("/admin/users/asha/role", headers=ORIGIN,
|
||||
data=_form(app, role="Viewer"))
|
||||
assert "const CAN_RUN = false;" in olu.get("/").text, "role now Viewer"
|
||||
admin.post("/admin/users/vee/disable", headers=ORIGIN, data=_form(app))
|
||||
assert vee.get("/days", follow_redirects=False).status_code == 303
|
||||
_, r = _sign_in(app, "vee")
|
||||
assert r.status_code == 401 and "disabled" in r.text
|
||||
admin.post("/admin/users/olu/reset", headers=ORIGIN,
|
||||
admin.post("/admin/users/asha/reset", headers=ORIGIN,
|
||||
data=_form(app, password="temp-password-2"))
|
||||
assert olu.get("/days", follow_redirects=False).status_code == 303
|
||||
assert store.users["olu"]["must_change"] is True
|
||||
assert store.users["asha"]["must_change"] is True
|
||||
|
||||
|
||||
@case
|
||||
def the_users_page_offers_only_what_each_role_may_change():
|
||||
"""An Admin's page: add a Viewer, change Viewer rows only. A Super
|
||||
Admin's: Admin or Viewer, never Super Admin; no row changes a Super
|
||||
Admin."""
|
||||
app, store, _ = _portal()
|
||||
a, _ = _sign_in(app, "asha")
|
||||
page = a.get("/admin/users").text
|
||||
assert "<option selected>Viewer</option>" in page
|
||||
assert "<option>Admin</option>" not in page, "an Admin adds Viewers only"
|
||||
assert 'action="/admin/users/vee/reset"' in page
|
||||
for name in ("sam", "asha"):
|
||||
assert f'action="/admin/users/{name}/' not in page, name
|
||||
s, _ = _sign_in(app, "sam")
|
||||
page = s.get("/admin/users").text
|
||||
assert "<option>Admin</option>" in page and "Super Admin</option>" not in page
|
||||
assert 'action="/admin/users/asha/role"' in page
|
||||
assert 'action="/admin/users/sam/' not in page, "nobody changes sam here"
|
||||
r = a.post("/admin/users/sam/disable", headers=ORIGIN, data=_form(app))
|
||||
assert r.status_code == 400 and not store.users["sam"]["disabled"]
|
||||
|
||||
|
||||
@case
|
||||
@@ -271,9 +302,10 @@ def admin_forms_need_the_token():
|
||||
def the_sign_in_history_lists_events_for_an_admin():
|
||||
app, _, _ = _portal()
|
||||
_sign_in(app, "vee", "wrong-password-x")
|
||||
c, _ = _sign_in(app, "asha")
|
||||
page = c.get("/admin/signins").text
|
||||
assert "wrong password" in page and "vee" in page
|
||||
for name in ("sam", "asha"):
|
||||
c, _ = _sign_in(app, name)
|
||||
page = c.get("/admin/signins").text
|
||||
assert "wrong password" in page and "vee" in page, name
|
||||
|
||||
|
||||
@case
|
||||
@@ -285,6 +317,21 @@ def with_no_users_the_sign_in_page_says_how_to_add_one():
|
||||
assert "add_portal_user.py" in page
|
||||
|
||||
|
||||
@case
|
||||
def the_sign_in_page_says_what_the_portal_is_and_helps_with_the_password():
|
||||
"""Operator, 2026-10-26: the Sign-in page was plain. It now introduces
|
||||
the Portal, can show the typed password, warns about Caps Lock, and
|
||||
says the idle and lockout rules."""
|
||||
app, _, _ = _portal()
|
||||
page = TestClient(app, base_url=BASE).get("/signin").text
|
||||
assert 'class="auth-brand"' in page and "Charge Validation" in page
|
||||
assert 'id="pw-toggle"' in page and 'aria-controls="pw"' in page
|
||||
assert 'id="caps"' in page, "a Caps Lock warning"
|
||||
assert "30 minutes" in page and "15 minutes" in page
|
||||
assert 'name="username"' in page and 'id="pw" name="password"' in page
|
||||
assert '<link rel="icon"' in page, "a tab icon, no /favicon.ico 404"
|
||||
|
||||
|
||||
@case
|
||||
def no_signed_in_page_carries_an_inline_style():
|
||||
app, _, _ = _portal()
|
||||
|
||||
+67
-11
@@ -74,7 +74,7 @@ class Clock:
|
||||
def _accounts():
|
||||
store, clock = FakeUserStore(), Clock()
|
||||
acc = users.Accounts(store, now=clock)
|
||||
acc.create_user("Asha", "Asha R", "Admin", "first-password-1")
|
||||
acc.create_user("Asha", "Asha R", "Super Admin", "first-password-1")
|
||||
return acc, store, clock
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ def a_good_sign_in_clears_the_wrong_count():
|
||||
@case
|
||||
def an_admin_made_user_must_change_and_a_reset_does_the_same():
|
||||
acc, store, _ = _accounts()
|
||||
acc.create_user("ravi", "Ravi", "Operator", "temp-password-1", by="asha")
|
||||
acc.create_user("ravi", "Ravi", "Viewer", "temp-password-1", by="asha")
|
||||
assert store.users["ravi"]["must_change"] is True
|
||||
assert store.users["ravi"]["created_by"] == "asha"
|
||||
acc.change_password("ravi", "temp-password-1", "ravis-own-pass")
|
||||
@@ -199,24 +199,80 @@ def a_disabled_user_cannot_sign_in_and_is_never_deleted():
|
||||
|
||||
|
||||
@case
|
||||
def the_last_admin_stays_and_nobody_disables_themselves():
|
||||
acc, _, _ = _accounts()
|
||||
for fn in (lambda: acc.set_role("asha", "Viewer", by="asha"),
|
||||
lambda: acc.set_disabled("asha", True, by="asha")):
|
||||
def an_admin_adds_and_changes_viewers_only():
|
||||
"""Operator, 2026-10-27: an Admin creates users, and users means
|
||||
Viewers."""
|
||||
acc, store, _ = _accounts()
|
||||
acc.create_user("ravi", "Ravi", "Admin", "temp-password-1", by="asha")
|
||||
acc.create_user("vee", "Vee", "Viewer", "temp-password-1", by="ravi")
|
||||
acc.reset_password("vee", "temp-password-2", by="ravi")
|
||||
acc.set_disabled("vee", True, by="ravi")
|
||||
acc.set_disabled("vee", False, by="ravi")
|
||||
acc.create_user("mani", "Mani", "Admin", "temp-password-1", by="asha")
|
||||
for fn in (lambda: acc.create_user("kim", "K", "Admin", "temp-password-1",
|
||||
by="ravi"),
|
||||
lambda: acc.set_role("vee", "Admin", by="ravi"),
|
||||
lambda: acc.set_disabled("mani", True, by="ravi"),
|
||||
lambda: acc.reset_password("mani", "temp-password-3",
|
||||
by="ravi"),
|
||||
lambda: acc.set_role("asha", "Viewer", by="ravi"),
|
||||
lambda: acc.set_disabled("asha", True, by="ravi")):
|
||||
try:
|
||||
fn()
|
||||
raise AssertionError("an Admin went past Viewers")
|
||||
except users.UserError:
|
||||
pass
|
||||
assert "kim" not in store.users and store.users["vee"]["role"] == "Viewer"
|
||||
|
||||
|
||||
@case
|
||||
def the_super_admin_runs_admins_and_nobody_touches_a_super_admin():
|
||||
acc, store, _ = _accounts()
|
||||
assert store.users["asha"]["role"] == "Super Admin"
|
||||
acc.create_user("ravi", "Ravi", "Admin", "temp-password-1", by="asha")
|
||||
acc.set_role("ravi", "Viewer", by="asha")
|
||||
acc.set_role("ravi", "Admin", by="asha")
|
||||
acc.set_disabled("ravi", True, by="asha")
|
||||
assert ("ravi", "role_admin", "asha") in store.events
|
||||
for fn in (lambda: acc.create_user("boss", "B", "Super Admin",
|
||||
"temp-password-1", by="asha"),
|
||||
lambda: acc.set_role("asha", "Viewer", by="asha"),
|
||||
lambda: acc.set_disabled("asha", True, by="asha"),
|
||||
lambda: acc.set_role("ravi", "Super Admin", by="asha"),
|
||||
lambda: acc.create_user("bob", "B", "Viewer",
|
||||
"temp-password-1", by="nobody")):
|
||||
try:
|
||||
fn()
|
||||
raise AssertionError("allowed")
|
||||
except users.UserError:
|
||||
pass
|
||||
acc.create_user("ravi", "Ravi", "Admin", "temp-password-1", by="asha")
|
||||
acc.set_role("asha", "Operator", by="ravi")
|
||||
assert store.users["asha"]["role"] == "Super Admin"
|
||||
acc.create_user("boss", "B", "Super Admin", "temp-password-1")
|
||||
assert store.users["boss"]["role"] == "Super Admin", "the PC tool may"
|
||||
|
||||
|
||||
@case
|
||||
def the_old_operator_role_is_gone():
|
||||
assert users.ROLES == ("Super Admin", "Admin", "Viewer"), users.ROLES
|
||||
assert users.CAN_RUN == ("Super Admin", "Admin")
|
||||
acc, _, _ = _accounts()
|
||||
try:
|
||||
acc.set_disabled("ravi", True, by="asha")
|
||||
raise AssertionError("disabled the last Admin")
|
||||
acc.create_user("olu", "Olu", "Operator", "temp-password-1")
|
||||
raise AssertionError("Operator accepted")
|
||||
except users.UserError:
|
||||
pass
|
||||
|
||||
|
||||
@case
|
||||
def the_sql_table_allows_the_same_roles_and_moves_old_ones():
|
||||
sql = (pathlib.Path(__file__).resolve().parent.parent / "tools" / "sql"
|
||||
/ "create_tables.sql").read_text(encoding="utf-8")
|
||||
check = "CHECK (role IN ('Super Admin', 'Admin', 'Viewer'))"
|
||||
assert sql.count(check) == 2, "the new table and the old one moved"
|
||||
first = sql.index("SET role = 'Super Admin' WHERE role = 'Admin'")
|
||||
assert first < sql.index("SET role = 'Admin' WHERE role = 'Operator'"), "Admins move up before Operators take their place"
|
||||
|
||||
|
||||
@case
|
||||
def sessions_time_out_after_thirty_idle_minutes():
|
||||
clock = Clock()
|
||||
@@ -234,7 +290,7 @@ def sessions_time_out_after_thirty_idle_minutes():
|
||||
@case
|
||||
def a_users_sessions_can_be_ended_and_refreshed():
|
||||
s = users.Sessions()
|
||||
t1 = s.new({"username": "ravi", "role": "Operator"})
|
||||
t1 = s.new({"username": "ravi", "role": "Admin"})
|
||||
t2 = s.new({"username": "asha", "role": "Admin"})
|
||||
s.refresh_user({"username": "ravi", "role": "Viewer"})
|
||||
assert s.get(t1)[0]["role"] == "Viewer"
|
||||
|
||||
Reference in new issue
Block a user