Staff were stuck pasting a raw bearer token with no reset. Now: a proper /login page (email+password)
that exchanges to the staff member's existing token (SPA unchanged downstream). PBKDF2 hashing (stdlib,
no bcrypt dep). New: POST /auth/login, POST /auth/change-password (self), POST /admin/staff/{id}/password
(admin reset). staff gains email(unique)/phone/pay_rate/password_hash. Staff admin UI: add/edit details,
set-password, and timecards now show pay (hours × rate). admin.html redirects to /login when unauthed +
bounces expired tokens; nav gets Change-password + Sign-out. Owner master token still works (break-glass on
the login page). Verified e2e: login, wrong-pw 401, token auth, admin reset invalidates old pw.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
4.3 KiB
HTML
95 lines
4.3 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Sign in · RecordGod</title>
|
|
<style>
|
|
:root{ --pink:#c2185b; --line:#e6e6ea; --ink:#16161d; --mut:#7a7a85; }
|
|
*{ box-sizing:border-box }
|
|
body{ margin:0; min-height:100vh; display:grid; place-items:center;
|
|
background:#f4f4f6; color:var(--ink); font:15px/1.45 system-ui,-apple-system,sans-serif }
|
|
.card{ width:340px; max-width:92vw; background:#fff; border:1px solid var(--line);
|
|
border-radius:16px; padding:30px 26px; box-shadow:0 10px 40px rgba(0,0,0,.07) }
|
|
.brand{ font-size:26px; font-weight:800; letter-spacing:-.5px; margin:0 0 2px }
|
|
.brand b{ color:var(--pink) }
|
|
.sub{ color:var(--mut); font-size:13px; margin:0 0 22px }
|
|
label{ display:block; font-size:12px; color:var(--mut); margin:14px 0 4px; text-transform:uppercase; letter-spacing:.4px }
|
|
input{ width:100%; padding:11px 12px; border:1px solid var(--line); border-radius:9px; font-size:15px; background:#fafafb }
|
|
input:focus{ outline:none; border-color:var(--pink); background:#fff }
|
|
button{ width:100%; margin-top:20px; padding:12px; border:none; border-radius:9px; background:var(--pink);
|
|
color:#fff; font-size:15px; font-weight:700; cursor:pointer }
|
|
button:hover{ filter:brightness(1.06) } button:disabled{ opacity:.6; cursor:default }
|
|
.err{ color:#c0392b; font-size:13px; min-height:18px; margin-top:12px; text-align:center }
|
|
.alt{ margin-top:18px; text-align:center; font-size:12px }
|
|
.alt a{ color:var(--mut); cursor:pointer; text-decoration:underline }
|
|
.tokrow{ display:none; margin-top:14px }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<form class="card" id="form" onsubmit="return doLogin(event)">
|
|
<div class="brand">Record<b>God</b></div>
|
|
<div class="sub">Staff sign-in</div>
|
|
|
|
<div id="pwbox">
|
|
<label for="email">Email</label>
|
|
<input id="email" type="email" autocomplete="username" placeholder="you@monsterrobot.party" autofocus>
|
|
<label for="pw">Password</label>
|
|
<input id="pw" type="password" autocomplete="current-password" placeholder="••••••••">
|
|
<button type="submit" id="go">Sign in</button>
|
|
</div>
|
|
|
|
<div class="tokrow" id="tokbox">
|
|
<label for="tok">Access token</label>
|
|
<input id="tok" type="password" placeholder="rg_… (owner / break-glass)">
|
|
<button type="button" onclick="tokenLogin()">Sign in with token</button>
|
|
</div>
|
|
|
|
<div class="err" id="err"></div>
|
|
<div class="alt"><a id="toggle" onclick="toggleMode()">Use an access token instead</a></div>
|
|
</form>
|
|
|
|
<script>
|
|
// already signed in? straight through
|
|
if (localStorage.getItem('rg_token')) location.replace('/admin');
|
|
|
|
const $ = s => document.querySelector(s);
|
|
function finish(token, name, role){
|
|
localStorage.setItem('rg_token', token);
|
|
if (name) localStorage.setItem('rg_name', name);
|
|
if (role) localStorage.setItem('rg_role', role);
|
|
location.replace('/admin');
|
|
}
|
|
async function doLogin(e){
|
|
e.preventDefault();
|
|
const email = $('#email').value.trim(), password = $('#pw').value;
|
|
if (!email || !password){ $('#err').textContent = 'enter your email and password'; return false; }
|
|
$('#go').disabled = true; $('#err').textContent = '';
|
|
try {
|
|
const r = await fetch('/auth/login', { method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ email, password }) });
|
|
const d = await r.json();
|
|
if (r.ok && d.token) finish(d.token, d.name, d.role);
|
|
else { $('#err').textContent = d.detail || 'wrong email or password'; $('#go').disabled = false; }
|
|
} catch(_) { $('#err').textContent = 'network error'; $('#go').disabled = false; }
|
|
return false;
|
|
}
|
|
async function tokenLogin(){
|
|
const tok = $('#tok').value.trim(); if (!tok) return;
|
|
try {
|
|
const r = await fetch('/admin/me', { headers:{ 'Authorization':'Bearer '+tok } });
|
|
if (!r.ok) throw 0;
|
|
const me = await r.json(); finish(tok, me.name, me.role);
|
|
} catch(_) { $('#err').textContent = 'invalid token'; }
|
|
}
|
|
function toggleMode(){
|
|
const t = $('#tokbox'), p = $('#pwbox');
|
|
const showTok = t.style.display !== 'block';
|
|
t.style.display = showTok ? 'block' : 'none';
|
|
p.style.display = showTok ? 'none' : 'block';
|
|
$('#toggle').textContent = showTok ? 'Use email and password instead' : 'Use an access token instead';
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|