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:
ManikandanandClaude Opus 5.5 committed 2026-10-27 13:45:44 +05:30
1 parent 120fd6808a
commit ce9bdbd974
11 files changed
+408 -99

No files matched your search

+28 -14
View File
@@ -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")