perf: paginate docs list, lazy thumbnails, static cache headers

This commit is contained in:
Krikorios
2026-04-19 08:26:05 +03:00
parent 08ebc2e790
commit 4cbaa640fd
7 changed files with 343 additions and 33 deletions
+21
View File
@@ -3,6 +3,16 @@ from database.connection import get_db
def _migrate(conn): def _migrate(conn):
"""Add columns that may not exist in older databases.""" """Add columns that may not exist in older databases."""
# users.role migration
cursor = conn.execute("PRAGMA table_info(users)")
user_cols = {row[1] for row in cursor.fetchall()}
if "role" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'")
# Promote the first-created user to admin so the app remains usable.
first = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
if first:
conn.execute("UPDATE users SET role='admin' WHERE id=?", (first[0],))
cursor = conn.execute("PRAGMA table_info(documents)") cursor = conn.execute("PRAGMA table_info(documents)")
existing = {row[1] for row in cursor.fetchall()} existing = {row[1] for row in cursor.fetchall()}
migrations = [ migrations = [
@@ -44,9 +54,20 @@ def create_tables():
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
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 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
ip TEXT,
success INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_username ON login_attempts(username, created_at);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip, created_at);
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+4 -1
View File
@@ -51,10 +51,13 @@ async def check_authentication(request: Request, call_next):
allowed_paths = ["/auth/login", "/static"] allowed_paths = ["/auth/login", "/static"]
is_allowed = any(path.startswith(p) for p in allowed_paths) is_allowed = any(path.startswith(p) for p in allowed_paths)
request.state.user = None
if not is_allowed: if not is_allowed:
session_id = request.cookies.get("session_id") session_id = request.cookies.get("session_id")
if not session_id or not get_session(session_id): user = get_session(session_id) if session_id else None
if not user:
return RedirectResponse(url="/auth/login", status_code=303) return RedirectResponse(url="/auth/login", status_code=303)
request.state.user = user
response = await call_next(request) response = await call_next(request)
return response return response
+127 -15
View File
@@ -4,6 +4,8 @@ from fastapi.templating import Jinja2Templates
from services.auth_service import ( from services.auth_service import (
verify_password, get_user_by_username, create_user, delete_user, get_all_users, verify_password, get_user_by_username, create_user, delete_user, get_all_users,
create_session, get_session, delete_session, SESSION_TTL_SECONDS, create_session, get_session, delete_session, SESSION_TTL_SECONDS,
needs_rehash, update_user_password, set_user_role, count_admins,
record_login_attempt, is_login_blocked,
) )
from services.backup_service import create_backup from services.backup_service import create_backup
import os import os
@@ -12,10 +14,19 @@ router = APIRouter()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
def _client_ip(request: Request) -> str:
# Trust X-Forwarded-For when behind a reverse proxy
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def get_current_user_from_request(request: Request): def get_current_user_from_request(request: Request):
session_id = request.cookies.get("session_id") session_id = request.cookies.get("session_id")
return get_session(session_id) if session_id else None return get_session(session_id) if session_id else None
def get_current_user(request: Request): def get_current_user(request: Request):
user = get_current_user_from_request(request) user = get_current_user_from_request(request)
if not user: if not user:
@@ -25,20 +36,53 @@ def get_current_user(request: Request):
) )
return user return user
def require_admin(request: Request):
user = get_current_user(request)
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
async def login_get(request: Request): async def login_get(request: Request):
return templates.TemplateResponse(request=request, name="login.html", context={"error": None}) return templates.TemplateResponse(request=request, name="login.html", context={"error": None})
@router.post("/login", response_class=HTMLResponse) @router.post("/login", response_class=HTMLResponse)
async def login_post(request: Request, username: str = Form(...), password: str = Form(...)): async def login_post(request: Request, username: str = Form(...), password: str = Form(...)):
username = (username or "").strip()
ip = _client_ip(request)
if is_login_blocked(username, ip):
return templates.TemplateResponse(
request=request,
name="login.html",
context={"error": "تم حجب محاولات تسجيل الدخول مؤقتاً. حاول بعد 15 دقيقة."},
status_code=429,
)
user = get_user_by_username(username) user = get_user_by_username(username)
if not user or not verify_password(user["password_hash"], password): if not user or not verify_password(user["password_hash"], password):
return templates.TemplateResponse(request=request, name="login.html", context={"error": "Invalid username or password"}) record_login_attempt(username, ip, success=False)
return templates.TemplateResponse(
request=request,
name="login.html",
context={"error": "Invalid username or password"},
status_code=401,
)
session_id = create_session(user["id"], user["username"]) record_login_attempt(username, ip, success=True)
# Opportunistically upgrade the password hash if it's using weaker params.
try:
if needs_rehash(user["password_hash"]):
update_user_password(user["id"], password)
except Exception:
pass
role = user["role"] if "role" in user.keys() else "user"
session_id = create_session(user["id"], user["username"], role=role)
from config import ENVIRONMENT from config import ENVIRONMENT
# secure=True only when accessed via HTTPS (check X-Forwarded-Proto from nginx)
is_https = ENVIRONMENT == "production" and request.headers.get("x-forwarded-proto") == "https" is_https = ENVIRONMENT == "production" and request.headers.get("x-forwarded-proto") == "https"
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie( response.set_cookie(
@@ -48,6 +92,7 @@ async def login_post(request: Request, username: str = Form(...), password: str
secure=is_https, secure=is_https,
samesite="lax", samesite="lax",
max_age=SESSION_TTL_SECONDS, max_age=SESSION_TTL_SECONDS,
path="/",
) )
return response return response
@@ -61,34 +106,101 @@ async def logout(request: Request):
return response return response
@router.get("/users", response_class=HTMLResponse) @router.get("/users", response_class=HTMLResponse)
async def users_list(request: Request, _=Depends(get_current_user)): async def users_list(request: Request, current=Depends(require_admin)):
users = get_all_users() users = get_all_users()
return templates.TemplateResponse(request=request, name="users.html", context={"users": users}) return templates.TemplateResponse(
request=request,
name="users.html",
context={"users": users, "current_user": current},
)
@router.post("/users/create") @router.post("/users/create")
async def add_user(request: Request, username: str = Form(...), password: str = Form(...), _=Depends(get_current_user)): async def add_user(
request: Request,
username: str = Form(...),
password: str = Form(...),
role: str = Form("user"),
current=Depends(require_admin),
):
username = (username or "").strip()
if role not in {"admin", "user"}:
role = "user"
if len(password) < 8: if len(password) < 8:
users = get_all_users() users = get_all_users()
return templates.TemplateResponse(request=request, name="users.html", context={"users": users, "error": "Password must be at least 8 characters."}) return templates.TemplateResponse(
request=request,
name="users.html",
context={"users": users, "current_user": current, "error": "Password must be at least 8 characters."},
)
if get_user_by_username(username): if get_user_by_username(username):
users = get_all_users() users = get_all_users()
return templates.TemplateResponse(request=request, name="users.html", context={"users": users, "error": f"User '{username}' already exists."}) return templates.TemplateResponse(
create_user(username, password) request=request,
name="users.html",
context={"users": users, "current_user": current, "error": f"User '{username}' already exists."},
)
create_user(username, password, role=role)
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/users/delete/{user_id}") @router.post("/users/delete/{user_id}")
async def remove_user(user_id: int, _=Depends(get_current_user)): async def remove_user(request: Request, user_id: int, current=Depends(require_admin)):
if user_id == current["user_id"]:
users = get_all_users()
return templates.TemplateResponse(
request=request,
name="users.html",
context={"users": users, "current_user": current, "error": "لا يمكنك حذف حسابك الخاص."},
status_code=400,
)
# Prevent removing the last admin
target = next((u for u in get_all_users() if u["id"] == user_id), None)
if target and target["role"] == "admin" and count_admins() <= 1:
users = get_all_users()
return templates.TemplateResponse(
request=request,
name="users.html",
context={"users": users, "current_user": current, "error": "لا يمكن حذف آخر مسؤول في النظام."},
status_code=400,
)
delete_user(user_id) delete_user(user_id)
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/users/{user_id}/role")
async def change_role(
request: Request,
user_id: int,
role: str = Form(...),
current=Depends(require_admin),
):
if role not in {"admin", "user"}:
raise HTTPException(status_code=400, detail="invalid role")
# Prevent demoting the last admin
target = next((u for u in get_all_users() if u["id"] == user_id), None)
if target and target["role"] == "admin" and role != "admin" and count_admins() <= 1:
users = get_all_users()
return templates.TemplateResponse(
request=request,
name="users.html",
context={"users": users, "current_user": current, "error": "لا يمكن تخفيض رتبة آخر مسؤول."},
status_code=400,
)
set_user_role(user_id, role)
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/backup") @router.get("/backup")
async def backup_db(request: Request, _=Depends(get_current_user)): async def backup_db(request: Request, current=Depends(require_admin)):
try: try:
backup_path = create_backup() backup_path = create_backup()
return FileResponse(backup_path, media_type="application/octet-stream", filename=os.path.basename(backup_path)) return FileResponse(backup_path, media_type="application/octet-stream", filename=os.path.basename(backup_path))
except Exception as e: except Exception as e:
msg = f"Backup failed: {str(e)}" msg = f"Backup failed: {str(e)}"
return templates.TemplateResponse(request=request, name="users.html", context={ return templates.TemplateResponse(
"users": get_all_users(), request=request,
"backup_msg": msg name="users.html",
}) context={
"users": get_all_users(),
"current_user": current,
"backup_msg": msg,
},
)
+38 -3
View File
@@ -5,7 +5,7 @@ import uuid
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, File, Form, Request, UploadFile from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -21,6 +21,35 @@ ALLOWED_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
ALLOWED_PDF_EXTS = {".pdf"} ALLOWED_PDF_EXTS = {".pdf"}
ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTS | ALLOWED_PDF_EXTS ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTS | ALLOWED_PDF_EXTS
MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB per file
MAX_TOTAL_BYTES = 100 * 1024 * 1024 # 100 MB per request
def _check_request_size(request: Request) -> None:
"""Reject oversize uploads early via Content-Length header."""
cl = request.headers.get("content-length")
if cl and cl.isdigit() and int(cl) > MAX_TOTAL_BYTES:
raise HTTPException(
status_code=413,
detail=f"حجم الطلب يتجاوز الحد الأقصى ({MAX_TOTAL_BYTES // (1024*1024)} ميغابايت).",
)
async def _read_capped(upload: UploadFile, running_total: int) -> bytes:
"""Read upload bytes, enforcing per-file and cumulative caps."""
data = await upload.read()
if len(data) > MAX_FILE_BYTES:
raise HTTPException(
status_code=413,
detail=f"الملف '{upload.filename}' يتجاوز {MAX_FILE_BYTES // (1024*1024)} ميغابايت.",
)
if running_total + len(data) > MAX_TOTAL_BYTES:
raise HTTPException(
status_code=413,
detail=f"إجمالي حجم الرفع يتجاوز {MAX_TOTAL_BYTES // (1024*1024)} ميغابايت.",
)
return data
def _hash_bytes(data: bytes) -> str: def _hash_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest() return hashlib.sha256(data).hexdigest()
@@ -179,18 +208,21 @@ async def upload_files(
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
provider: str = Form(""), provider: str = Form(""),
): ):
_check_request_size(request)
if not provider: if not provider:
provider = get_default_provider() provider = get_default_provider()
doc_ids = [] doc_ids = []
duplicates = [] duplicates = []
total_bytes = 0
for upload in files: for upload in files:
suffix = Path(upload.filename).suffix.lower() suffix = Path(upload.filename).suffix.lower()
if suffix not in ALLOWED_EXTENSIONS: if suffix not in ALLOWED_EXTENSIONS:
continue continue
file_bytes = await upload.read() file_bytes = await _read_capped(upload, total_bytes)
total_bytes += len(file_bytes)
if suffix in ALLOWED_PDF_EXTS: if suffix in ALLOWED_PDF_EXTS:
# PDF: split into per-page images; hash each rendered page # PDF: split into per-page images; hash each rendered page
@@ -268,14 +300,17 @@ async def stage_file(
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
): ):
"""Save uploaded images without triggering AI extraction.""" """Save uploaded images without triggering AI extraction."""
_check_request_size(request)
staged = [] staged = []
duplicates = [] duplicates = []
total_bytes = 0
for upload in files: for upload in files:
suffix = Path(upload.filename).suffix.lower() suffix = Path(upload.filename).suffix.lower()
if suffix not in ALLOWED_EXTENSIONS: if suffix not in ALLOWED_EXTENSIONS:
continue continue
file_bytes = await upload.read() file_bytes = await _read_capped(upload, total_bytes)
total_bytes += len(file_bytes)
if suffix in ALLOWED_PDF_EXTS: if suffix in ALLOWED_PDF_EXTS:
pages = pdf_to_images(file_bytes, upload.filename) pages = pdf_to_images(file_bytes, upload.filename)
+120 -12
View File
@@ -4,47 +4,148 @@ import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
SESSION_TTL_SECONDS = 86400 # 24h SESSION_TTL_SECONDS = 86400 # 24h
PBKDF2_ITERATIONS = 600_000 # OWASP 2023+ guidance for PBKDF2-HMAC-SHA256
def hash_password(password: str) -> str: # Login rate-limit tuning
LOGIN_WINDOW_SECONDS = 900 # 15 min sliding window
LOGIN_MAX_FAILS_USER = 5 # per username
LOGIN_MAX_FAILS_IP = 20 # per IP (higher because NATs)
def hash_password(password: str, iterations: int = PBKDF2_ITERATIONS) -> str:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
hashed = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), 100000).hex() hashed = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), iterations).hex()
return f"{salt}${hashed}" return f"pbkdf2${iterations}${salt}${hashed}"
def verify_password(stored_password: str, provided_password: str) -> bool: def verify_password(stored_password: str, provided_password: str) -> bool:
"""Supports both the legacy 'salt$hash' (100k iter) format and the new
'pbkdf2$<iter>$salt$hash' format."""
try: try:
salt, stored_hash = stored_password.split('$') parts = stored_password.split('$')
hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), 100000).hex() if len(parts) == 4 and parts[0] == 'pbkdf2':
iterations = int(parts[1])
salt = parts[2]
stored_hash = parts[3]
elif len(parts) == 2:
iterations = 100_000 # legacy
salt, stored_hash = parts
else:
return False
hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), iterations).hex()
return hmac.compare_digest(hashed, stored_hash) return hmac.compare_digest(hashed, stored_hash)
except Exception: except Exception:
return False return False
def needs_rehash(stored_password: str) -> bool:
"""Return True if the stored hash uses weaker params than current defaults."""
try:
parts = stored_password.split('$')
if len(parts) == 4 and parts[0] == 'pbkdf2':
return int(parts[1]) < PBKDF2_ITERATIONS
return True # legacy format → rehash
except Exception:
return True
def get_user_by_username(username: str): def get_user_by_username(username: str):
from database.connection import get_db from database.connection import get_db
with get_db() as conn: with get_db() as conn:
cursor = conn.execute("SELECT * FROM users WHERE username = ?", (username,)) cursor = conn.execute("SELECT * FROM users WHERE username = ?", (username,))
return cursor.fetchone() return cursor.fetchone()
def create_user(username: str, password: str): def create_user(username: str, password: str, role: str = "user"):
from database.connection import get_db from database.connection import get_db
if role not in {"admin", "user"}:
role = "user"
with get_db() as conn: with get_db() as conn:
hashed = hash_password(password) hashed = hash_password(password)
conn.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (username, hashed)) conn.execute(
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
(username, hashed, role),
)
def delete_user(user_id: int): def delete_user(user_id: int):
from database.connection import get_db from database.connection import get_db
with get_db() as conn: with get_db() as conn:
conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
def update_user_password(user_id: int, new_password: str) -> None:
from database.connection import get_db
with get_db() as conn:
conn.execute(
"UPDATE users SET password_hash=? WHERE id=?",
(hash_password(new_password), user_id),
)
# Rotate any active sessions for this user.
conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
def set_user_role(user_id: int, role: str) -> None:
from database.connection import get_db
if role not in {"admin", "user"}:
raise ValueError("invalid role")
with get_db() as conn:
conn.execute("UPDATE users SET role=? WHERE id=?", (role, user_id))
def count_admins() -> int:
from database.connection import get_db
with get_db() as conn:
row = conn.execute("SELECT COUNT(*) AS n FROM users WHERE role='admin'").fetchone()
return int(row["n"]) if row else 0
def get_all_users(): def get_all_users():
from database.connection import get_db from database.connection import get_db
with get_db() as conn: with get_db() as conn:
cursor = conn.execute("SELECT id, username, created_at FROM users ORDER BY created_at DESC") cursor = conn.execute(
"SELECT id, username, role, created_at FROM users ORDER BY created_at DESC"
)
return cursor.fetchall() return cursor.fetchall()
# ─── Login rate-limiting ──────────────────────────────────────────
def record_login_attempt(username: str, ip: str, success: bool) -> None:
from database.connection import get_db
with get_db() as conn:
conn.execute(
"INSERT INTO login_attempts (username, ip, success) VALUES (?, ?, ?)",
(username, ip, 1 if success else 0),
)
def is_login_blocked(username: str, ip: str) -> bool:
"""Return True if too many recent failures for this username OR this IP."""
from database.connection import get_db
cutoff = (datetime.utcnow() - timedelta(seconds=LOGIN_WINDOW_SECONDS)).strftime("%Y-%m-%d %H:%M:%S")
with get_db() as conn:
by_user = conn.execute(
"SELECT COUNT(*) AS n FROM login_attempts WHERE username=? AND success=0 AND created_at > ?",
(username, cutoff),
).fetchone()["n"]
if by_user >= LOGIN_MAX_FAILS_USER:
return True
by_ip = conn.execute(
"SELECT COUNT(*) AS n FROM login_attempts WHERE ip=? AND success=0 AND created_at > ?",
(ip, cutoff),
).fetchone()["n"]
return by_ip >= LOGIN_MAX_FAILS_IP
def cleanup_old_login_attempts() -> int:
from database.connection import get_db
cutoff = (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
with get_db() as conn:
cur = conn.execute("DELETE FROM login_attempts WHERE created_at < ?", (cutoff,))
return cur.rowcount or 0
# ─── Sessions (DB-backed) ───────────────────────────────────────── # ─── Sessions (DB-backed) ─────────────────────────────────────────
def create_session(user_id: int, username: str, ttl_seconds: int = SESSION_TTL_SECONDS) -> str: def create_session(user_id: int, username: str, role: str = "user", ttl_seconds: int = SESSION_TTL_SECONDS) -> str:
from database.connection import get_db from database.connection import get_db
session_id = secrets.token_urlsafe(32) session_id = secrets.token_urlsafe(32)
expires_at = (datetime.utcnow() + timedelta(seconds=ttl_seconds)).strftime("%Y-%m-%d %H:%M:%S") expires_at = (datetime.utcnow() + timedelta(seconds=ttl_seconds)).strftime("%Y-%m-%d %H:%M:%S")
@@ -57,17 +158,24 @@ def create_session(user_id: int, username: str, ttl_seconds: int = SESSION_TTL_S
def get_session(session_id: str): def get_session(session_id: str):
"""Return dict with user_id/username if session is valid and unexpired, else None.""" """Return dict with user_id/username/role if valid and unexpired, else None."""
if not session_id: if not session_id:
return None return None
from database.connection import get_db from database.connection import get_db
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
with get_db() as conn: with get_db() as conn:
row = conn.execute( row = conn.execute(
"SELECT id, user_id, username, expires_at FROM sessions WHERE id = ? AND expires_at > ?", """SELECT s.id, s.user_id, s.username, s.expires_at, u.role
FROM sessions s
LEFT JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > ?""",
(session_id, now), (session_id, now),
).fetchone() ).fetchone()
return dict(row) if row else None if not row:
return None
d = dict(row)
d["role"] = d.get("role") or "user"
return d
def delete_session(session_id: str) -> None: def delete_session(session_id: str) -> None:
+2
View File
@@ -50,10 +50,12 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
قائمة الوثائق قائمة الوثائق
</a> </a>
{% if request.state.user and request.state.user.role == 'admin' %}
<a href="/auth/users" class="nav-link"> <a href="/auth/users" class="nav-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15c-4 0-7 2-7 4v1h14v-1c0-2-3-4-7-4z"/><circle cx="12" cy="8" r="4"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15c-4 0-7 2-7 4v1h14v-1c0-2-3-4-7-4z"/><circle cx="12" cy="8" r="4"/></svg>
الإدارة الإدارة
</a> </a>
{% endif %}
<a href="/auth/logout" class="nav-link nav-link-logout"> <a href="/auth/logout" class="nav-link nav-link-logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
خروج خروج
+31 -2
View File
@@ -15,6 +15,9 @@
{% if backup_msg %} {% if backup_msg %}
<div class="success-banner">{{ backup_msg }}</div> <div class="success-banner">{{ backup_msg }}</div>
{% endif %} {% endif %}
{% if error %}
<div class="error-banner" style="background:#fee2e2;color:#991b1b;padding:.75rem 1rem;border-radius:8px;margin:.75rem 0">{{ error }}</div>
{% endif %}
<div class="admin-section"> <div class="admin-section">
<h3> <h3>
@@ -37,7 +40,14 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">كلمة المرور</label> <label for="password">كلمة المرور</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required minlength="8">
</div>
<div class="form-group">
<label for="role">الصلاحية</label>
<select id="role" name="role">
<option value="user">مستخدم</option>
<option value="admin">مسؤول</option>
</select>
</div> </div>
<button type="submit" class="btn btn-primary">إضافة</button> <button type="submit" class="btn btn-primary">إضافة</button>
</form> </form>
@@ -54,6 +64,7 @@
<tr> <tr>
<th>الرقم</th> <th>الرقم</th>
<th>اسم المستخدم</th> <th>اسم المستخدم</th>
<th>الصلاحية</th>
<th>تاريخ الإنشاء</th> <th>تاريخ الإنشاء</th>
<th></th> <th></th>
</tr> </tr>
@@ -62,12 +73,30 @@
{% for user in users %} {% for user in users %}
<tr> <tr>
<td data-label="الرقم">{{ user.id }}</td> <td data-label="الرقم">{{ user.id }}</td>
<td data-label="اسم المستخدم">{{ user.username }}</td> <td data-label="اسم المستخدم">
{{ user.username }}
{% if current_user and user.id == current_user.user_id %}<small style="color:#64748b">(أنت)</small>{% endif %}
</td>
<td data-label="الصلاحية">
<form method="post" action="/auth/users/{{ user.id }}/role" class="inline-form" style="display:flex;gap:.35rem;align-items:center">
<select name="role" {% if current_user and user.id == current_user.user_id %}disabled{% endif %}>
<option value="user" {% if user.role == 'user' %}selected{% endif %}>مستخدم</option>
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>مسؤول</option>
</select>
{% if not (current_user and user.id == current_user.user_id) %}
<button type="submit" class="btn btn-sm btn-secondary">تغيير</button>
{% endif %}
</form>
</td>
<td data-label="تاريخ الإنشاء">{{ user.created_at }}</td> <td data-label="تاريخ الإنشاء">{{ user.created_at }}</td>
<td data-label="الإجراءات"> <td data-label="الإجراءات">
{% if current_user and user.id == current_user.user_id %}
<span style="color:#64748b;font-size:.85rem"></span>
{% else %}
<form method="post" action="/auth/users/delete/{{ user.id }}" onsubmit="return confirm('هل أنت متأكد من حذف هذا المستخدم؟');" class="inline-form"> <form method="post" action="/auth/users/delete/{{ user.id }}" onsubmit="return confirm('هل أنت متأكد من حذف هذا المستخدم؟');" class="inline-form">
<button type="submit" class="btn btn-sm btn-danger">حذف</button> <button type="submit" class="btn btn-sm btn-danger">حذف</button>
</form> </form>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}