7 Commits

Author SHA1 Message Date
IT狗 acbc767247 feat(auth): switch to bcrypt primary scheme, keep sha256_crypt for migration
- database.py: CryptContext = ['bcrypt', 'sha256_crypt'] with sha256_crypt deprecated
- database.py: add verify_and_update_password() returning (ok, new_hash)
- main.py: login uses verify_and_update; on successful verify transparently
  commits new bcrypt hash when old hash is sha256_crypt
- requirements.txt: passlib[bcrypt]==1.7.4 + bcrypt==4.0.1 (compat with passlib 1.7.4)

Migration: zero-touch. Admin's first login after this deploy will
auto-upgrade the stored hash from $5$... to $2b$...

Avoids the passlib sha256_crypt cross-version verify bug that bit us
this morning. bcrypt is OpenSSL-native and stable across passlib
versions.
2026-07-22 12:07:51 +08:00
IT狗 f6712d9d0e test(auth): smoke test for /api/auth/users + reset-password 2026-07-22 11:02:11 +08:00
IT狗 262c44f6a1 feat(auth): user management UI in Settings + GET /api/auth/users
- New: GET /api/auth/users (admin only) returns [{id, email, name, role, is_active}]
- New: 'User Management' card in Settings page
  - Lists all users with role badge (admin/user) and inactive indicator
  - 'Reset Password' button per user
  - Modal: enter new password (>=6 chars), confirm/cancel
  - Toast success / inline error
  - Esc closes modal, click backdrop closes
  - ESC + Enter shortcuts
  - Non-admin (or 403) shows empty state
2026-07-22 11:00:48 +08:00
IT狗 3c855d521b fix(auth): import get_password_hash in main.py 2026-07-22 10:57:25 +08:00
IT狗 5596ab2cb5 feat(auth): add admin-only /api/auth/reset-password endpoint
POST /api/auth/reset-password
Body: { email, new_password }
Auth: requires admin role (get_current_admin)
Response: { ok, email, id, reset_by }

- Validates new_password >= 6 chars
- Returns 404 if target email not found
- Logs admin_id + target_user_id for audit trail
- Fixes passlib sha256_crypt cross-version verify bug by allowing
  admin to rehash any user's password on demand
2026-07-22 10:56:09 +08:00
IT狗 a7dfd208d2 fix(dashboard): use backend attendance_rate instead of broken client formula
Old formula: (total - late - abnormal) / total
- Did not subtract missing (so 100% with 缺勤 2)
- Subtracted abnormal twice (already hidden late/early/ot)
- Could never reach 0%

New: read backend's pre-computed attendance_rate
  = (total - missing) / total * 100

Wing Fung (10 rec, 2 missing) now 80% (was 100%)
Cindy (10 rec, 4 missing) now 60% (was 90%)
2026-07-22 10:41:11 +08:00
IT狗 a16e243da1 fix(dashboard): include abnormal-override records in component counts
status_code='abnormal' records (>30 min) hide the underlying late/early/ot
component from by_status. Use *_minutes>0 union to surface them so the
dashboard count matches the real data.

