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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue