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

+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. 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 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). 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`):** **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 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` 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, in SSMS (adds `CV_PortalUser`, `CV_PortalSignIn` and, for Stage 3,
`CV_ChargeSummary.started_by`), then `CV_ChargeSummary.started_by`), then
`python tools\add_portal_user.py` for the first Admin (password typed hidden), `python tools\add_portal_user.py` for the first Super Admin (password typed
then restart `run_web.bat`. Roles: **Admin** (Users page: add / role / reset hidden), then restart `run_web.bat`. Roles since `f99dc17` (2026-10-27,
/ disable — never delete; Sign-ins page), **Operator** (Start / Stop), operator: "super admin means me"): **Super Admin** (everything; Users page
**Viewer** (see only). Passwords PBKDF2-SHA256 (600k, own salt), 10+ 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 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 = 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 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`. unchanged. Tests: `test_run_alerts.py`.
**From the page (2026-10-14):** `.\run_web.bat` → http://127.0.0.1:8000 **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 (`SAVE_CORRECTIONS=0` forced), information boxes go to the log
(`QUIET_BOXES=1`); CAPTCHA / 2FA boxes still come up on the PC. The card (`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 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. this machine, with ODBC Driver 18 for SQL Server.
- Tests run offline against fake HTML pages in `tests/fixtures/` and fake DB - 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` 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 starts a local Chromium on a debug port). As of 2026-10-27: 71 test
files, **1215 tests** (full run 2026-10-23: 1211/1211; the 4 new Portal files, **1220 tests** (full run 2026-10-27: 1220/1220) (earlier flaky, each
tests and every Portal test file passed 2026-10-26) (earlier flaky, each
failed once and passed on rerun: test_verify_no_live_writes, 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). 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, 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. a scratchpad script; no SQL, no real users), desktop and phone width.
The running Portal shows it after `run_web.bat` is restarted. 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 - Server `MECARE331\MECARE331`, database `Charge_Validation`, table
`dbo.CV_ChargeSummary` (every table of this project starts with **`CV_`**, `dbo.CV_ChargeSummary` (every table of this project starts with **`CV_`**,
operator 2026-10-12): the format's 24 columns + `run_at`, `run_mode` 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"). 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`? 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. 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`. 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` 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 (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), 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-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-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-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-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-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) | | 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 ## 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 - **Portal for the client — Gitea + Dokploy (asked 2026-10-23, not
started).** Gitea `giteanew.ecareindia.com` (10.200.0.15, internal; its started).** Gitea `giteanew.ecareindia.com` (10.200.0.15, internal; its
HTTPS certificate is not in Git Bash's curl bundle) and Dokploy 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 _Avoid_: login, logon
**Role**: **Role**:
What a Portal User may do. **Admin**: everything, including managing Portal What a Portal User may do. **Super Admin**: everything; adds and changes
Users. **Operator**: see everything and start / stop a Run. **Viewer**: see Admins and Viewers; nobody changes a Super Admin from the Portal. **Admin**:
only. 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**: **Started By**:
The Portal User who started a Run from the Portal, or the Windows user for a The Portal User who started a Run from the Portal, or the Windows user for a
+71 -24
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 * Wrong password / lock / disabled read as such; 30 minutes idle signs
out (and is recorded). out (and is recorded).
* A user an Admin made must change the password before anything else. * 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 * Roles (2026-10-27): a Viewer sees no Start / Stop and is refused them;
Admin reaches Portal Users and the Sign-in history. 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. * Started By / Stopped By reach the runner as the signed-in username.
* Admin changes take effect on the user's open session at once. * 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): 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.""" vee (Viewer), all able to sign in straight away."""
store = FakeUserStore() store = FakeUserStore()
acc = portal_users.Accounts(store) acc = portal_users.Accounts(store)
for name, role in (("asha", "Admin"), ("olu", "Operator"), for name, role in (("sam", "Super Admin"), ("asha", "Admin"),
("vee", "Viewer")): ("vee", "Viewer")):
acc.create_user(name, name.title(), role, PW) acc.create_user(name, name.title(), role, PW)
runner = runner or FakeRunner() runner = runner or FakeRunner()
@@ -136,7 +138,7 @@ def a_wrong_password_says_so_and_five_lock():
@case @case
def sign_out_ends_the_session(): def sign_out_ends_the_session():
app, store, _ = _portal() app, store, _ = _portal()
c, _ = _sign_in(app, "olu") c, _ = _sign_in(app, "asha")
assert c.post("/signout", headers=ORIGIN, assert c.post("/signout", headers=ORIGIN,
data={"token": "wrong"}).status_code == 403 data={"token": "wrong"}).status_code == 403
r = c.post("/signout", headers=ORIGIN, data=_form(app), 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" assert r.status_code == 303 and r.headers["location"] == "/signin"
c.cookies.clear() c.cookies.clear()
assert c.get("/days", follow_redirects=False).status_code == 303 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 @case
@@ -203,24 +205,27 @@ def a_viewer_sees_no_start_and_is_refused_it():
@case @case
def started_by_and_stopped_by_are_the_signed_in_user(): def started_by_and_stopped_by_are_the_signed_in_user():
app, _, runner = _portal() app, _, runner = _portal()
c, _ = _sign_in(app, "olu") c, _ = _sign_in(app, "asha")
assert "const CAN_RUN = true;" in c.get("/").text assert "const CAN_RUN = true;" in c.get("/").text
h = {**ORIGIN, "X-Run-Token": app.state.run_token} h = {**ORIGIN, "X-Run-Token": app.state.run_token}
assert c.post("/api/bot/start", headers=h).status_code == 200 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 c.post("/api/bot/stop", headers=h).status_code == 200
assert runner.stopped_by == "olu" assert runner.stopped_by == "asha"
assert c.get("/admin/users").status_code == 403, "Operator is no Admin"
@case @case
def the_header_names_the_user_and_admin_links_are_admin_only(): def the_header_names_the_user_and_admin_links_are_admin_only():
app, _, _ = _portal() app, _, _ = _portal()
a, _ = _sign_in(app, "asha") for name, role in (("sam", "Super Admin"), ("asha", "Admin")):
page = a.get("/runs").text a, _ = _sign_in(app, name)
assert "<b>Asha</b> · Admin" in page and 'href="/admin/users"' in page page = a.get("/runs").text
o, _ = _sign_in(app, "olu") assert f"<b>{name.title()}</b> · {role}" in page, name
assert 'href="/admin/users"' not in o.get("/runs").text 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 @case
@@ -228,9 +233,13 @@ def an_admin_adds_a_user_who_then_must_change():
app, store, _ = _portal() app, store, _ = _portal()
c, _ = _sign_in(app, "asha") c, _ = _sign_in(app, "asha")
r = c.post("/admin/users", headers=ORIGIN, data=_form( 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")) password="temp-password-1"))
assert r.status_code == 200 and "ravi added" in r.text, r.text[:300] 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 store.users["ravi"]["must_change"] is True
assert ("ravi", "user_created", "asha") in store.events assert ("ravi", "user_created", "asha") in store.events
r = c.post("/admin/users", headers=ORIGIN, data=_form( r = c.post("/admin/users", headers=ORIGIN, data=_form(
@@ -242,20 +251,42 @@ def an_admin_adds_a_user_who_then_must_change():
@case @case
def admin_changes_reach_an_open_session_at_once(): def admin_changes_reach_an_open_session_at_once():
app, store, _ = _portal() app, store, _ = _portal()
admin, _ = _sign_in(app, "asha") admin, _ = _sign_in(app, "sam")
olu, _ = _sign_in(app, "olu") olu, _ = _sign_in(app, "asha")
vee, _ = _sign_in(app, "vee") 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")) data=_form(app, role="Viewer"))
assert "const CAN_RUN = false;" in olu.get("/").text, "role now Viewer" assert "const CAN_RUN = false;" in olu.get("/").text, "role now Viewer"
admin.post("/admin/users/vee/disable", headers=ORIGIN, data=_form(app)) admin.post("/admin/users/vee/disable", headers=ORIGIN, data=_form(app))
assert vee.get("/days", follow_redirects=False).status_code == 303 assert vee.get("/days", follow_redirects=False).status_code == 303
_, r = _sign_in(app, "vee") _, r = _sign_in(app, "vee")
assert r.status_code == 401 and "disabled" in r.text 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")) data=_form(app, password="temp-password-2"))
assert olu.get("/days", follow_redirects=False).status_code == 303 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 @case
@@ -271,9 +302,10 @@ def admin_forms_need_the_token():
def the_sign_in_history_lists_events_for_an_admin(): def the_sign_in_history_lists_events_for_an_admin():
app, _, _ = _portal() app, _, _ = _portal()
_sign_in(app, "vee", "wrong-password-x") _sign_in(app, "vee", "wrong-password-x")
c, _ = _sign_in(app, "asha") for name in ("sam", "asha"):
page = c.get("/admin/signins").text c, _ = _sign_in(app, name)
assert "wrong password" in page and "vee" in page page = c.get("/admin/signins").text
assert "wrong password" in page and "vee" in page, name
@case @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 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 @case
def no_signed_in_page_carries_an_inline_style(): def no_signed_in_page_carries_an_inline_style():
app, _, _ = _portal() app, _, _ = _portal()
+67 -11
View File
@@ -74,7 +74,7 @@ class Clock:
def _accounts(): def _accounts():
store, clock = FakeUserStore(), Clock() store, clock = FakeUserStore(), Clock()
acc = users.Accounts(store, now=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 return acc, store, clock
@@ -149,7 +149,7 @@ def a_good_sign_in_clears_the_wrong_count():
@case @case
def an_admin_made_user_must_change_and_a_reset_does_the_same(): def an_admin_made_user_must_change_and_a_reset_does_the_same():
acc, store, _ = _accounts() 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"]["must_change"] is True
assert store.users["ravi"]["created_by"] == "asha" assert store.users["ravi"]["created_by"] == "asha"
acc.change_password("ravi", "temp-password-1", "ravis-own-pass") 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 @case
def the_last_admin_stays_and_nobody_disables_themselves(): def an_admin_adds_and_changes_viewers_only():
acc, _, _ = _accounts() """Operator, 2026-10-27: an Admin creates users, and users means
for fn in (lambda: acc.set_role("asha", "Viewer", by="asha"), Viewers."""
lambda: acc.set_disabled("asha", True, by="asha")): 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: try:
fn() fn()
raise AssertionError("allowed") raise AssertionError("allowed")
except users.UserError: except users.UserError:
pass pass
acc.create_user("ravi", "Ravi", "Admin", "temp-password-1", by="asha") assert store.users["asha"]["role"] == "Super Admin"
acc.set_role("asha", "Operator", by="ravi") 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: try:
acc.set_disabled("ravi", True, by="asha") acc.create_user("olu", "Olu", "Operator", "temp-password-1")
raise AssertionError("disabled the last Admin") raise AssertionError("Operator accepted")
except users.UserError: except users.UserError:
pass 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 @case
def sessions_time_out_after_thirty_idle_minutes(): def sessions_time_out_after_thirty_idle_minutes():
clock = Clock() clock = Clock()
@@ -234,7 +290,7 @@ def sessions_time_out_after_thirty_idle_minutes():
@case @case
def a_users_sessions_can_be_ended_and_refreshed(): def a_users_sessions_can_be_ended_and_refreshed():
s = users.Sessions() s = users.Sessions()
t1 = s.new({"username": "ravi", "role": "Operator"}) t1 = s.new({"username": "ravi", "role": "Admin"})
t2 = s.new({"username": "asha", "role": "Admin"}) t2 = s.new({"username": "asha", "role": "Admin"})
s.refresh_user({"username": "ravi", "role": "Viewer"}) s.refresh_user({"username": "ravi", "role": "Viewer"})
assert s.get(t1)[0]["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 Add a Portal User from this PC — the way the first Super Admin is made,
nobody can sign in to the Portal yet (spec 2026-10-22 Stage 2, EEWA-32). 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 (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 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 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: def main(argv) -> int:
username = argv[0] if argv else input("Username: ").strip() username = argv[0] if argv else input("Username: ").strip()
role = argv[1] if len(argv) > 1 else ( 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: ") name = argv[2] if len(argv) > 2 else input("Name shown on the Portal: ")
password = getpass.getpass("Password (10+ characters, hidden): ") password = getpass.getpass("Password (10+ characters, hidden): ")
if getpass.getpass("Password again: ") != password: if getpass.getpass("Password again: ") != password:
+17 -3
View File
@@ -60,7 +60,7 @@ BEGIN
CREATE TABLE dbo.CV_PortalUser ( CREATE TABLE dbo.CV_PortalUser (
username NVARCHAR(50) NOT NULL PRIMARY KEY, -- lower case username NVARCHAR(50) NOT NULL PRIMARY KEY, -- lower case
display_name NVARCHAR(100) NOT NULL, 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_hash CHAR(64) NOT NULL, -- hex
pw_salt CHAR(32) NOT NULL, -- hex pw_salt CHAR(32) NOT NULL, -- hex
pw_iterations INT NOT NULL, pw_iterations INT NOT NULL,
@@ -72,10 +72,24 @@ BEGIN
created_by NVARCHAR(50) NOT NULL, created_by NVARCHAR(50) NOT NULL,
changed_at DATETIME2 NOT NULL, changed_at DATETIME2 NOT NULL,
CONSTRAINT CK_CV_PortalUser_role CONSTRAINT CK_CV_PortalUser_role
CHECK (role IN ('Admin', 'Operator', 'Viewer')) CHECK (role IN ('Super Admin', 'Admin', 'Viewer'))
); );
END END
GO 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 IF OBJECT_ID('dbo.CV_PortalSignIn', 'U') IS NULL
BEGIN BEGIN
CREATE TABLE dbo.CV_PortalSignIn ( CREATE TABLE dbo.CV_PortalSignIn (
@@ -84,7 +98,7 @@ BEGIN
username NVARCHAR(50) NOT NULL, -- as typed, for a wrong one username NVARCHAR(50) NOT NULL, -- as typed, for a wrong one
event NVARCHAR(30) NOT NULL, -- sign_in, sign_out, wrong_password, event NVARCHAR(30) NOT NULL, -- sign_in, sign_out, wrong_password,
-- locked, timeout, user_created, ... -- 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); CREATE INDEX IX_CV_PortalSignIn_at ON dbo.CV_PortalSignIn (at);
END 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 .repo import SqlResultsRepo # noqa: E402
from . import run_status # noqa: E402 from . import run_status # noqa: E402
from . import runs as run_view # noqa: E402 from . import runs as run_view # noqa: E402
from .users import (Accounts, CAN_RUN, ROLES, Sessions, # noqa: E402 from .users import (Accounts, CAN_ADMIN, CAN_RUN, # noqa: E402
SqlUserStore, UserError) IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES, MANAGES,
Sessions, SqlUserStore, UserError)
from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402 from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402
NotRunning, StopFailed) NotRunning, StopFailed)
@@ -379,7 +380,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
"run_token": app.state.run_token, "crumbs": crumbs, "run_token": app.state.run_token, "crumbs": crumbs,
"categories": CATEGORIES, "user": user, "categories": CATEGORIES, "user": user,
"can_run": bool(user and user["role"] in CAN_RUN), "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}) **context})
@app.get("/", response_class=HTMLResponse) @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): def _signin_page(request, error="", next_url="/", status_code=200):
page = _page(request, "signin.html", [["Sign in", None]], page = _page(request, "signin.html", [["Sign in", None]],
error=error, next_url=_safe_next(next_url), 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 page.status_code = status_code
return page return page
@@ -611,26 +614,28 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
sessions.refresh_user(updated) sessions.refresh_user(updated)
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
# ── Portal Users (Admin) ───────────────────────────────────────────── # ── Portal Users (Super Admin, Admin) ────────────────────────────────
def _users_page(request, error="", message="", status_code=200): def _users_page(request, error="", message="", status_code=200):
page = _page(request, "users.html", page = _page(request, "users.html",
[["Dashboard", "/"], ["Portal Users", None]], [["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) message=message)
page.status_code = status_code page.status_code = status_code
return page return page
@app.get("/admin/users", response_class=HTMLResponse) @app.get("/admin/users", response_class=HTMLResponse)
def users_page(request: Request, msg: str = ""): def users_page(request: Request, msg: str = ""):
_need(request, ("Admin",)) _need(request, CAN_ADMIN)
return _users_page(request, message=msg[:200]) return _users_page(request, message=msg[:200])
def _admin_change(request, token, done, fn, *args): def _admin_change(request, token, done, fn, *args):
"""One Admin change: checked, made, then back to the list with """One Admin change: checked, made, then back to the list with
`done` said — or the list again with the problem.""" `done` said — or the list again with the problem."""
_check_token(request, token) _check_token(request, token)
me = _need(request, ("Admin",))["username"] me = _need(request, CAN_ADMIN)["username"]
try: try:
_db(fn, *args, by=me) _db(fn, *args, by=me)
except UserError as exc: 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) @app.get("/admin/signins", response_class=HTMLResponse)
def signins_page(request: Request): def signins_page(request: Request):
_need(request, ("Admin",)) _need(request, CAN_ADMIN)
return _page(request, "signins.html", return _page(request, "signins.html",
[["Dashboard", "/"], ["Sign-in history", None]], [["Dashboard", "/"], ["Sign-in history", None]],
events=_db(store.history)) events=_db(store.history))
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="run-token" content="{{ run_token }}"> <meta name="run-token" content="{{ run_token }}">
<title>{% block title %}Charge Validation{% endblock %} · eCW</title> <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 %} {% block head %}{% endblock %}
<style> <style>
/* Navy & blue (operator, 2026-10-19): navy header, white cards on light /* Navy & blue (operator, 2026-10-19): navy header, white cards on light
+143 -18
View File
@@ -1,25 +1,150 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sign in{% endblock %} {% block title %}Sign in{% endblock %}
{% block style %} {% block style %}
.signin { max-width: 380px; margin: 40px auto; } /* Operator, 2026-10-26: the Portal's front door — what it is on the
.signin label { display: block; margin: 0 0 12px; font-weight: 600; } left, the form on the right; one column on a phone. */
.signin input { display: block; width: 100%; margin-top: 4px; } /* A slow-moving navy-to-blue background, this page only. */
.signin button { width: 100%; margin-top: 6px; } 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 %} {% endblock %}
{% block content %} {% block content %}
<div class="signin"> <div class="auth">
<h1>Sign in</h1> <section class="auth-brand">
<p class="sub">The Portal shows patient data: every Portal User signs in, <span class="big" aria-hidden="true">CV</span>
and every Run records who started it.</p> <h1>Charge Validation</h1>
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %} <ul>
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run <li><span class="dot" aria-hidden="true">✓</span><b>Charge conditions</b></li>
<code>python tools\add_portal_user.py</code> to add the first Admin.</div>{% endif %} <li><span class="dot" aria-hidden="true">▦</span><b>Pass, Fail, Review</b></li>
<form class="card" method="post" action="/signin" autocomplete="off"> <li><span class="dot" aria-hidden="true">◷</span><b>Every run recorded</b></li>
<input type="hidden" name="token" value="{{ run_token }}"> </ul>
<input type="hidden" name="next" value="{{ next_url }}"> <p class="foot-note">Ecare India · {{ mode }} mode</p>
<label>Username <input name="username" autocomplete="username" required autofocus></label> </section>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label> <section class="auth-form">
<button type="submit">Sign in</button> <h2>Sign in</h2>
</form> {% 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> </div>
{% endblock %} {% 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 %} {% endblock %}
{% block content %} {% block content %}
<h1>Portal Users</h1> <h1>Portal Users</h1>
<p class="sub">Admin: everything, and this page. Operator: see everything, <p class="sub">Super Admin: everything, adds and changes Admins and Viewers.
Start / Stop a Run. Viewer: see only. Users are disabled, never deleted.</p> 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 error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
{% if message %}<div class="msg good">{{ message }}</div>{% endif %} {% if message %}<div class="msg good">{{ message }}</div>{% endif %}
@@ -59,11 +60,14 @@
</td> </td>
<td>{{ u.created_at | usdate }}<br><span class="note">by {{ u.created_by }}</span></td> <td>{{ u.created_at | usdate }}<br><span class="note">by {{ u.created_by }}</span></td>
<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"> <form method="post" action="/admin/users/{{ u.username }}/role">
<input type="hidden" name="token" value="{{ run_token }}"> <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> <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> <button type="submit" class="plain">Set role</button>
</form> </form>
{% endif %}
<form method="post" action="/admin/users/{{ u.username }}/reset" autocomplete="off"> <form method="post" action="/admin/users/{{ u.username }}/reset" autocomplete="off">
<input type="hidden" name="token" value="{{ run_token }}"> <input type="hidden" name="token" value="{{ run_token }}">
<input name="password" type="password" placeholder="New first password" autocomplete="new-password" minlength="10" required> <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 }}"> <input type="hidden" name="token" value="{{ run_token }}">
<button type="submit" class="plain">Enable</button> <button type="submit" class="plain">Enable</button>
</form> </form>
{% elif u.username != user.username %} {% else %}
<form method="post" action="/admin/users/{{ u.username }}/disable"> <form method="post" action="/admin/users/{{ u.username }}/disable">
<input type="hidden" name="token" value="{{ run_token }}"> <input type="hidden" name="token" value="{{ run_token }}">
<button type="submit" class="plain">Disable</button> <button type="submit" class="plain">Disable</button>
</form> </form>
{% endif %} {% endif %}
{% else %}<span class="note">—</span>{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
+28 -14
View File
@@ -7,8 +7,11 @@ GLOSSARY.md).
salt and iteration count (stdlib, no new package on this locked-down salt and iteration count (stdlib, no new package on this locked-down
PC); never stored, logged or shown in plain. At least 10 characters, PC); never stored, logged or shown in plain. At least 10 characters,
not the username. not the username.
* **Roles**: Admin (everything, and managing Portal Users), Operator (see * **Roles** (2026-10-27): Super Admin (everything; manages Admins and
everything, Start / Stop a Run), Viewer (see only). 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. * **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 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 same time (a hash is still computed), so the page does not reveal which
@@ -30,8 +33,11 @@ import secrets
import hashlib import hashlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
ROLES = ("Admin", "Operator", "Viewer") ROLES = ("Super Admin", "Admin", "Viewer")
CAN_RUN = ("Admin", "Operator") 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 MIN_PASSWORD = 10
ITERATIONS = 600_000 ITERATIONS = 600_000
LOCK_AFTER = 5 LOCK_AFTER = 5
@@ -250,6 +256,8 @@ class Accounts:
problem = password_problem(password, name) problem = password_problem(password, name)
if problem: if problem:
raise UserError(problem) raise UserError(problem)
if by:
self._check_manages(by, role)
if self.store.get(name): if self.store.get(name):
raise UserError(f"{name} already exists") raise UserError(f"{name} already exists")
pw_hash, salt, iterations = hash_password(password) pw_hash, salt, iterations = hash_password(password)
@@ -280,6 +288,7 @@ class Accounts:
def reset_password(self, username, new, by): def reset_password(self, username, new, by):
user = self._existing(username) user = self._existing(username)
self._check_manages(by, user["role"])
self._set_password(user, new, must_change=True) self._set_password(user, new, must_change=True)
self.store.record(user["username"], "password_reset", by) self.store.record(user["username"], "password_reset", by)
@@ -287,18 +296,18 @@ class Accounts:
user = self._existing(username) user = self._existing(username)
if role not in ROLES: if role not in ROLES:
raise UserError(f"Role must be one of {', '.join(ROLES)}") raise UserError(f"Role must be one of {', '.join(ROLES)}")
if user["role"] == "Admin" and role != "Admin": self._check_manages(by, user["role"])
self._keep_an_admin(user["username"]) self._check_manages(by, role)
self.store.update(user["username"], role=role, self.store.update(user["username"], role=role,
changed_at=self._now().replace(microsecond=0)) 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): def set_disabled(self, username, disabled: bool, by):
user = self._existing(username) user = self._existing(username)
if disabled and user["username"] == by: if disabled and user["username"] == by:
raise UserError("You cannot disable yourself") raise UserError("You cannot disable yourself")
if disabled and user["role"] == "Admin": self._check_manages(by, user["role"])
self._keep_an_admin(user["username"])
self.store.update(user["username"], disabled=disabled, self.store.update(user["username"], disabled=disabled,
failed_count=0, locked_until=None, failed_count=0, locked_until=None,
changed_at=self._now().replace(microsecond=0)) changed_at=self._now().replace(microsecond=0))
@@ -321,8 +330,13 @@ class Accounts:
raise UserError(f"No user {username}") raise UserError(f"No user {username}")
return user return user
def _keep_an_admin(self, leaving): def _check_manages(self, by, role):
others = [u for u in self.store.list() if u["role"] == "Admin" """`by` (a signed-in user) may add or change a `role` user — read
and not u["disabled"] and u["username"] != leaving] from the store, not the session, so a role just taken away counts."""
if not others: actor = self.store.get(clean_username(by))
raise UserError("The Portal needs at least one active Admin") 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")