feat: panel admin - gestion utilisateurs (#16)
- Route /api/admin avec middleware require_admin - Liste utilisateurs avec statut, role, dates - Actions: activer/desactiver, promouvoir/rétrograder admin, supprimer - Dashboard stats (utilisateurs, téléchargements) - Template admin_panel.html avec table responsive - Champ is_admin ajoute au modele User - Migration automatique colonne is_admin - Protection: impossible de modifier son propre compte Closes #16
This commit is contained in:
@@ -48,6 +48,14 @@ def _ensure_columns(engine):
|
||||
'download_dir': 'TEXT DEFAULT "downloads"',
|
||||
}
|
||||
|
||||
# Add is_admin to users table if missing
|
||||
if 'users' in inspector.get_table_names():
|
||||
user_cols = {col['name'] for col in inspector.get_columns('users')}
|
||||
if 'is_admin' not in user_cols:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0'))
|
||||
conn.commit()
|
||||
|
||||
with engine.connect() as conn:
|
||||
for col_name, col_def in new_columns.items():
|
||||
if col_name not in existing:
|
||||
|
||||
@@ -12,6 +12,7 @@ class UserBase(SQLModel):
|
||||
email: Optional[str] = Field(default=None, index=True)
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool = Field(default=True)
|
||||
is_admin: bool = Field(default=False)
|
||||
|
||||
|
||||
class UserTable(UserBase, table=True):
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.routers.router_player import router as player_router
|
||||
from .router_static import router as static_router
|
||||
from .router_root import router as root_router
|
||||
from .router_settings import router as settings_router
|
||||
from .router_admin import router as admin_router
|
||||
|
||||
__all__ = [
|
||||
"auth_router",
|
||||
@@ -26,5 +27,6 @@ __all__ = [
|
||||
"static_router",
|
||||
"root_router",
|
||||
"settings_router",
|
||||
"admin_router",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Admin panel routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.database import get_session, engine
|
||||
from app.models.auth import User, UserTable
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
async def require_admin(current_user: User = Depends(get_current_user_from_token)) -> User:
|
||||
"""Dependency that requires the current user to be an admin."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""List all users (admin only)"""
|
||||
with Session(engine) as session:
|
||||
statement = select(UserTable)
|
||||
users = session.exec(statement).all()
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"full_name": u.full_name,
|
||||
"is_active": u.is_active,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"total": len(users),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Get admin dashboard statistics"""
|
||||
from app.download_manager import DownloadManager
|
||||
from main import download_manager
|
||||
|
||||
with Session(engine) as session:
|
||||
total_users = len(session.exec(select(UserTable)).all())
|
||||
active_users = len(session.exec(select(UserTable).where(UserTable.is_active == True)).all())
|
||||
admin_users = len(session.exec(select(UserTable).where(UserTable.is_admin == True)).all())
|
||||
|
||||
tasks = download_manager.get_all_tasks()
|
||||
total_downloads = len(tasks)
|
||||
completed_downloads = len([t for t in tasks if t.status == "completed"])
|
||||
active_downloads = len([t for t in tasks if t.status in ("downloading", "pending")])
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"active": active_users,
|
||||
"admins": admin_users,
|
||||
},
|
||||
"downloads": {
|
||||
"total": total_downloads,
|
||||
"completed": completed_downloads,
|
||||
"active": active_downloads,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/toggle-active")
|
||||
async def toggle_user_active(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Activate or deactivate a user"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify your own account")
|
||||
user.is_active = not user.is_active
|
||||
session.add(user)
|
||||
session.commit()
|
||||
status = "active" if user.is_active else "inactive"
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {user.username} is now {status}", "type": "success"}}}}'
|
||||
return {"id": user_id, "is_active": user.is_active}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/toggle-admin")
|
||||
async def toggle_user_admin(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Promote or demote a user to/from admin"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify your own admin status")
|
||||
user.is_admin = not user.is_admin
|
||||
session.add(user)
|
||||
session.commit()
|
||||
role = "admin" if user.is_admin else "user"
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{user.username} is now {role}", "type": "success"}}}}'
|
||||
return {"id": user_id, "is_admin": user.is_admin}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Delete a user"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
username = user.username
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {username} deleted", "type": "info"}}}}'
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
||||
@router.get("/ui")
|
||||
async def get_admin_ui(
|
||||
request: Request,
|
||||
current_user: Optional[User] = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get admin panel UI"""
|
||||
if current_user is None or not current_user.is_admin:
|
||||
from app.routers.router_auth import get_optional_user
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
with Session(engine) as session:
|
||||
users = session.exec(select(UserTable)).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"components/admin_panel.html",
|
||||
{"request": request, "users": users, "current_user": current_user},
|
||||
)
|
||||
@@ -144,6 +144,7 @@ from app.routers import (
|
||||
static_router,
|
||||
root_router,
|
||||
settings_router,
|
||||
admin_router,
|
||||
)
|
||||
|
||||
|
||||
@@ -159,6 +160,7 @@ app.include_router(sonarr_router)
|
||||
app.include_router(player_router)
|
||||
app.include_router(static_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<div class="settings-container section-container">
|
||||
<div class="section-header">
|
||||
<h2>Administration</h2>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div id="admin-stats" class="admin-stats-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px;">
|
||||
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 800; color: var(--primary);" id="stat-total-users">{{ users|length }}</div>
|
||||
<div style="color: var(--text-dim); font-size: 0.85rem;">Utilisateurs</div>
|
||||
</div>
|
||||
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 800; color: var(--accent);" id="stat-active-users">{{ users|selectattr('is_active')|list|length }}</div>
|
||||
<div style="color: var(--text-dim); font-size: 0.85rem;">Actifs</div>
|
||||
</div>
|
||||
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 800; color: #f0a500;" id="stat-admin-users">{{ users|selectattr('is_admin')|list|length }}</div>
|
||||
<div style="color: var(--text-dim); font-size: 0.85rem;">Admins</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div style="background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); overflow: hidden;">
|
||||
<div style="padding: 20px 25px; border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||||
<h3 style="margin: 0; color: var(--primary);">Gestion des utilisateurs</h3>
|
||||
</div>
|
||||
|
||||
{% if users %}
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||||
<th style="padding: 12px 20px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Utilisateur</th>
|
||||
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Email</th>
|
||||
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Statut</th>
|
||||
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Role</th>
|
||||
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Derniere connexion</th>
|
||||
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Inscription</th>
|
||||
<th style="padding: 12px 20px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.03); {% if not user.is_active %}opacity: 0.5;{% endif %}">
|
||||
<td style="padding: 12px 20px;">
|
||||
<div style="font-weight: 600;">{{ user.username }}</div>
|
||||
{% if user.full_name %}
|
||||
<div style="font-size: 0.8rem; color: var(--text-dim);">{{ user.full_name }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.9rem;">{{ user.email or '-' }}</td>
|
||||
<td style="padding: 12px 15px; text-align: center;">
|
||||
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_active %}rgba(0,255,136,0.15); color: var(--accent){% else %}rgba(255,77,77,0.15); color: var(--danger){% endif %};">
|
||||
{% if user.is_active %}Actif{% else %}Inactif{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px 15px; text-align: center;">
|
||||
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_admin %}rgba(240,165,0,0.15); color: #f0a500{% else %}rgba(255,255,255,0.05); color: var(--text-dim){% endif %};">
|
||||
{% if user.is_admin %}Admin{% else %}User{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
|
||||
{{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else '-' }}
|
||||
</td>
|
||||
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
|
||||
{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '-' }}
|
||||
</td>
|
||||
<td style="padding: 12px 20px; text-align: center; white-space: nowrap;">
|
||||
{% if user.id != current_user.id %}
|
||||
<button class="btn btn-sm {% if user.is_active %}btn-secondary{% else %}btn-accent{% endif %}"
|
||||
hx-put="/api/admin/users/{{ user.id }}/toggle-active" hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||
title="{% if user.is_active %}Desactiver{% else %}Activer{% endif %}">
|
||||
{% if user.is_active %}Desactiver{% else %}Activer{% endif %}
|
||||
</button>
|
||||
<button class="btn btn-sm {% if user.is_admin %}btn-secondary{% else %}btn-accent{% endif %}"
|
||||
hx-put="/api/admin/users/{{ user.id }}/toggle-admin" hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||
title="{% if user.is_admin %}Retrograder{% else %}Promouvoir{% endif %}">
|
||||
{% if user.is_admin %}Retrograder{% else %}Admin{% endif %}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/api/admin/users/{{ user.id }}" hx-swap="none"
|
||||
hx-confirm="Supprimer {{ user.username }} ?"
|
||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||
title="Supprimer">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span style="color: var(--text-dim); font-size: 0.8rem;">Vous</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: 40px; text-align: center; color: var(--text-dim);">Aucun utilisateur</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +139,15 @@
|
||||
<div id="tab-settings" class="tab-content" x-show="activeTab === 'settings'">
|
||||
<div hx-get="/api/settings/ui" hx-trigger="set-tab[detail.tab === 'settings'] from:window, refresh-settings" hx-swap="innerHTML">
|
||||
<div class="loading-placeholder">
|
||||
<div class="spinner"></div> Chargement des paramètres...
|
||||
<div class="spinner"></div> Chargement des parametres...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-admin" class="tab-content" x-show="activeTab === 'admin'">
|
||||
<div id="admin-panel-content" hx-get="/api/admin/ui" hx-trigger="set-tab[detail.tab === 'admin'] from:window" hx-swap="innerHTML">
|
||||
<div class="loading-placeholder">
|
||||
<div class="spinner"></div> Chargement du panel admin...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user