Split staff role into cashier/pos-staff; fix admin tab visibility

Roles:
- cashier: Members + Cashier (top-up) tabs; POST /topup enforced at API level
- pos-staff: Members + Bar (charge) tabs; POST /charge enforced at API level
- admin: all tabs including Admin panel

Changes:
- migrate_db() recreates staff_accounts with new CHECK constraint and
  converts any existing 'staff' rows to 'pos-staff' on startup
- cashier_user / pos_user FastAPI dependencies added; applied to /topup and /charge
- Role dropdowns in admin panel updated to the three new values
- startApp() hides irrelevant tabs per role on login
- doLogout() resets all tab visibility so the next login starts clean
- fmtRole() formats role names ('pos-staff' → 'POS Staff') in the accounts table

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 14:19:41 +00:00
parent 803d157d25
commit 68a35e5bff
No known key found for this signature in database
3 changed files with 67 additions and 11 deletions

57
main.py
View file

@ -101,8 +101,8 @@ def init_db():
name TEXT NOT NULL, name TEXT NOT NULL,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'staff' role TEXT NOT NULL DEFAULT 'pos-staff'
CHECK(role IN ('staff','admin')), CHECK(role IN ('cashier','pos-staff','admin')),
active INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@ -114,6 +114,36 @@ def init_db():
ON ledger_entries(member_id); ON ledger_entries(member_id);
""") """)
def migrate_db():
"""Run schema migrations that can't be expressed as CREATE TABLE IF NOT EXISTS."""
with db_conn() as conn:
schema = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='staff_accounts'"
).fetchone()
if schema and "'pos-staff'" not in schema["sql"]:
# Recreate staff_accounts with new role set; convert 'staff' → 'pos-staff'
conn.execute("ALTER TABLE staff_accounts RENAME TO _staff_accounts_old")
conn.execute("""
CREATE TABLE staff_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'pos-staff'
CHECK(role IN ('cashier','pos-staff','admin')),
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
INSERT INTO staff_accounts
SELECT id, name, username, password_hash,
CASE role WHEN 'staff' THEN 'pos-staff' ELSE role END,
active, created_at
FROM _staff_accounts_old
""")
conn.execute("DROP TABLE _staff_accounts_old")
def seed_admin(): def seed_admin():
with db_conn() as conn: with db_conn() as conn:
if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0: if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0:
@ -182,6 +212,16 @@ def current_user(session: Optional[str] = Cookie(default=None)):
def admin_user(user: dict = Depends(current_user)): def admin_user(user: dict = Depends(current_user)):
if user["role"] != "admin": if user["role"] != "admin":
raise HTTPException(403, "Admin access required") raise HTTPException(403, "Admin access required")
def cashier_user(user: dict = Depends(current_user)):
if user["role"] not in ("cashier", "admin"):
raise HTTPException(403, "Cashier access required")
return user
def pos_user(user: dict = Depends(current_user)):
if user["role"] not in ("pos-staff", "admin"):
raise HTTPException(403, "POS staff access required")
return user
return user return user
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -191,6 +231,7 @@ def admin_user(user: dict = Depends(current_user)):
@asynccontextmanager @asynccontextmanager
async def lifespan(app): async def lifespan(app):
init_db() init_db()
migrate_db()
seed_admin() seed_admin()
refresh_settings() refresh_settings()
yield yield
@ -257,7 +298,7 @@ class StaffAccountCreate(BaseModel):
name: str name: str
username: str username: str
password: str password: str
role: str = "staff" role: str = "pos-staff"
class StaffAccountUpdate(BaseModel): class StaffAccountUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
@ -409,7 +450,7 @@ def list_members(q: Optional[str] = None, user: dict = Depends(current_user)):
return result return result
@app.post("/topup") @app.post("/topup")
def topup(body: TopupRequest, user: dict = Depends(current_user)): def topup(body: TopupRequest, user: dict = Depends(cashier_user)):
s = _settings s = _settings
if body.amount < s["min_topup"]: if body.amount < s["min_topup"]:
raise HTTPException(400, f"Minimum top-up is {format_amount(s['min_topup'])}") raise HTTPException(400, f"Minimum top-up is {format_amount(s['min_topup'])}")
@ -427,7 +468,7 @@ def topup(body: TopupRequest, user: dict = Depends(current_user)):
return {"ok": True, "entry_id": eid, "new_balance": bal, "new_balance_display": format_amount(bal)} return {"ok": True, "entry_id": eid, "new_balance": bal, "new_balance_display": format_amount(bal)}
@app.post("/charge") @app.post("/charge")
def charge(body: ChargeRequest, user: dict = Depends(current_user)): def charge(body: ChargeRequest, user: dict = Depends(pos_user)):
s = _settings s = _settings
if body.amount <= 0: if body.amount <= 0:
raise HTTPException(400, "Amount must be positive") raise HTTPException(400, "Amount must be positive")
@ -673,8 +714,8 @@ def list_staff_accounts(user: dict = Depends(admin_user)):
@app.post("/admin/staff-accounts") @app.post("/admin/staff-accounts")
def create_staff_account(body: StaffAccountCreate, user: dict = Depends(admin_user)): def create_staff_account(body: StaffAccountCreate, user: dict = Depends(admin_user)):
if body.role not in ("staff", "admin"): if body.role not in ("cashier", "pos-staff", "admin"):
raise HTTPException(400, "Role must be 'staff' or 'admin'") raise HTTPException(400, "Role must be 'cashier', 'pos-staff', or 'admin'")
with db_conn() as conn: with db_conn() as conn:
if conn.execute("SELECT id FROM staff_accounts WHERE username=?", if conn.execute("SELECT id FROM staff_accounts WHERE username=?",
(body.username.strip(),)).fetchone(): (body.username.strip(),)).fetchone():
@ -702,7 +743,7 @@ def update_staff_account(account_id: int, body: StaffAccountUpdate,
if body.password is not None: if body.password is not None:
updates["password_hash"] = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode() updates["password_hash"] = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode()
if body.role is not None: if body.role is not None:
if body.role not in ("staff","admin"): raise HTTPException(400, "Invalid role") if body.role not in ("cashier","pos-staff","admin"): raise HTTPException(400, "Invalid role")
updates["role"] = body.role updates["role"] = body.role
if body.active is not None: if body.active is not None:
updates["active"] = 1 if body.active else 0 updates["active"] = 1 if body.active else 0

View file

@ -49,6 +49,10 @@ async function doLogin(e) {
async function doLogout() { async function doLogout() {
try { await fetch('/auth/logout', { method: 'POST' }); } catch (e) { /* ignore */ } try { await fetch('/auth/logout', { method: 'POST' }); } catch (e) { /* ignore */ }
currentUser = null; currentUser = null;
// Reset tab visibility for next login
document.getElementById('adminTabBtn').classList.add('hidden');
document.querySelector('[data-view="cashier"]').classList.remove('hidden');
document.querySelector('[data-view="bar"]').classList.remove('hidden');
// Reset to members tab // Reset to members tab
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.querySelector('[data-view="members"]').classList.add('active'); document.querySelector('[data-view="members"]').classList.add('active');
@ -65,9 +69,16 @@ async function startApp() {
if (brand) brand.textContent = cfg.club_name; if (brand) brand.textContent = cfg.club_name;
document.getElementById('navUser').textContent = currentUser.name; document.getElementById('navUser').textContent = currentUser.name;
// Role-based tab visibility
if (currentUser.role === 'admin') { if (currentUser.role === 'admin') {
document.getElementById('adminTabBtn').classList.remove('hidden'); document.getElementById('adminTabBtn').classList.remove('hidden');
} }
if (currentUser.role === 'pos-staff') {
document.querySelector('[data-view="cashier"]').classList.add('hidden');
}
if (currentUser.role === 'cashier') {
document.querySelector('[data-view="bar"]').classList.add('hidden');
}
// Nav tab switching // Nav tab switching
document.querySelectorAll('.nav-btn').forEach(btn => { document.querySelectorAll('.nav-btn').forEach(btn => {
@ -98,6 +109,10 @@ async function startApp() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Amount helpers (users enter major units, we send minor units) // Amount helpers (users enter major units, we send minor units)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ROLE_LABELS = { admin: 'Admin', cashier: 'Cashier', 'pos-staff': 'POS Staff' };
function fmtRole(role) { return ROLE_LABELS[role] || role; }
function toMinor(inputId) { function toMinor(inputId) {
const v = parseFloat(document.getElementById(inputId).value); const v = parseFloat(document.getElementById(inputId).value);
if (isNaN(v) || v <= 0) return null; if (isNaN(v) || v <= 0) return null;
@ -358,7 +373,7 @@ async function loadStaffAccounts() {
<tr> <tr>
<td>${esc(a.name)}</td> <td>${esc(a.name)}</td>
<td>${esc(a.username)}</td> <td>${esc(a.username)}</td>
<td style="text-transform:capitalize">${esc(a.role)}</td> <td>${esc(fmtRole(a.role))}</td>
<td>${a.active ? '<span style="color:#080">Active</span>' : '<span style="color:#999">Inactive</span>'}</td> <td>${a.active ? '<span style="color:#080">Active</span>' : '<span style="color:#999">Inactive</span>'}</td>
<td class="row-actions"> <td class="row-actions">
<button class="btn row-btn" onclick="openEditAccountModal(${a.id},'${esc(a.name)}','${esc(a.username)}','${esc(a.role)}',${a.active})">Edit</button> <button class="btn row-btn" onclick="openEditAccountModal(${a.id},'${esc(a.name)}','${esc(a.username)}','${esc(a.role)}',${a.active})">Edit</button>

View file

@ -182,7 +182,7 @@
<div class="form-row"><label>Username</label><input type="text" id="acc-username" required autocomplete="off"></div> <div class="form-row"><label>Username</label><input type="text" id="acc-username" required autocomplete="off"></div>
<div class="form-row"><label>Password</label><input type="password" id="acc-password" required autocomplete="new-password"></div> <div class="form-row"><label>Password</label><input type="password" id="acc-password" required autocomplete="new-password"></div>
<div class="form-row"><label>Role</label> <div class="form-row"><label>Role</label>
<select id="acc-role"><option value="staff">Staff</option><option value="admin">Admin</option></select> <select id="acc-role"><option value="pos-staff">POS Staff</option><option value="cashier">Cashier</option><option value="admin">Admin</option></select>
</div> </div>
<button type="submit" class="btn btn-primary">Add Account</button> <button type="submit" class="btn btn-primary">Add Account</button>
</form> </form>
@ -223,7 +223,7 @@
<input type="password" id="eacc-password" placeholder="Leave blank to keep" autocomplete="new-password"> <input type="password" id="eacc-password" placeholder="Leave blank to keep" autocomplete="new-password">
</div> </div>
<div class="form-row"><label>Role</label> <div class="form-row"><label>Role</label>
<select id="eacc-role"><option value="staff">Staff</option><option value="admin">Admin</option></select> <select id="eacc-role"><option value="pos-staff">POS Staff</option><option value="cashier">Cashier</option><option value="admin">Admin</option></select>
</div> </div>
<div class="form-row form-row-check"> <div class="form-row form-row-check">
<input type="checkbox" id="eacc-active"> <input type="checkbox" id="eacc-active">