mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
803d157d25
commit
68a35e5bff
3 changed files with 67 additions and 11 deletions
57
main.py
57
main.py
|
|
@ -101,8 +101,8 @@ def init_db():
|
|||
name TEXT NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'staff'
|
||||
CHECK(role IN ('staff','admin')),
|
||||
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'))
|
||||
);
|
||||
|
|
@ -114,6 +114,36 @@ def init_db():
|
|||
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():
|
||||
with db_conn() as conn:
|
||||
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)):
|
||||
if user["role"] != "admin":
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -191,6 +231,7 @@ def admin_user(user: dict = Depends(current_user)):
|
|||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
init_db()
|
||||
migrate_db()
|
||||
seed_admin()
|
||||
refresh_settings()
|
||||
yield
|
||||
|
|
@ -257,7 +298,7 @@ class StaffAccountCreate(BaseModel):
|
|||
name: str
|
||||
username: str
|
||||
password: str
|
||||
role: str = "staff"
|
||||
role: str = "pos-staff"
|
||||
|
||||
class StaffAccountUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
|
@ -409,7 +450,7 @@ def list_members(q: Optional[str] = None, user: dict = Depends(current_user)):
|
|||
return result
|
||||
|
||||
@app.post("/topup")
|
||||
def topup(body: TopupRequest, user: dict = Depends(current_user)):
|
||||
def topup(body: TopupRequest, user: dict = Depends(cashier_user)):
|
||||
s = _settings
|
||||
if body.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)}
|
||||
|
||||
@app.post("/charge")
|
||||
def charge(body: ChargeRequest, user: dict = Depends(current_user)):
|
||||
def charge(body: ChargeRequest, user: dict = Depends(pos_user)):
|
||||
s = _settings
|
||||
if body.amount <= 0:
|
||||
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")
|
||||
def create_staff_account(body: StaffAccountCreate, user: dict = Depends(admin_user)):
|
||||
if body.role not in ("staff", "admin"):
|
||||
raise HTTPException(400, "Role must be 'staff' or 'admin'")
|
||||
if body.role not in ("cashier", "pos-staff", "admin"):
|
||||
raise HTTPException(400, "Role must be 'cashier', 'pos-staff', or 'admin'")
|
||||
with db_conn() as conn:
|
||||
if conn.execute("SELECT id FROM staff_accounts WHERE username=?",
|
||||
(body.username.strip(),)).fetchone():
|
||||
|
|
@ -702,7 +743,7 @@ def update_staff_account(account_id: int, body: StaffAccountUpdate,
|
|||
if body.password is not None:
|
||||
updates["password_hash"] = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode()
|
||||
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
|
||||
if body.active is not None:
|
||||
updates["active"] = 1 if body.active else 0
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ async function doLogin(e) {
|
|||
async function doLogout() {
|
||||
try { await fetch('/auth/logout', { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
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
|
||||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('[data-view="members"]').classList.add('active');
|
||||
|
|
@ -65,9 +69,16 @@ async function startApp() {
|
|||
if (brand) brand.textContent = cfg.club_name;
|
||||
document.getElementById('navUser').textContent = currentUser.name;
|
||||
|
||||
// Role-based tab visibility
|
||||
if (currentUser.role === 'admin') {
|
||||
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
|
||||
document.querySelectorAll('.nav-btn').forEach(btn => {
|
||||
|
|
@ -98,6 +109,10 @@ async function startApp() {
|
|||
// ---------------------------------------------------------------------------
|
||||
// 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) {
|
||||
const v = parseFloat(document.getElementById(inputId).value);
|
||||
if (isNaN(v) || v <= 0) return null;
|
||||
|
|
@ -358,7 +373,7 @@ async function loadStaffAccounts() {
|
|||
<tr>
|
||||
<td>${esc(a.name)}</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 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>
|
||||
|
|
|
|||
|
|
@ -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>Password</label><input type="password" id="acc-password" required autocomplete="new-password"></div>
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary">Add Account</button>
|
||||
</form>
|
||||
|
|
@ -223,7 +223,7 @@
|
|||
<input type="password" id="eacc-password" placeholder="Leave blank to keep" autocomplete="new-password">
|
||||
</div>
|
||||
<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 class="form-row form-row-check">
|
||||
<input type="checkbox" id="eacc-active">
|
||||
|
|
|
|||
Loading…
Reference in a new issue