Add admin login page for web UI

This commit is contained in:
TronoSfera 2026-01-19 11:26:11 +03:00
parent d4256d6d77
commit dc1fadd928
4 changed files with 102 additions and 8 deletions

View file

@ -19,7 +19,7 @@ from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
@ -36,7 +36,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/login", auto_error=False)
def hash_password(password: str) -> str:
@ -58,7 +58,9 @@ def create_access_token(data: dict, expires_delta: Optional[datetime.timedelta]
async def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
request: Request,
token: str | None = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> models.User:
"""Retrieve the current user from a JWT token."""
credentials_exception = HTTPException(
@ -66,8 +68,11 @@ async def get_current_user(
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
raw_token = token or request.cookies.get("access_token")
if not raw_token:
raise credentials_exception
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
payload = jwt.decode(raw_token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int | None = payload.get("sub")
if user_id is None:
raise credentials_exception
@ -93,4 +98,4 @@ async def get_current_admin(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough privileges",
)
return current_user
return current_user

View file

@ -37,9 +37,10 @@ from fastapi import (
Request,
Response,
)
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.exception_handlers import http_exception_handler
from sqlalchemy.orm import Session
from . import models, schemas, auth, storage as storage_module, database
@ -50,6 +51,17 @@ templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "t
storage = storage_module.get_storage()
@app.exception_handler(HTTPException)
async def custom_http_exception_handler(request: Request, exc: HTTPException) -> Response:
if (
exc.status_code == status.HTTP_401_UNAUTHORIZED
and "text/html" in request.headers.get("accept", "")
and not request.url.path.startswith("/api")
):
return RedirectResponse(url="/login")
return await http_exception_handler(request, exc)
@app.on_event("startup")
async def on_startup() -> None:
# Create database tables if they do not exist
@ -649,6 +661,53 @@ async def update_client_config(
# ======== Web interface routes =========
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> Response:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"title": "Admin Login",
},
)
@app.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(database.get_db),
) -> Response:
user = db.query(models.User).filter(models.User.username == username).first()
if not user or not auth.verify_password(password, user.hashed_password):
return templates.TemplateResponse(
"login.html",
{
"request": request,
"title": "Admin Login",
"error": "Invalid username or password.",
},
status_code=status.HTTP_401_UNAUTHORIZED,
)
access_token = auth.create_access_token(data={"sub": user.id, "is_admin": user.is_admin})
response = RedirectResponse(url="/clients", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
"access_token",
access_token,
httponly=True,
samesite="lax",
)
return response
@app.get("/logout")
async def logout() -> Response:
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie("access_token")
return response
@app.get("/clients", response_class=HTMLResponse)
async def list_clients_page(
request: Request,
@ -745,4 +804,4 @@ async def root_redirect(request: Request) -> Response:
redirect keeps the root endpoint simple and ensures there is no
ambiguity with multiple handlers for ``/``.
"""
return Response(status_code=303, headers={"Location": "/clients"})
return Response(status_code=303, headers={"Location": "/clients"})

View file

@ -12,10 +12,17 @@
<nav class="container mx-auto px-4 py-3 flex items-center space-x-4">
<a href="/" class="text-xl font-semibold">Backup Service</a>
<a href="/clients" class="hover:underline">Clients</a>
<div class="ml-auto">
{% if user %}
<a href="/logout" class="hover:underline">Logout</a>
{% else %}
<a href="/login" class="hover:underline">Login</a>
{% endif %}
</div>
</nav>
</header>
<main class="container mx-auto p-4">
{% block content %}{% endblock %}
</main>
</body>
</html>
</html>

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto bg-white shadow rounded-lg p-6 mt-8">
<h1 class="text-2xl font-bold mb-2">Admin Login</h1>
<p class="text-sm text-gray-600 mb-6">Sign in to manage clients and backups.</p>
{% if error %}
<div class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{{ error }}
</div>
{% endif %}
<form action="/login" method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="username">Username</label>
<input id="username" type="text" name="username" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="password">Password</label>
<input id="password" type="password" name="password" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Sign In</button>
</form>
</div>
{% endblock %}