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
+14
-9
@@ -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))
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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
@@ -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