Rebase services-as-hub-ia onto mobile-responsive-parity

Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
This commit is contained in:
Developer
2026-06-26 20:55:00 +00:00
parent b583d5a365
commit 01527ae4f0
75 changed files with 5343 additions and 4926 deletions
@@ -0,0 +1,144 @@
"""Authentik directory + messaging router.
Resolves an ``authentik`` service instance from the registry, builds an
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
proxies paginated directory queries plus message-compose (email enqueue).
Graceful "not configured" / "unreachable" payloads (matching the monitoring
router's pattern) so the UI always renders.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
from media_library_viewer_api.services.mail_queue import MailQueue
from media_library_viewer_api.services.mailer import validate_smtp_settings
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
class MessageRequest(BaseModel):
"""Compose-request body for the Authentik messaging endpoint."""
recipient_emails: list[str]
subject: str
html_body: str
def _resolve_service_record(
store: SettingsStore,
service_id: str | None = None,
) -> ServiceRecord | None:
"""Return the requested authentik instance, else the first enabled one.
Returns ``None`` when the instance does not exist / is the wrong type, or
when no enabled ``authentik`` instance is configured.
"""
service_type = "authentik"
if service_id:
row = store.get_service(service_id)
if not row or row.get("service_type") != service_type:
return None
if not row.get("enabled", True):
return None
return build_service_record(store, row)
for row in store.list_services(service_type):
if row.get("enabled", True):
return build_service_record(store, row)
return None
def _build_client(service: ServiceRecord) -> AuthentikClient:
base_url = str(service.config.get("base_url") or "").rstrip("/")
api_token = str(service.secrets.get("api_token") or "")
try:
timeout = float(service.config.get("timeout_seconds") or 10)
except (TypeError, ValueError):
timeout = 10.0
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
def _empty(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
@router.get("/{service_id}/users")
def get_authentik_users(
service_id: str,
search: str | None = None,
page: int = 1,
page_size: int = 50,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance."""
service = _resolve_service_record(store, service_id)
if service is None:
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
return _empty("Authentik service not configured")
try:
client = _build_client(service)
return client.users(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik users query failed for service %s", service_id)
return _empty("Authentik is unreachable")
@router.get("/{service_id}/message/status")
def get_authentik_message_status(
service_id: str,
store: SettingsStore = Depends(get_settings_store),
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Mail-queue status snapshot for the Authentik messaging tab."""
service = _resolve_service_record(store, service_id)
if service is None:
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
return mail_queue.status()
@router.post("/{service_id}/message")
def post_authentik_message(
service_id: str,
body: MessageRequest,
store: SettingsStore = Depends(get_settings_store),
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
service = _resolve_service_record(store, service_id)
if service is None:
return {"status": "error", "error": "Authentik service not configured"}
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
if not recipients:
return {"status": "error", "error": "No recipients with valid email addresses."}
settings = get_settings()
try:
validate_smtp_settings(settings)
except ValueError as exc:
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
subject=body.subject,
html_body=body.html_body,
)
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
return {
"status": "queued",
"request_id": request_id,
"recipient_count": len(recipients),
}