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
+28
-14
@@ -7,8 +7,11 @@ GLOSSARY.md).
|
||||
salt and iteration count (stdlib, no new package on this locked-down
|
||||
PC); never stored, logged or shown in plain. At least 10 characters,
|
||||
not the username.
|
||||
* **Roles**: Admin (everything, and managing Portal Users), Operator (see
|
||||
everything, Start / Stop a Run), Viewer (see only).
|
||||
* **Roles** (2026-10-27): Super Admin (everything; manages Admins and
|
||||
Viewers), Admin (everything; manages Viewers only), Viewer (sees the
|
||||
claims and Runs; changes nothing, no Portal Users or Sign-in history).
|
||||
Nobody changes a Super Admin from the Portal: one is made only with
|
||||
`toolsdd_portal_user.py` on this PC.
|
||||
* **Lockout**: 5 wrong passwords in a row lock the user for 15 minutes.
|
||||
An unknown username is told the same as a wrong password, and costs the
|
||||
same time (a hash is still computed), so the page does not reveal which
|
||||
@@ -30,8 +33,11 @@ import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ROLES = ("Admin", "Operator", "Viewer")
|
||||
CAN_RUN = ("Admin", "Operator")
|
||||
ROLES = ("Super Admin", "Admin", "Viewer")
|
||||
CAN_RUN = ("Super Admin", "Admin")
|
||||
CAN_ADMIN = ("Super Admin", "Admin") # Portal Users, Sign-in history
|
||||
# The roles each role may add, give, reset, disable or enable.
|
||||
MANAGES = {"Super Admin": ("Admin", "Viewer"), "Admin": ("Viewer",)}
|
||||
MIN_PASSWORD = 10
|
||||
ITERATIONS = 600_000
|
||||
LOCK_AFTER = 5
|
||||
@@ -250,6 +256,8 @@ class Accounts:
|
||||
problem = password_problem(password, name)
|
||||
if problem:
|
||||
raise UserError(problem)
|
||||
if by:
|
||||
self._check_manages(by, role)
|
||||
if self.store.get(name):
|
||||
raise UserError(f"{name} already exists")
|
||||
pw_hash, salt, iterations = hash_password(password)
|
||||
@@ -280,6 +288,7 @@ class Accounts:
|
||||
|
||||
def reset_password(self, username, new, by):
|
||||
user = self._existing(username)
|
||||
self._check_manages(by, user["role"])
|
||||
self._set_password(user, new, must_change=True)
|
||||
self.store.record(user["username"], "password_reset", by)
|
||||
|
||||
@@ -287,18 +296,18 @@ class Accounts:
|
||||
user = self._existing(username)
|
||||
if role not in ROLES:
|
||||
raise UserError(f"Role must be one of {', '.join(ROLES)}")
|
||||
if user["role"] == "Admin" and role != "Admin":
|
||||
self._keep_an_admin(user["username"])
|
||||
self._check_manages(by, user["role"])
|
||||
self._check_manages(by, role)
|
||||
self.store.update(user["username"], role=role,
|
||||
changed_at=self._now().replace(microsecond=0))
|
||||
self.store.record(user["username"], f"role_{role.lower()}", by)
|
||||
self.store.record(user["username"],
|
||||
"role_" + role.lower().replace(" ", "_"), by)
|
||||
|
||||
def set_disabled(self, username, disabled: bool, by):
|
||||
user = self._existing(username)
|
||||
if disabled and user["username"] == by:
|
||||
raise UserError("You cannot disable yourself")
|
||||
if disabled and user["role"] == "Admin":
|
||||
self._keep_an_admin(user["username"])
|
||||
self._check_manages(by, user["role"])
|
||||
self.store.update(user["username"], disabled=disabled,
|
||||
failed_count=0, locked_until=None,
|
||||
changed_at=self._now().replace(microsecond=0))
|
||||
@@ -321,8 +330,13 @@ class Accounts:
|
||||
raise UserError(f"No user {username}")
|
||||
return user
|
||||
|
||||
def _keep_an_admin(self, leaving):
|
||||
others = [u for u in self.store.list() if u["role"] == "Admin"
|
||||
and not u["disabled"] and u["username"] != leaving]
|
||||
if not others:
|
||||
raise UserError("The Portal needs at least one active Admin")
|
||||
def _check_manages(self, by, role):
|
||||
"""`by` (a signed-in user) may add or change a `role` user — read
|
||||
from the store, not the session, so a role just taken away counts."""
|
||||
actor = self.store.get(clean_username(by))
|
||||
allowed = MANAGES.get(actor["role"], ()) if actor else ()
|
||||
if role not in allowed:
|
||||
who = actor["role"] if actor else "Viewer"
|
||||
can = " and ".join(allowed) or "nobody"
|
||||
article = "An" if who[0] in "AEIOU" else "A"
|
||||
raise UserError(f"{article} {who} can add or change {can} only")
|
||||
Reference in new issue
Block a user