RECORDGOD/site/login.html
type-two 9a66f59bfe feat(auth): staff email+password login + password reset (login stays token-based under the hood)
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>
2026-06-26 16:24:34 +10:00

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>