Late: 9 → 23, Early: 0 → 17, OT: 15 → 28
Note: component totals may now exceed total_records because each record
can carry multiple status components (e.g. late_ot).
2026-07-22 09:55:18 +08:00
6 changed files with 339 additions and 12 deletions
+14 -1
View File
@@ -15,7 +15,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()
pwd_context = CryptContext(schemes=["sha256_crypt"], deprecated="auto") # bcrypt is the primary scheme (cross-version stable, OpenSSL-native).
# sha256_crypt is kept as deprecated fallback so legacy hashes still
# verify; on first successful login the password is re-hashed to bcrypt
# via verify_and_update().
pwd_context = CryptContext(schemes=["bcrypt", "sha256_crypt"], deprecated=["sha256_crypt"])
# JWT Settings # JWT Settings
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production" SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production"
@@ -34,6 +38,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password) return pwd_context.verify(plain_password, hashed_password)
def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, str | None]:
"""Verify password and return new hash if rehash is needed.
Returns (ok, new_hash_or_none). Caller is responsible for committing
the new hash to the DB when new_hash is not None.
"""
return pwd_context.verify_and_update(plain_password, hashed_password)
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
return pwd_context.hash(password) return pwd_context.hash(password)
+96 -5
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Optional, List from typing import Optional, List
import json import json
from database import get_db, create_access_token, verify_password, init_db, Base, engine from database import get_db, create_access_token, verify_password, verify_and_update_password, get_password_hash, init_db, Base, engine
from models import User, AttendanceRecord, Shift, Accident, Holiday, LeaveRecord from models import User, AttendanceRecord, Shift, Accident, Holiday, LeaveRecord
from sqlalchemy import func as sqlfunc from sqlalchemy import func as sqlfunc
from schemas import * from schemas import *
@@ -254,9 +254,20 @@ async def startup():
@app.post("/api/auth/login", response_model=TokenResponse) @app.post("/api/auth/login", response_model=TokenResponse)
async def login(form_data: LoginRequest, db: Session = Depends(get_db)): async def login(form_data: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form_data.email).first() user = db.query(User).filter(User.email == form_data.email).first()
if not user or not verify_password(form_data.password, user.password_hash): if not user:
logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "user_not_found"}})
raise HTTPException(status_code=401, detail="Invalid email or password")
# verify_and_update returns (ok, new_hash_if_needs_rehash)
ok, new_hash = verify_and_update_password(form_data.password, user.password_hash)
if not ok:
logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "invalid_credentials"}}) logger.warning("login failed", extra={"context": {"email": form_data.email, "reason": "invalid_credentials"}})
raise HTTPException(status_code=401, detail="Invalid email or password") raise HTTPException(status_code=401, detail="Invalid email or password")
# Migrate hash scheme in place (e.g. sha256_crypt -> bcrypt) without forcing logout
if new_hash:
user.password_hash = new_hash
db.commit()
db.refresh(user)
logger.info("password hash upgraded", extra={"context": {"user_id": user.id, "email": form_data.email}})
set_user_id(user.id) set_user_id(user.id)
access_token = create_access_token({"sub": str(user.id)}) access_token = create_access_token({"sub": str(user.id)})
logger.info("login success", extra={"context": {"user_id": user.id, "email": form_data.email}}) logger.info("login success", extra={"context": {"user_id": user.id, "email": form_data.email}})
@@ -266,6 +277,64 @@ async def login(form_data: LoginRequest, db: Session = Depends(get_db)):
async def get_me(current_user: User = Depends(get_current_user)): async def get_me(current_user: User = Depends(get_current_user)):
return current_user return current_user
class ResetPasswordRequest(BaseModel):
email: str
new_password: str
@app.post("/api/auth/reset-password")
async def reset_password(
payload: ResetPasswordRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Admin-only: reset another user's password.
Body: {"email": "<target email>", "new_password": "<new plaintext>"}
"""
if not payload.email or not payload.new_password:
raise HTTPException(status_code=400, detail="email and new_password are required")
if len(payload.new_password) < 6:
raise HTTPException(status_code=400, detail="new_password must be at least 6 characters")
target = db.query(User).filter(User.email == payload.email).first()
if not target:
raise HTTPException(status_code=404, detail=f"User not found: {payload.email}")
target.password_hash = get_password_hash(payload.new_password)
db.commit()
db.refresh(target)
logger.info(
"password reset by admin",
extra={"context": {"admin_id": current_user.id, "target_user_id": target.id, "email": payload.email}},
)
return {
"ok": True,
"email": target.email,
"id": target.id,
"reset_by": current_user.email,
}
@app.get("/api/auth/users")
async def list_users(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
"""Admin-only: list all users (id, email, name, role, is_active)."""
users = db.query(User).order_by(User.id.asc()).all()
return [
{
"id": u.id,
"email": u.email,
"name": u.name,
"role": u.role,
"is_active": u.is_active,
}
for u in users
]
# ============ Excel File Storage ============ # ============ Excel File Storage ============
def get_excel_path(section: str) -> str: def get_excel_path(section: str) -> str:
"""Get path to the stored Excel file for a section""" """Get path to the stored Excel file for a section"""
@@ -1085,6 +1154,25 @@ async def dashboard_summary(
if r.employee_name: if r.employee_name:
staff_set.add(r.employee_name) staff_set.add(r.employee_name)
# Component counts: union of status_code match AND minutes field
# (handles abnormal override where status_code='abnormal' hides
# the underlying late/early/ot component from by_status)
late_count = (
sum(v for k, v in by_status.items() if "late" in k)
+ sum(1 for r in rows if r.late_minutes and r.late_minutes > 0
and (r.status_code or "").lower() == "abnormal")
)
early_count = (
sum(v for k, v in by_status.items() if "early" in k)
+ sum(1 for r in rows if r.early_minutes and r.early_minutes > 0
and (r.status_code or "").lower() == "abnormal")
)
ot_count = (
sum(v for k, v in by_status.items() if "ot" in k)
+ sum(1 for r in rows if r.ot_minutes and r.ot_minutes > 0
and (r.status_code or "").lower() == "abnormal")
)
return { return {
# Backend / dashboard canonical fields # Backend / dashboard canonical fields
"total": len(rows), "total": len(rows),
@@ -1094,11 +1182,14 @@ async def dashboard_summary(
"by_department": by_department, "by_department": by_department,
"trend": [], "trend": [],
# Frontend-expected fields (alias for legacy UI) # Frontend-expected fields (alias for legacy UI)
# Component counts: late + early + ot may exceed total because
# a single record can carry multiple status components
# (e.g. status_code='late_ot' contributes to both late and ot).
"total_records": len(rows), "total_records": len(rows),
"normal_count": by_status.get("normal", 0), "normal_count": by_status.get("normal", 0),
"late_count": sum(v for k, v in by_status.items() if "late" in k), "late_count": late_count,
"early_count": sum(v for k, v in by_status.items() if "early" in k), "early_count": early_count,
"ot_count": sum(v for k, v in by_status.items() if "ot" in k), "ot_count": ot_count,
"missing_count": by_status.get("missing", 0), "missing_count": by_status.get("missing", 0),
"abnormal_count": by_status.get("abnormal", 0), "abnormal_count": by_status.get("abnormal", 0),
"staff_count": len(staff_set), "staff_count": len(staff_set),
+2 -1
View File
@@ -3,7 +3,8 @@ uvicorn[standard]==0.30.6
sqlalchemy==2.0.35 sqlalchemy==2.0.35
pydantic==2.9.2 pydantic==2.9.2
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.12 python-multipart==0.0.12
openpyxl==3.1.5 openpyxl==3.1.5
reportlab==4.2.5 reportlab==4.2.5
+6 -4
View File
@@ -368,10 +368,12 @@ export default function Dashboard() {
const otRank = [...byStaff].sort((a, b) => (b.ot_count || 0) - (a.ot_count || 0)) const otRank = [...byStaff].sort((a, b) => (b.ot_count || 0) - (a.ot_count || 0))
const abnormalRank = [...byStaff].sort((a, b) => (b.abnormal_count || 0) - (a.abnormal_count || 0)) const abnormalRank = [...byStaff].sort((a, b) => (b.abnormal_count || 0) - (a.abnormal_count || 0))
const missingRank = [...byStaff].sort((a, b) => (b.missing_count || 0) - (a.missing_count || 0)) const missingRank = [...byStaff].sort((a, b) => (b.missing_count || 0) - (a.missing_count || 0))
const attendanceRate = [...byStaff].map(s => ({ // Use backend's pre-computed attendance_rate = (total - missing) / total * 100
...s, // (handles missing records correctly; a record can still be late/early/ot
rate: (s.total_records || s.total || 0) > 0 ? Math.round(((((s.total_records || s.total || 0) - (s.late_count || 0) - (s.abnormal_count || 0))) / (s.total_records || s.total || 0)) * 100) : 0 // and count as attended)
})).sort((a, b) => b.rate - a.rate) const attendanceRate = [...byStaff]
.map(s => ({ ...s, rate: s.attendance_rate ?? 0 }))
.sort((a, b) => b.rate - a.rate)
const statCards = summary ? [ const statCards = summary ? [
{ label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' }, { label: '總記錄', value: summary.total_records, icon: Calendar, color: 'bg-blue-50 text-blue-600' },
+174 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info } from "lucide-react" import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info, KeyRound, User as UserIcon } from "lucide-react"
import api from "../api" import api from "../api"
import toast from "react-hot-toast" import toast from "react-hot-toast"
@@ -41,11 +41,68 @@ export default function Settings() {
const [restoring, setRestoring] = useState(null) const [restoring, setRestoring] = useState(null)
const [restoreConfirm, setRestoreConfirm] = useState("") const [restoreConfirm, setRestoreConfirm] = useState("")
// User management (admin only)
const [users, setUsers] = useState([])
const [loadingUsers, setLoadingUsers] = useState(false)
const [resetTarget, setResetTarget] = useState(null)
const [newPassword, setNewPassword] = useState("")
const [resetting, setResetting] = useState(false)
const [resetError, setResetError] = useState(null)
useEffect(() => { useEffect(() => {
fetchVersion() fetchVersion()
fetchBackups() fetchBackups()
fetchUsers()
}, []) }, [])
const fetchUsers = async () => {
setLoadingUsers(true)
try {
const { data } = await api.get("/api/auth/users")
setUsers(data || [])
} catch (e) {
// 403 if not admin — leave list empty
setUsers([])
} finally {
setLoadingUsers(false)
}
}
const openResetDialog = (u) => {
setResetTarget(u)
setNewPassword("")
setResetError(null)
}
const closeResetDialog = () => {
if (resetting) return
setResetTarget(null)
setNewPassword("")
setResetError(null)
}
const handleResetPassword = async () => {
if (!resetTarget || !newPassword) return
if (newPassword.length < 6) {
setResetError("密碼至少需要 6 個字元")
return
}
setResetting(true)
setResetError(null)
try {
await api.post("/api/auth/reset-password", {
email: resetTarget.email,
new_password: newPassword,
})
toast.success(`已重設 ${resetTarget.email} 嘅密碼`)
closeResetDialog()
} catch (e) {
setResetError(e.response?.data?.detail || e.message)
} finally {
setResetting(false)
}
}
const fetchVersion = async () => { const fetchVersion = async () => {
try { try {
const { data } = await api.get("/api/version") const { data } = await api.get("/api/version")
@@ -273,6 +330,122 @@ export default function Settings() {
)} )}
</div> </div>
{/* User Management — admin only */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50">
<div className="flex items-center gap-2">
<UserIcon className="w-5 h-5 text-slate-600" />
<h2 className="text-lg font-semibold text-slate-800">使用者管理 User Management</h2>
</div>
<p className="text-sm text-slate-500 mt-1">
重設使用者密碼Admin
</p>
</div>
<div className="divide-y divide-slate-100">
{loadingUsers && (
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中</div>
)}
{!loadingUsers && users.length === 0 && (
<div className="px-5 py-6 text-sm text-slate-400 text-center">
沒有使用者或你唔係 admin
</div>
)}
{!loadingUsers && users.map((u) => (
<div key={u.id} className="px-5 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
<UserIcon className="w-4 h-4 text-slate-500" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-slate-800 truncate">
{u.name || u.email}
</div>
<div className="text-xs text-slate-500 truncate">
{u.email}
<span className={`ml-2 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${
u.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'
}`}>
{u.role}
</span>
{!u.is_active && (
<span className="ml-1 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-100 text-red-700">
inactive
</span>
)}
</div>
</div>
</div>
<button
onClick={() => openResetDialog(u)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 hover:bg-slate-50 hover:border-slate-400 rounded-md transition-colors"
>
<KeyRound className="w-3.5 h-3.5" />
重設密碼
</button>
</div>
))}
</div>
</div>
{/* Reset Password Modal */}
{resetTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" onClick={closeResetDialog}>
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="px-6 py-4 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-900">重設密碼</h3>
<p className="text-sm text-slate-500 mt-1">
<span className="font-mono text-slate-700">{resetTarget.email}</span> 設定新密碼
</p>
</div>
<div className="px-6 py-5 space-y-3">
<label className="block text-sm font-medium text-slate-700">
新密碼
<input
type="text"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value)
setResetError(null)
}}
placeholder="至少 6 個字元"
className="mt-1.5 block w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleResetPassword()
if (e.key === 'Escape') closeResetDialog()
}}
/>
</label>
{resetError && (
<div className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{resetError}
</div>
)}
</div>
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-2">
<button
onClick={closeResetDialog}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors disabled:opacity-50"
>
取消
</button>
<button
onClick={handleResetPassword}
disabled={resetting || !newPassword}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-md transition-colors"
>
{resetting ? "重設中…" : "確認重設"}
</button>
</div>
</div>
</div>
)}
{/* Danger Zone */} {/* Danger Zone */}
<div className="bg-white border border-red-200 rounded-xl overflow-hidden"> <div className="bg-white border border-red-200 rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-red-100 bg-red-50/50"> <div className="px-5 py-4 border-b border-red-100 bg-red-50/50">
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Smoke test: /api/auth/users + /api/auth/reset-password."""
import json
import sys
import urllib.request
BASE = "http://localhost:8000"
def req(method, path, body=None, token=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body).encode() if body else None
r = urllib.request.Request(f"{BASE}{path}", data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(r, timeout=10) as resp:
return resp.status, json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode() or "{}")
# 1. Login
code, body = req("POST", "/api/auth/login", {"email": "admin@aars.hk", "password": "admin123"})
if code != 200:
print(f"LOGIN FAILED: {code} {body}")
sys.exit(1)
token = body["access_token"]
print(f"✅ login ok, token={token[:30]}...")
# 2. List users
code, body = req("GET", "/api/auth/users", token=token)
print(f"\n--- GET /api/auth/users (code={code}) ---")
print(json.dumps(body, indent=2, ensure_ascii=False))
# 3. Reset password (no actual change, just smoke test)
code, body = req("POST", "/api/auth/reset-password",
{"email": "admin@aars.hk", "new_password": "admin123"},
token=token)
print(f"\n--- POST /api/auth/reset-password (code={code}) ---")
print(json.dumps(body, indent=2, ensure_ascii=False))
# 4. Verify login still works
code, body = req("POST", "/api/auth/login", {"email": "admin@aars.hk", "password": "admin123"})
print(f"\n--- verify login (code={code}) ---")
if code == 200:
print("✅ login still works after reset")
else:
print(f"❌ login broken: {body}")