Sign-in page redesign and Portal roles Super Admin / Admin / Viewer
Sign-in: brand panel, show / hide password, Caps Lock warning, moving background, tab icon. Roles: Super Admin manages Admins and Viewers, Admin manages Viewers only (and can Start / Stop, see Sign-ins), Viewer sees claims and Runs only. create_tables.sql moves an old table. 71 test files, 1220 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
120fd6808a
commit
ce9bdbd974
11 files changed
+408
-99
No files matched your search
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="run-token" content="{{ run_token }}">
|
||||
<title>{% block title %}Charge Validation{% endblock %} · eCW</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2312355b'/%3E%3Ctext x='16' y='21' font-family='Segoe UI,Arial' font-size='13' font-weight='800' text-anchor='middle' fill='%23fff'%3ECV%3C/text%3E%3C/svg%3E">
|
||||
{% block head %}{% endblock %}
|
||||
<style>
|
||||
/* Navy & blue (operator, 2026-10-19): navy header, white cards on light
|
||||
|
||||
+143
-18
@@ -1,25 +1,150 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sign in{% endblock %}
|
||||
{% block style %}
|
||||
.signin { max-width: 380px; margin: 40px auto; }
|
||||
.signin label { display: block; margin: 0 0 12px; font-weight: 600; }
|
||||
.signin input { display: block; width: 100%; margin-top: 4px; }
|
||||
.signin button { width: 100%; margin-top: 6px; }
|
||||
/* Operator, 2026-10-26: the Portal's front door — what it is on the
|
||||
left, the form on the right; one column on a phone. */
|
||||
/* A slow-moving navy-to-blue background, this page only. */
|
||||
body { background: linear-gradient(120deg, #0f2a47, #12355b 35%,
|
||||
#1d4f86 65%, #1565c0);
|
||||
background-size: 220% 220%; animation: drift 18s ease infinite; }
|
||||
@keyframes drift { 0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; } }
|
||||
footer.foot { background: rgba(255, 255, 255, .92); }
|
||||
.auth { display: grid; grid-template-columns: 5fr 6fr; max-width: 860px;
|
||||
margin: 40px auto; background: var(--panel); border-radius: 16px;
|
||||
overflow: hidden; box-shadow: 0 24px 60px rgba(4, 20, 40, .35);
|
||||
animation: rise .5s ease both; }
|
||||
.auth-brand { background: var(--navy); color: #dbe5f1; padding: 40px 34px;
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
position: relative; overflow: hidden; }
|
||||
/* Two soft circles drifting behind the brand. */
|
||||
.auth-brand::before, .auth-brand::after { content: ""; position: absolute;
|
||||
border-radius: 50%; background: rgba(255, 255, 255, .07); }
|
||||
.auth-brand::before { width: 220px; height: 220px; right: -70px; top: -60px;
|
||||
animation: float 9s ease-in-out infinite; }
|
||||
.auth-brand::after { width: 150px; height: 150px; left: -50px; bottom: -40px;
|
||||
background: rgba(21, 101, 192, .35);
|
||||
animation: float 11s ease-in-out infinite reverse; }
|
||||
@keyframes float { 0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-18px, 22px); } }
|
||||
.auth-brand > * { position: relative; z-index: 1; }
|
||||
.auth-brand .big { display: inline-grid; place-items: center; width: 56px;
|
||||
height: 56px; border-radius: 16px; background: #fff;
|
||||
color: var(--navy); font-size: 19px; font-weight: 800;
|
||||
animation: ring 2.8s ease-out infinite; }
|
||||
@keyframes ring { 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, .45); }
|
||||
70%, 100% { box-shadow: 0 0 0 14px rgba(255, 255, 255, 0); } }
|
||||
.auth-brand h1 { color: #fff; font-size: 28px; margin: 0; line-height: 1.2; }
|
||||
.auth-brand ul { list-style: none; margin: 0; padding: 0;
|
||||
display: grid; gap: 14px; }
|
||||
.auth-brand li { display: flex; gap: 12px; align-items: center;
|
||||
animation: slide .5s ease both; }
|
||||
.auth-brand li:nth-child(1) { animation-delay: .15s; }
|
||||
.auth-brand li:nth-child(2) { animation-delay: .3s; }
|
||||
.auth-brand li:nth-child(3) { animation-delay: .45s; }
|
||||
@keyframes slide { from { transform: translateX(-16px); }
|
||||
to { transform: none; } }
|
||||
.auth-brand li .dot { flex: none; display: inline-grid; place-items: center;
|
||||
width: 30px; height: 30px; border-radius: 9px;
|
||||
background: rgba(255, 255, 255, .12); color: #fff;
|
||||
font-size: 14px; }
|
||||
.auth-brand li b { color: #fff; }
|
||||
.auth-brand .foot-note { margin-top: auto; font-size: 12px; color: #8fa3bb; }
|
||||
.auth-form { padding: 40px 38px; display: flex; flex-direction: column;
|
||||
justify-content: center; }
|
||||
.auth-form h2 { font-size: 24px; margin: 0 0 22px; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-weight: 600; margin-bottom: 6px;
|
||||
font-size: 13px; }
|
||||
.field .box { display: flex; align-items: center; border: 1px solid #c9d2de;
|
||||
border-radius: 8px; background: var(--panel);
|
||||
transition: border-color .15s, box-shadow .15s; }
|
||||
.field .box:focus-within { border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(21, 101, 192, .15); }
|
||||
.field .ic { padding: 0 4px 0 12px; color: var(--muted); font-size: 15px; }
|
||||
.field input { flex: 1; border: 0; padding: 11px 10px; min-width: 0;
|
||||
background: transparent; outline: none; }
|
||||
.field button.eye { background: transparent; border: 0; color: var(--accent);
|
||||
font-weight: 600; font-size: 12px; padding: 0 12px; }
|
||||
.field button.eye:hover { background: transparent; text-decoration: underline; }
|
||||
#caps { color: var(--review); font-size: 12px; margin-top: 6px; }
|
||||
.auth-form button[type="submit"] { width: 100%; padding: 12px 14px;
|
||||
font-size: 15px; border-radius: 8px;
|
||||
margin-top: 4px; position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform .15s, box-shadow .15s; }
|
||||
.auth-form button[type="submit"]:hover { transform: translateY(-1px);
|
||||
box-shadow: 0 8px 18px rgba(21, 101, 192, .35); }
|
||||
/* A light sweep across the button on hover. */
|
||||
.auth-form button[type="submit"]::after { content: ""; position: absolute;
|
||||
top: 0; left: -60%; width: 40%; height: 100%;
|
||||
background: linear-gradient(90deg, transparent,
|
||||
rgba(255, 255, 255, .35), transparent);
|
||||
transform: skewX(-20deg); }
|
||||
.auth-form button[type="submit"]:hover::after { left: 120%;
|
||||
transition: left .6s ease; }
|
||||
.auth-form .rules { margin-top: 18px; padding-top: 14px;
|
||||
border-top: 1px solid var(--line); }
|
||||
@media (max-width: 760px) {
|
||||
.auth { grid-template-columns: 1fr; margin: 8px 0; }
|
||||
.auth-brand, .auth-form { padding: 24px 20px; }
|
||||
.auth-brand ul { display: none; }
|
||||
}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="signin">
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">The Portal shows patient data: every Portal User signs in,
|
||||
and every Run records who started it.</p>
|
||||
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
|
||||
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run
|
||||
<code>python tools\add_portal_user.py</code> to add the first Admin.</div>{% endif %}
|
||||
<form class="card" method="post" action="/signin" autocomplete="off">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<input type="hidden" name="next" value="{{ next_url }}">
|
||||
<label>Username <input name="username" autocomplete="username" required autofocus></label>
|
||||
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<div class="auth">
|
||||
<section class="auth-brand">
|
||||
<span class="big" aria-hidden="true">CV</span>
|
||||
<h1>Charge Validation</h1>
|
||||
<ul>
|
||||
<li><span class="dot" aria-hidden="true">✓</span><b>Charge conditions</b></li>
|
||||
<li><span class="dot" aria-hidden="true">▦</span><b>Pass, Fail, Review</b></li>
|
||||
<li><span class="dot" aria-hidden="true">◷</span><b>Every run recorded</b></li>
|
||||
</ul>
|
||||
<p class="foot-note">Ecare India · {{ mode }} mode</p>
|
||||
</section>
|
||||
<section class="auth-form">
|
||||
<h2>Sign in</h2>
|
||||
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
|
||||
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run
|
||||
<code>python tools\add_portal_user.py</code> to add the first Admin.</div>{% endif %}
|
||||
<form method="post" action="/signin" autocomplete="off">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<input type="hidden" name="next" value="{{ next_url }}">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<div class="box"><span class="ic" aria-hidden="true">👤</span><input id="username" name="username" autocomplete="username" required autofocus></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pw">Password</label>
|
||||
<div class="box"><span class="ic" aria-hidden="true">🔒</span><input id="pw" name="password" type="password" autocomplete="current-password" required><button type="button" class="eye" id="pw-toggle" aria-controls="pw" aria-pressed="false">Show</button></div>
|
||||
<div id="caps" role="status" hidden>Caps Lock is on</div>
|
||||
</div>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<p class="note rules">Signed out after {{ idle_minutes }} minutes idle.
|
||||
{{ lock_after }} wrong passwords lock the account for
|
||||
{{ lock_minutes }} minutes. Forgot it? An Admin can reset it.</p>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const pw = $('pw'), eye = $('pw-toggle'), caps = $('caps');
|
||||
eye.addEventListener('click', () => {
|
||||
const show = pw.type === 'password';
|
||||
pw.type = show ? 'text' : 'password';
|
||||
eye.textContent = show ? 'Hide' : 'Show';
|
||||
eye.setAttribute('aria-pressed', String(show));
|
||||
pw.focus();
|
||||
});
|
||||
const capsCheck = e => {
|
||||
if (e.getModifierState) caps.hidden = !e.getModifierState('CapsLock');
|
||||
};
|
||||
pw.addEventListener('keydown', capsCheck);
|
||||
pw.addEventListener('keyup', capsCheck);
|
||||
pw.addEventListener('blur', () => { caps.hidden = true; });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -23,8 +23,9 @@
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Portal Users</h1>
|
||||
<p class="sub">Admin: everything, and this page. Operator: see everything,
|
||||
Start / Stop a Run. Viewer: see only. Users are disabled, never deleted.</p>
|
||||
<p class="sub">Super Admin: everything, adds and changes Admins and Viewers.
|
||||
Admin: everything, adds and changes Viewers. Viewer: sees the claims and
|
||||
Runs, changes nothing. Users are disabled, never deleted.</p>
|
||||
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
|
||||
{% if message %}<div class="msg good">{{ message }}</div>{% endif %}
|
||||
|
||||
@@ -59,11 +60,14 @@
|
||||
</td>
|
||||
<td>{{ u.created_at | usdate }}<br><span class="note">by {{ u.created_by }}</span></td>
|
||||
<td>
|
||||
{% if u.role in roles and u.username != user.username %}
|
||||
{% if roles | length > 1 %}
|
||||
<form method="post" action="/admin/users/{{ u.username }}/role">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<select name="role">{% for r in roles %}<option{% if r == u.role %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
|
||||
<button type="submit" class="plain">Set role</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/users/{{ u.username }}/reset" autocomplete="off">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<input name="password" type="password" placeholder="New first password" autocomplete="new-password" minlength="10" required>
|
||||
@@ -74,12 +78,13 @@
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<button type="submit" class="plain">Enable</button>
|
||||
</form>
|
||||
{% elif u.username != user.username %}
|
||||
{% else %}
|
||||
<form method="post" action="/admin/users/{{ u.username }}/disable">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<button type="submit" class="plain">Disable</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}<span class="note">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
Reference in new issue
Block a user