Session hardening: / and /admin now respond with Cache-Control: no-store, and
both pages re-derive their auth state on pageshow (event.persisted) as a
safety net against bfcache showing a stale logged-in/out view across
back/forward navigation. The user page also syncs logout across tabs via the
storage event, since localStorage is shared but in-memory JS state isn't.
Password recovery: admin gets a "Reset" button per user (POST
/admin/users/{id}/reset-password) that generates and sets a new password,
shown once — passwords are Argon2-hashed and can never be recovered, only
replaced. Users get self-service password change (POST
/users/me/change-password, requires the current password) under a new
Profilo tab, alongside read-only account info (username, address, balance,
join date).
Round display robustness: the user dashboard now refreshes immediately on
tab visibility change (background tabs get their timers throttled hard),
shows an explicit "connessione persa" state after repeated failed polls
instead of silently freezing on stale data, and polls faster both right when
the countdown hits zero and through the gap where the round is past its
deadline but still waiting for in-flight bets to confirm before the server
actually closes it.
55 lines
1.8 KiB
Python
55 lines
1.8 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth.dependencies import get_current_user
|
|
from app.auth.security import hash_password, verify_password
|
|
from app.db.models import User
|
|
from app.db.session import get_session
|
|
|
|
router = APIRouter(prefix="/users", tags=["users"])
|
|
|
|
_MIN_PASSWORD_LENGTH = 8
|
|
|
|
|
|
class MeResponse(BaseModel):
|
|
id: int
|
|
username: str
|
|
address: str
|
|
balance_sats: int
|
|
created_at: str
|
|
|
|
|
|
@router.get("/me", response_model=MeResponse)
|
|
async def me(user: User = Depends(get_current_user)) -> MeResponse:
|
|
return MeResponse(
|
|
id=user.id,
|
|
username=user.username,
|
|
address=user.address,
|
|
balance_sats=user.cached_balance_sats,
|
|
created_at=user.created_at.isoformat(),
|
|
)
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def change_password(
|
|
body: ChangePasswordRequest,
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> None:
|
|
"""Self-service password change — requires the current password, unlike the
|
|
admin-only /admin/users/{id}/reset-password (which is for a user who's
|
|
actually locked out and can't provide it)."""
|
|
if not verify_password(body.current_password, user.password_hash):
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "current password is incorrect")
|
|
if len(body.new_password) < _MIN_PASSWORD_LENGTH:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"new password must be at least {_MIN_PASSWORD_LENGTH} characters")
|
|
|
|
user.password_hash = hash_password(body.new_password)
|
|
await session.commit()
|