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
+399 -90

No files matched your search

+47 -10
View File
@@ -87,6 +87,13 @@ before any login; **17:07 — the box came up, OK, login in 279 s (box wait
included), 2FA mail in 42 s, Chrome showed only eCW tabs**, claims running.
A full test suite ran beside the 16:27 and 17:07 runs without touching
them (the tests' Chrome uses a free port, never 9222).
**2026-10-26 17:40 terminal run:** Chrome open box → OK, login 97 s (2FA
mail 42 s), **50/50 read, 38 Pass / 12 Fail / 0 Review, no bot error**,
~27 min. Against the 23/10 17:07 run, 49 claims gave the same comment;
the one change is eCW data, not code: a patient's sliding fee was renewed
(new period from 10/23/2026), so a claim with an earlier DOS is now Fail
"Sliding fee NOT STARTED" (§6 Waiting 6). The #25 and Portal changes
of 23–26/10 changed no other result.
**Runs page (2026-10-22, Stage 1 of EEWA-31..33, `751a177`):**
http://127.0.0.1:8000/runs (header link "Runs"). Every Run writes
@@ -113,10 +120,16 @@ and not SQL: `docs/adr/0001-run-events-in-files.md`. Spec:
page and `/api` needs a Sign-in. Setup, once: run `tools\sql\create_tables.sql`
in SSMS (adds `CV_PortalUser`, `CV_PortalSignIn` and, for Stage 3,
`CV_ChargeSummary.started_by`), then
`python tools\add_portal_user.py` for the first Admin (password typed hidden),
then restart `run_web.bat`. Roles: **Admin** (Users page: add / role / reset
/ disable — never delete; Sign-ins page), **Operator** (Start / Stop),
**Viewer** (see only). Passwords PBKDF2-SHA256 (600k, own salt), 10+
`python tools\add_portal_user.py` for the first Super Admin (password typed
hidden), then restart `run_web.bat`. Roles since `f99dc17` (2026-10-27,
operator: "super admin means me"): **Super Admin** (everything; Users page
adds / changes Admins and Viewers — never deletes; nobody changes a Super
Admin from the Portal, only the PC tool makes one), **Admin** (everything:
Start / Stop, Sign-ins page; adds / changes Viewers only), **Viewer**
(Dashboard, Claim status, Runs; changes nothing, no Users or Sign-ins).
Operator is gone (folded into Admin). The live table was moved 2026-10-27
by the operator in SSMS (the bot's SQL login cannot ALTER the table): the
one user is Super Admin; the CHECK allows only the three roles. Passwords PBKDF2-SHA256 (600k, own salt), 10+
characters; a user an Admin adds or resets must choose their own at the
next Sign-in. 5 wrong passwords = locked 15 minutes; 30 minutes idle =
signed out; sessions live in the server's memory (a restart signs everyone
@@ -147,7 +160,7 @@ table needs an update — run create_tables.sql". The Excel Charge Summary is
unchanged. Tests: `test_run_alerts.py`.
**From the page (2026-10-14):** `.\run_web.bat` → http://127.0.0.1:8000
(the Dashboard) → **Start run** (Admin / Operator, since 2026-10-22). The page starts `python run.py` itself, always **VERIFY**
(the Dashboard) → **Start run** (Super Admin / Admin; Admin / Operator 2026-10-22 to 10-27). The page starts `python run.py` itself, always **VERIFY**
(`SAVE_CORRECTIONS=0` forced), information boxes go to the log
(`QUIET_BOXES=1`); CAPTCHA / 2FA boxes still come up on the PC. The card
shows phase, progress, Pass/Fail/Review and a live log; **Stop** kills the
@@ -173,9 +186,8 @@ Get-ChildItem tests\test_*.py | ForEach-Object { "$($_.Name): $((python $_.FullN
this machine, with ODBC Driver 18 for SQL Server.
- Tests run offline against fake HTML pages in `tests/fixtures/` and fake DB
connections; they never touch eCW or SQL Server (`test_cdp_browser.py`
starts a local Chromium on a debug port). As of 2026-10-26: 71 test
files, **1215 tests** (full run 2026-10-23: 1211/1211; the 4 new Portal
tests and every Portal test file passed 2026-10-26) (earlier flaky, each
starts a local Chromium on a debug port). As of 2026-10-27: 71 test
files, **1220 tests** (full run 2026-10-27: 1220/1220) (earlier flaky, each
failed once and passed on rerun: test_verify_no_live_writes,
depression_dx_pointer, progress_note_wait — watch them) (about 5 minutes for all of them).
@@ -444,6 +456,14 @@ browser holds in the background must not stay faded), and off under
Checked in the Browser pane against a made-up-data preview (port 8765,
a scratchpad script; no SQL, no real users), desktop and phone width.
The running Portal shows it after `run_web.bat` is restarted.
**Sign-in page since `61338b4` (2026-10-26, operator):** a card on a
slow navy-to-blue moving gradient — navy brand panel ("Charge
Validation" and three feature titles only, no description lines;
floating circles, pulsing CV mark) and the form (icon fields, Show /
Hide password, Caps Lock warning, idle / lockout rules read from
`web/users.py`). Every page has an inline SVG tab icon (no
`/favicon.ico` 404). Motion off under `prefers-reduced-motion`. Live on
the Portal (restarted 2026-10-26, [portal] terminal tab).
- Server `MECARE331\MECARE331`, database `Charge_Validation`, table
`dbo.CV_ChargeSummary` (every table of this project starts with **`CV_`**,
operator 2026-10-12): the format's 24 columns + `run_at`, `run_mode`
@@ -568,6 +588,7 @@ The running Portal shows it after `run_web.bat` is restarted.
3. Dental HIGH RISK / sliding EXPIRED rows say "Corrections pending (not saved)" although nothing was corrected. Needs the operator's wording (e.g. "Submit via DentaQuest portal").
4. Demo `.env`: which sheet `TEST_CLAIM_IDS_FILE` points at (now the Dental sheet), and a shorter `MSGBOX_TIMEOUT_SECONDS`?
5. #25 mental health: G0469 or G0470 (new / established) — the client's ruling (EEWA-37). Until then Review.
6. Sliding fee renewed: a claim whose DOS falls in the patient's **previous** sliding fee period, checked after a renewal — Pass on the old period, or Fail "NOT STARTED" against the current one (as built)? Seen 2026-10-26. Billing team / client to rule.
---
@@ -602,7 +623,9 @@ The running Portal shows it after `run_web.bat` is restarted.
History: `git log --oneline`.
Last code commits: `a436489` (2026-10-26, Portal look: aligned header /
Last code commits: `f99dc17` (2026-10-27, Portal roles Super Admin /
Admin / Viewer — §2), `61338b4` (2026-10-26, Sign-in page redesign + tab
icon — §5), `a436489` (2026-10-26, Portal look: aligned header /
footer, 7-day chart, today's reasons, pass rate — §5), `2d4d4fc`
(2026-10-23, tracked files de-identified — §7), `8fea168` (2026-10-23, "Chrome open" box before the
login and the empty first tab closed, in a Chrome the run opened — §2),
@@ -703,7 +726,7 @@ Status lives in Jira; the open work is in §6 and §10.
| EEWA-30 | medicaid_pregnancy_th (#23: TH modifier on a pregnancy visit on Medicaid) | To Do |
| EEWA-29 | medicare_wellness_history (#27/#28/#29: G0402 / G0438 / G0439 billed too often → Review) | To Do |
| EEWA-31 | run_events + Runs page (Stage 1) | Done (closed 2026-10-26; built `751a177`, seen on every live run since 2026-10-22) |
| EEWA-32 | portal sign-in (Stage 2: Portal Users, Roles, Started By / Stopped By); blocked by EEWA-31 | In Progress (built 2026-10-22, `c230a8e`; live Sign-in and a Portal Start with Started By 2026-10-22; Viewer not yet tried) |
| EEWA-32 | portal sign-in (Stage 2: Portal Users, Roles, Started By / Stopped By); blocked by EEWA-31 | In Progress (built 2026-10-22, `c230a8e`; live Sign-in and a Portal Start with Started By 2026-10-22; roles Super Admin / Admin / Viewer `f99dc17` 2026-10-27; Viewer not yet tried) |
| EEWA-33 | portal run alerts (Stage 3: Run Error banner, `started_by` column); blocked by EEWA-32 | Done (closed 2026-10-26; built `ecbbf3a`; banner seen after the 19:42 run, `started_by` on the Portal runs' rows) |
| EEWA-34 | medicare_pps_code (#25) — spec | To Do (parent of the work below) |
| EEWA-35 | #25 PPS Code check — Review when missing | In Progress (built 2026-10-23, `ac25f83`; not run live) |
@@ -724,6 +747,20 @@ test claim ids — new posts must not (§7).
## 10. Open items
- **Stability (review of 25 runs, 20–26/10, 2026-10-26).** After the eCW
login the run is steady: the last five full runs read 50/50 with no
bot error and the same results run to run. The weak part is getting
in: about half the runs never passed the login — "No second factor
prompted / Login not confirmed" (four on 20/10, not since), a late
2FA mail (372 s once, one 9-minute timeout), the 19:42 restriction
(fixed by the proxy check), guard waits, and stops by hand. Two runs
stopped at "Opening landing page" (Chrome takes ~2 min there); the
watchdog came after them and has not fired live yet. Suggested, not
ticketed: a landing-page timeout with one retry, a 2FA retry, a week of
daily runs to measure login times on the Runs page.
- **Gitea:** one overall snapshot update at the end of a day, when the
operator asks (from the clone `D:\ecw-charge-validation`; operator
pushes; 2026-10-26: `ba15a57` → `120fd68`).
- **Portal for the client — Gitea + Dokploy (asked 2026-10-23, not
started).** Gitea `giteanew.ecareindia.com` (10.200.0.15, internal; its
HTTPS certificate is not in Git Bash's curl bundle) and Dokploy
+6 -3
View File
@@ -83,9 +83,12 @@ minutes idle.
_Avoid_: login, logon
**Role**:
What a Portal User may do. **Admin**: everything, including managing Portal
Users. **Operator**: see everything and start / stop a Run. **Viewer**: see
only.
What a Portal User may do. **Super Admin**: everything; adds and changes
Admins and Viewers; nobody changes a Super Admin from the Portal. **Admin**:
everything, including start / stop a Run and the Sign-in history; adds and
changes Viewers only. **Viewer**: sees the claims and Runs, changes nothing,
no Portal Users or Sign-in history.
_Avoid_: Operator (the old role, folded into Admin on 2026-10-27)
**Started By**:
The Portal User who started a Run from the Portal, or the Windows user for a
+69 -22
View File
@@ -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")
for name, role in (("sam", "Super Admin"), ("asha", "Admin")):
a, _ = _sign_in(app, name)
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
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")
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
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
View File
@@ -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"
+6 -4
View File
@@ -1,9 +1,10 @@
"""
Add a Portal User from this PC — the way the first Admin is made, when
nobody can sign in to the Portal yet (spec 2026-10-22 Stage 2, EEWA-32).
Add a Portal User from this PC — the way the first Super Admin is made,
when nobody can sign in to the Portal yet, and the only way to make any
Super Admin (spec 2026-10-22 Stage 2, EEWA-32; roles 2026-10-27).
python tools\\add_portal_user.py (asks for everything)
python tools\\add_portal_user.py asha Admin "Asha R"
python tools\\add_portal_user.py asha "Super Admin" "Asha R"
The password is typed hidden and twice; it is never shown, logged or
passed on the command line. Needs `tools\\sql\\create_tables.sql` run once
@@ -31,7 +32,8 @@ from web.users import Accounts, ROLES, SqlUserStore, UserError
def main(argv) -> int:
username = argv[0] if argv else input("Username: ").strip()
role = argv[1] if len(argv) > 1 else (
input(f"Role ({' / '.join(ROLES)}) [Admin]: ").strip() or "Admin")
input(f"Role ({' / '.join(ROLES)}) [Super Admin]: ").strip()
or "Super Admin")
name = argv[2] if len(argv) > 2 else input("Name shown on the Portal: ")
password = getpass.getpass("Password (10+ characters, hidden): ")
if getpass.getpass("Password again: ") != password:
+17 -3
View File
@@ -60,7 +60,7 @@ BEGIN
CREATE TABLE dbo.CV_PortalUser (
username NVARCHAR(50) NOT NULL PRIMARY KEY, -- lower case
display_name NVARCHAR(100) NOT NULL,
role NVARCHAR(20) NOT NULL, -- Admin / Operator / Viewer
role NVARCHAR(20) NOT NULL, -- Super Admin / Admin / Viewer
pw_hash CHAR(64) NOT NULL, -- hex
pw_salt CHAR(32) NOT NULL, -- hex
pw_iterations INT NOT NULL,
@@ -72,10 +72,24 @@ BEGIN
created_by NVARCHAR(50) NOT NULL,
changed_at DATETIME2 NOT NULL,
CONSTRAINT CK_CV_PortalUser_role
CHECK (role IN ('Admin', 'Operator', 'Viewer'))
CHECK (role IN ('Super Admin', 'Admin', 'Viewer'))
);
END
GO
-- Roles from 2026-10-27: Super Admin / Admin / Viewer, no Operator. A table
-- made before then: its Admins become Super Admins and its Operators
-- Admins, in that order. Runs until there is a Super Admin; safe to run
-- again. (Not read from sys.check_constraints: the bot's login may not see
-- a constraint's definition.)
IF NOT EXISTS (SELECT 1 FROM dbo.CV_PortalUser WHERE role = 'Super Admin')
BEGIN
ALTER TABLE dbo.CV_PortalUser DROP CONSTRAINT CK_CV_PortalUser_role;
UPDATE dbo.CV_PortalUser SET role = 'Super Admin' WHERE role = 'Admin';
UPDATE dbo.CV_PortalUser SET role = 'Admin' WHERE role = 'Operator';
ALTER TABLE dbo.CV_PortalUser ADD CONSTRAINT CK_CV_PortalUser_role
CHECK (role IN ('Super Admin', 'Admin', 'Viewer'));
END
GO
IF OBJECT_ID('dbo.CV_PortalSignIn', 'U') IS NULL
BEGIN
CREATE TABLE dbo.CV_PortalSignIn (
@@ -84,7 +98,7 @@ BEGIN
username NVARCHAR(50) NOT NULL, -- as typed, for a wrong one
event NVARCHAR(30) NOT NULL, -- sign_in, sign_out, wrong_password,
-- locked, timeout, user_created, ...
by_user NVARCHAR(50) NULL -- the Admin, for an Admin change
by_user NVARCHAR(50) NULL -- the Super Admin / Admin who changed it
);
CREATE INDEX IX_CV_PortalSignIn_at ON dbo.CV_PortalSignIn (at);
END
+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
+136 -11
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>
<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 class="card" method="post" action="/signin" autocomplete="off">
<form 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>
<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")