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

+14 -9
View File
@@ -54,8 +54,9 @@ from ecw_bot.core.run_events import run_id_of # noqa: E402
from .repo import SqlResultsRepo # noqa: E402
from . import run_status # noqa: E402
from . import runs as run_view # noqa: E402
from .users import (Accounts, CAN_RUN, ROLES, Sessions, # noqa: E402
SqlUserStore, UserError)
from .users import (Accounts, CAN_ADMIN, CAN_RUN, # noqa: E402
IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES, MANAGES,
Sessions, SqlUserStore, UserError)
from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402
NotRunning, StopFailed)
@@ -379,7 +380,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
"run_token": app.state.run_token, "crumbs": crumbs,
"categories": CATEGORIES, "user": user,
"can_run": bool(user and user["role"] in CAN_RUN),
"is_admin": bool(user and user["role"] == "Admin"),
"is_admin": bool(user and user["role"] in CAN_ADMIN),
**context})
@app.get("/", response_class=HTMLResponse)
@@ -548,7 +549,9 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
def _signin_page(request, error="", next_url="/", status_code=200):
page = _page(request, "signin.html", [["Sign in", None]],
error=error, next_url=_safe_next(next_url),
no_users=not _db(store.list))
no_users=not _db(store.list),
idle_minutes=IDLE_MINUTES, lock_after=LOCK_AFTER,
lock_minutes=LOCK_MINUTES)
page.status_code = status_code
return page
@@ -611,26 +614,28 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
sessions.refresh_user(updated)
return RedirectResponse("/", status_code=303)
# ── Portal Users (Admin) ─────────────────────────────────────────────
# ── Portal Users (Super Admin, Admin) ────────────────────────────────
def _users_page(request, error="", message="", status_code=200):
page = _page(request, "users.html",
[["Dashboard", "/"], ["Portal Users", None]],
users=_db(store.list), roles=ROLES, error=error,
users=_db(store.list),
roles=MANAGES.get(request.state.user["role"], ()),
error=error,
message=message)
page.status_code = status_code
return page
@app.get("/admin/users", response_class=HTMLResponse)
def users_page(request: Request, msg: str = ""):
_need(request, ("Admin",))
_need(request, CAN_ADMIN)
return _users_page(request, message=msg[:200])
def _admin_change(request, token, done, fn, *args):
"""One Admin change: checked, made, then back to the list with
`done` said — or the list again with the problem."""
_check_token(request, token)
me = _need(request, ("Admin",))["username"]
me = _need(request, CAN_ADMIN)["username"]
try:
_db(fn, *args, by=me)
except UserError as exc:
@@ -686,7 +691,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
@app.get("/admin/signins", response_class=HTMLResponse)
def signins_page(request: Request):
_need(request, ("Admin",))
_need(request, CAN_ADMIN)
return _page(request, "signins.html",
[["Dashboard", "/"], ["Sign-in history", None]],
events=_db(store.history))
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="run-token" content="{{ run_token }}">
<title>{% block title %}Charge Validation{% endblock %} · eCW</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2312355b'/%3E%3Ctext x='16' y='21' font-family='Segoe UI,Arial' font-size='13' font-weight='800' text-anchor='middle' fill='%23fff'%3ECV%3C/text%3E%3C/svg%3E">
{% block head %}{% endblock %}
<style>
/* Navy & blue (operator, 2026-10-19): navy header, white cards on light
+143 -18
View File
@@ -1,25 +1,150 @@
{% extends "base.html" %}
{% block title %}Sign in{% endblock %}
{% block style %}
.signin { max-width: 380px; margin: 40px auto; }
.signin label { display: block; margin: 0 0 12px; font-weight: 600; }
.signin input { display: block; width: 100%; margin-top: 4px; }
.signin button { width: 100%; margin-top: 6px; }
/* Operator, 2026-10-26: the Portal's front door — what it is on the
left, the form on the right; one column on a phone. */
/* A slow-moving navy-to-blue background, this page only. */
body { background: linear-gradient(120deg, #0f2a47, #12355b 35%,
#1d4f86 65%, #1565c0);
background-size: 220% 220%; animation: drift 18s ease infinite; }
@keyframes drift { 0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; } }
footer.foot { background: rgba(255, 255, 255, .92); }
.auth { display: grid; grid-template-columns: 5fr 6fr; max-width: 860px;
margin: 40px auto; background: var(--panel); border-radius: 16px;
overflow: hidden; box-shadow: 0 24px 60px rgba(4, 20, 40, .35);
animation: rise .5s ease both; }
.auth-brand { background: var(--navy); color: #dbe5f1; padding: 40px 34px;
display: flex; flex-direction: column; gap: 22px;
position: relative; overflow: hidden; }
/* Two soft circles drifting behind the brand. */
.auth-brand::before, .auth-brand::after { content: ""; position: absolute;
border-radius: 50%; background: rgba(255, 255, 255, .07); }
.auth-brand::before { width: 220px; height: 220px; right: -70px; top: -60px;
animation: float 9s ease-in-out infinite; }
.auth-brand::after { width: 150px; height: 150px; left: -50px; bottom: -40px;
background: rgba(21, 101, 192, .35);
animation: float 11s ease-in-out infinite reverse; }
@keyframes float { 0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-18px, 22px); } }
.auth-brand > * { position: relative; z-index: 1; }
.auth-brand .big { display: inline-grid; place-items: center; width: 56px;
height: 56px; border-radius: 16px; background: #fff;
color: var(--navy); font-size: 19px; font-weight: 800;
animation: ring 2.8s ease-out infinite; }
@keyframes ring { 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, .45); }
70%, 100% { box-shadow: 0 0 0 14px rgba(255, 255, 255, 0); } }
.auth-brand h1 { color: #fff; font-size: 28px; margin: 0; line-height: 1.2; }
.auth-brand ul { list-style: none; margin: 0; padding: 0;
display: grid; gap: 14px; }
.auth-brand li { display: flex; gap: 12px; align-items: center;
animation: slide .5s ease both; }
.auth-brand li:nth-child(1) { animation-delay: .15s; }
.auth-brand li:nth-child(2) { animation-delay: .3s; }
.auth-brand li:nth-child(3) { animation-delay: .45s; }
@keyframes slide { from { transform: translateX(-16px); }
to { transform: none; } }
.auth-brand li .dot { flex: none; display: inline-grid; place-items: center;
width: 30px; height: 30px; border-radius: 9px;
background: rgba(255, 255, 255, .12); color: #fff;
font-size: 14px; }
.auth-brand li b { color: #fff; }
.auth-brand .foot-note { margin-top: auto; font-size: 12px; color: #8fa3bb; }
.auth-form { padding: 40px 38px; display: flex; flex-direction: column;
justify-content: center; }
.auth-form h2 { font-size: 24px; margin: 0 0 22px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-weight: 600; margin-bottom: 6px;
font-size: 13px; }
.field .box { display: flex; align-items: center; border: 1px solid #c9d2de;
border-radius: 8px; background: var(--panel);
transition: border-color .15s, box-shadow .15s; }
.field .box:focus-within { border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(21, 101, 192, .15); }
.field .ic { padding: 0 4px 0 12px; color: var(--muted); font-size: 15px; }
.field input { flex: 1; border: 0; padding: 11px 10px; min-width: 0;
background: transparent; outline: none; }
.field button.eye { background: transparent; border: 0; color: var(--accent);
font-weight: 600; font-size: 12px; padding: 0 12px; }
.field button.eye:hover { background: transparent; text-decoration: underline; }
#caps { color: var(--review); font-size: 12px; margin-top: 6px; }
.auth-form button[type="submit"] { width: 100%; padding: 12px 14px;
font-size: 15px; border-radius: 8px;
margin-top: 4px; position: relative;
overflow: hidden;
transition: transform .15s, box-shadow .15s; }
.auth-form button[type="submit"]:hover { transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(21, 101, 192, .35); }
/* A light sweep across the button on hover. */
.auth-form button[type="submit"]::after { content: ""; position: absolute;
top: 0; left: -60%; width: 40%; height: 100%;
background: linear-gradient(90deg, transparent,
rgba(255, 255, 255, .35), transparent);
transform: skewX(-20deg); }
.auth-form button[type="submit"]:hover::after { left: 120%;
transition: left .6s ease; }
.auth-form .rules { margin-top: 18px; padding-top: 14px;
border-top: 1px solid var(--line); }
@media (max-width: 760px) {
.auth { grid-template-columns: 1fr; margin: 8px 0; }
.auth-brand, .auth-form { padding: 24px 20px; }
.auth-brand ul { display: none; }
}
{% endblock %}
{% block content %}
<div class="signin">
<h1>Sign in</h1>
<p class="sub">The Portal shows patient data: every Portal User signs in,
and every Run records who started it.</p>
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run
<code>python tools\add_portal_user.py</code> to add the first Admin.</div>{% endif %}
<form class="card" method="post" action="/signin" autocomplete="off">
<input type="hidden" name="token" value="{{ run_token }}">
<input type="hidden" name="next" value="{{ next_url }}">
<label>Username <input name="username" autocomplete="username" required autofocus></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
</form>
<div class="auth">
<section class="auth-brand">
<span class="big" aria-hidden="true">CV</span>
<h1>Charge Validation</h1>
<ul>
<li><span class="dot" aria-hidden="true">✓</span><b>Charge conditions</b></li>
<li><span class="dot" aria-hidden="true">▦</span><b>Pass, Fail, Review</b></li>
<li><span class="dot" aria-hidden="true">◷</span><b>Every run recorded</b></li>
</ul>
<p class="foot-note">Ecare India · {{ mode }} mode</p>
</section>
<section class="auth-form">
<h2>Sign in</h2>
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run
<code>python tools\add_portal_user.py</code> to add the first Admin.</div>{% endif %}
<form method="post" action="/signin" autocomplete="off">
<input type="hidden" name="token" value="{{ run_token }}">
<input type="hidden" name="next" value="{{ next_url }}">
<div class="field">
<label for="username">Username</label>
<div class="box"><span class="ic" aria-hidden="true">👤</span><input id="username" name="username" autocomplete="username" required autofocus></div>
</div>
<div class="field">
<label for="pw">Password</label>
<div class="box"><span class="ic" aria-hidden="true">🔒</span><input id="pw" name="password" type="password" autocomplete="current-password" required><button type="button" class="eye" id="pw-toggle" aria-controls="pw" aria-pressed="false">Show</button></div>
<div id="caps" role="status" hidden>Caps Lock is on</div>
</div>
<button type="submit">Sign in</button>
</form>
<p class="note rules">Signed out after {{ idle_minutes }} minutes idle.
{{ lock_after }} wrong passwords lock the account for
{{ lock_minutes }} minutes. Forgot it? An Admin can reset it.</p>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>
(() => {
const pw = $('pw'), eye = $('pw-toggle'), caps = $('caps');
eye.addEventListener('click', () => {
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
eye.textContent = show ? 'Hide' : 'Show';
eye.setAttribute('aria-pressed', String(show));
pw.focus();
});
const capsCheck = e => {
if (e.getModifierState) caps.hidden = !e.getModifierState('CapsLock');
};
pw.addEventListener('keydown', capsCheck);
pw.addEventListener('keyup', capsCheck);
pw.addEventListener('blur', () => { caps.hidden = true; });
})();
</script>
{% endblock %}
+8 -3
View File
@@ -23,8 +23,9 @@
{% endblock %}
{% block content %}
<h1>Portal Users</h1>
<p class="sub">Admin: everything, and this page. Operator: see everything,
Start / Stop a Run. Viewer: see only. Users are disabled, never deleted.</p>
<p class="sub">Super Admin: everything, adds and changes Admins and Viewers.
Admin: everything, adds and changes Viewers. Viewer: sees the claims and
Runs, changes nothing. Users are disabled, never deleted.</p>
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
{% if message %}<div class="msg good">{{ message }}</div>{% endif %}
@@ -59,11 +60,14 @@
</td>
<td>{{ u.created_at | usdate }}<br><span class="note">by {{ u.created_by }}</span></td>
<td>
{% if u.role in roles and u.username != user.username %}
{% if roles | length > 1 %}
<form method="post" action="/admin/users/{{ u.username }}/role">
<input type="hidden" name="token" value="{{ run_token }}">
<select name="role">{% for r in roles %}<option{% if r == u.role %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
<button type="submit" class="plain">Set role</button>
</form>
{% endif %}
<form method="post" action="/admin/users/{{ u.username }}/reset" autocomplete="off">
<input type="hidden" name="token" value="{{ run_token }}">
<input name="password" type="password" placeholder="New first password" autocomplete="new-password" minlength="10" required>
@@ -74,12 +78,13 @@
<input type="hidden" name="token" value="{{ run_token }}">
<button type="submit" class="plain">Enable</button>
</form>
{% elif u.username != user.username %}
{% else %}
<form method="post" action="/admin/users/{{ u.username }}/disable">
<input type="hidden" name="token" value="{{ run_token }}">
<button type="submit" class="plain">Disable</button>
</form>
{% endif %}
{% else %}<span class="note">—</span>{% endif %}
</td>
</tr>
{% endfor %}
+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")