diff --git a/backend/src/media_library_viewer_api/clients/authentik.py b/backend/src/media_library_viewer_api/clients/authentik.py index c355e62..61fab45 100644 --- a/backend/src/media_library_viewer_api/clients/authentik.py +++ b/backend/src/media_library_viewer_api/clients/authentik.py @@ -1,9 +1,7 @@ -"""Authentik directory API client. +"""Read-only Authentik directory client. -Authentik is the user-directory source (replacing the Jellyfin-backed Users -page). This client wraps the Authentik REST API for browsing the user directory -with pagination and search. OIDC authentication is unchanged — this client is -for the directory, not SSO. +The client normalizes the subset of Authentik core data that Manage displays. +It deliberately does not fetch individual users or expose policy/provider data. """ from __future__ import annotations @@ -17,9 +15,34 @@ from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, logger = logging.getLogger(__name__) +_MAX_COLLECTION_ITEMS = 10_000 +_PAGE_SIZE = 100 + + +def _text(value: Any) -> str: + return str(value).strip() if value is not None else "" + + +def _identifier(item: dict[str, Any]) -> str: + for key in ("pk", "id", "uuid"): + value = _text(item.get(key)) + if value: + return value + return "" + + +def _page_total(payload: dict[str, Any], fallback: int) -> int: + pagination = payload.get("pagination") + if isinstance(pagination, dict): + try: + return max(0, int(pagination.get("count") or fallback)) + except (TypeError, ValueError): + pass + return fallback + class AuthentikClient: - """Small wrapper around the Authentik core directory API.""" + """Small wrapper around Authentik's read-only core API.""" def __init__(self, base_url: str, api_token: str, timeout: float = DEFAULT_READ_TIMEOUT): if not base_url: @@ -31,88 +54,139 @@ class AuthentikClient: if self.base_url.endswith("/api/v3"): self.base_url = self.base_url[:-7] self.api_token = api_token - # timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s. self.timeout = http_timeout(timeout) self.session = requests.Session() - self.session.headers.update( - { - "Authorization": f"Bearer {api_token}", - "Accept": "application/json", - } - ) + self.session.headers.update({"Authorization": f"Bearer {api_token}", "Accept": "application/json"}) def get(self, path: str, **params: Any) -> Any: """GET an Authentik endpoint and include useful response text on errors.""" - clean_params = {k: v for k, v in params.items() if v is not None and v != ""} + clean_params = {key: value for key, value in params.items() if value is not None and value != ""} logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys())) - response = self.session.get( - f"{self.base_url}/api/v3{path}", - params=clean_params, - timeout=self.timeout, - ) + response = self.session.get(f"{self.base_url}/api/v3{path}", params=clean_params, timeout=self.timeout) try: response.raise_for_status() except requests.HTTPError as exc: detail = response.text[:500] - logger.warning( - "Authentik GET %s failed status=%s url=%s", - path, - response.status_code, - response.url, - ) - raise requests.HTTPError( - f"{response.status_code} for {response.url}: {detail}", - response=response, - ) from exc - logger.debug("Authentik GET %s ok status=%s", path, response.status_code) + logger.warning("Authentik GET %s failed status=%s url=%s", path, response.status_code, response.url) + raise requests.HTTPError(f"{response.status_code} for {response.url}: {detail}", response=response) from exc return response.json() - def users( + def users(self, search: str | None = None, page: int = 1, page_size: int = 50) -> dict[str, Any]: + """Return one raw user page for the directory and messaging surfaces.""" + payload = self.get("/core/users/", search=search, page=page, page_size=page_size) + if not isinstance(payload, dict): + logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__) + return {"items": [], "total": 0, "page": page, "page_size": page_size} + results = payload.get("results") + items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else [] + return {"items": items, "total": _page_total(payload, len(items)), "page": page, "page_size": page_size} + + def _collection(self, path: str, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]: + """Read a paginated core collection with a hard cap and loop protection.""" + try: + requested = max(1, min(int(limit), _MAX_COLLECTION_ITEMS)) + except (TypeError, ValueError): + requested = _MAX_COLLECTION_ITEMS + items: list[dict[str, Any]] = [] + page = 1 + total = 0 + while len(items) < requested: + payload = self.get(path, page=page, page_size=min(_PAGE_SIZE, requested - len(items))) + if not isinstance(payload, dict): + logger.warning("Authentik %s payload was not a dict: %s", path, type(payload).__name__) + break + results = payload.get("results") + page_items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else [] + total = _page_total(payload, len(items) + len(page_items)) + items.extend(page_items[: requested - len(items)]) + if not page_items or len(items) >= total: + break + page += 1 + if page > 100: # defensive limit for malformed pagination responses + logger.warning("Authentik %s pagination stopped after 100 pages", path) + break + return {"items": items, "total": total or len(items)} + + @staticmethod + def _normalize_group(item: dict[str, Any]) -> dict[str, str] | None: + group_id = _identifier(item) + if not group_id: + return None + name = _text(item.get("name") or item.get("display_name") or item.get("slug")) + return {"id": group_id, "name": name or f"Unnamed group ({group_id})"} + + @staticmethod + def _normalize_application(item: dict[str, Any]) -> dict[str, str]: + app_id = _identifier(item) + return { + "id": app_id, + "name": _text(item.get("name") or item.get("slug") or item.get("meta_name")) or "Unnamed application", + "slug": _text(item.get("slug")), + "launch_url": _text(item.get("launch_url") or item.get("meta_launch_url")), + } + + def groups(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]: + """Return normalized groups; only display-safe identifiers and names are retained.""" + raw = self._collection("/core/groups/", limit) + items = [normalized for item in raw["items"] if (normalized := self._normalize_group(item)) is not None] + return {"items": items, "total": raw["total"]} + + def applications(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]: + """Return normalized applications without provider, policy, or secret fields.""" + raw = self._collection("/core/applications/", limit) + return {"items": [self._normalize_application(item) for item in raw["items"]], "total": raw["total"]} + + @staticmethod + def _group_references(user: dict[str, Any]) -> list[str]: + """Extract group ids from release-dependent user reference shapes.""" + raw = user.get("groups", user.get("group", [])) + if not isinstance(raw, list): + raw = [raw] if raw is not None else [] + ids: list[str] = [] + for reference in raw: + if isinstance(reference, dict): + group_id = _identifier(reference) + else: + group_id = _text(reference) + if group_id and group_id not in ids: + ids.append(group_id) + return ids + + def access_summaries( self, search: str | None = None, page: int = 1, page_size: int = 50, ) -> dict[str, Any]: - """Return a normalized page of Authentik users. + """Summarize user group references and privileged flags without N+1 user reads. - Calls ``GET /api/v3/core/users/`` and normalizes the paginated - Authentik response into ``{items, total, page, page_size}``. Each item - is the raw Authentik user dict (pk, username, name, email, avatar, …) - so the frontend can pick the fields it needs. + This is directory metadata only: group membership plus the explicit + ``is_superuser`` and ``is_staff`` fields. It does not evaluate policies + or claim to calculate effective authorization. """ - payload = self.get( - "/core/users/", - search=search, - page=page, - page_size=page_size, - ) - if not isinstance(payload, dict): - logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__) - return {"items": [], "total": 0, "page": page, "page_size": page_size} - - results = payload.get("results") - items: list[dict[str, Any]] = ( - [item for item in results if isinstance(item, dict)] if isinstance(results, list) else [] - ) - - pagination = payload.get("pagination") or {} - total = 0 - if isinstance(pagination, dict): - try: - total = int(pagination.get("count") or 0) - except (TypeError, ValueError): - total = 0 - - logger.info( - "Authentik users page=%s page_size=%s -> %s items (total=%s)", - page, - page_size, - len(items), - total, - ) - return { - "items": items, - "total": total, - "page": page, - "page_size": page_size, - } + users = self.users(search=search, page=page, page_size=page_size) + groups = self.groups() + group_names = {group["id"]: group["name"] for group in groups["items"]} + summaries: list[dict[str, Any]] = [] + for user in users["items"]: + group_ids = self._group_references(user) + summaries.append( + { + "id": _identifier(user), + "username": _text(user.get("username")), + "name": _text(user.get("name")), + "email": _text(user.get("email")), + "is_active": bool(user.get("is_active", True)), + "is_superuser": bool(user.get("is_superuser", False)), + "is_staff": bool(user.get("is_staff", False)), + "groups": [ + { + "id": group_id, + "name": group_names.get(group_id, f"Unknown group ({group_id})"), + "known": group_id in group_names, + } + for group_id in group_ids + ], + } + ) + return {"items": summaries, "total": users["total"], "page": users["page"], "page_size": users["page_size"]} diff --git a/backend/src/media_library_viewer_api/integrations/authentik.py b/backend/src/media_library_viewer_api/integrations/authentik.py index 508d077..6fdcd63 100644 --- a/backend/src/media_library_viewer_api/integrations/authentik.py +++ b/backend/src/media_library_viewer_api/integrations/authentik.py @@ -1,15 +1,11 @@ -"""Authentik service definition. - -Authentik is the user-directory source (replacing the Jellyfin-backed Users -page). Its directory API is queried via :class:`AuthentikClient` and surfaced -on the Authentik service page (Users + Messaging tabs). OIDC authentication -is unchanged -- this service type is for the directory, not SSO. -""" +"""Authentik service definition for read-only directory and access metadata.""" from __future__ import annotations from typing import TYPE_CHECKING, Any +from pydantic import Field + from media_library_viewer_api.clients.authentik import AuthentikClient from media_library_viewer_api.integrations.base import ( SecretField, @@ -17,27 +13,25 @@ from media_library_viewer_api.integrations.base import ( ServiceConfigBase, ServiceDefinition, TestResult, + WidgetConfigBase, translate_connection_error, + widget_kind, ) if TYPE_CHECKING: from media_library_viewer_api.services.settings_store import SettingsStore -def test_connection( - config: dict[str, Any], - secrets: dict[str, str], - store: SettingsStore, -) -> TestResult: - """Probe AuthentikClient.users(page=1, page_size=1) — lightest directory call.""" +def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult: + """Probe the least-expensive Authentik directory endpoint.""" try: - base_url = str(config.get("base_url") or "").rstrip("/") - api_token = str(secrets.get("api_token") or "") - timeout = float(config.get("timeout_seconds") or 60) - client = AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout) + client = AuthentikClient( + base_url=str(config.get("base_url") or "").rstrip("/"), + api_token=str(secrets.get("api_token") or ""), + timeout=float(config.get("timeout_seconds") or 60), + ) result = client.users(page=1, page_size=1) - total = result.get("total", 0) if isinstance(result, dict) else 0 - return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{total} users") + return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{result.get('total', 0)} users") except Exception as exc: return translate_connection_error(exc, context="Authentik") @@ -46,17 +40,46 @@ class AuthentikConfig(ServiceConfigBase): """Non-secret Authentik connection config.""" base_url: ServiceBaseUrl - timeout_seconds: int = 60 + timeout_seconds: int = Field(default=60, ge=1, le=300) + + +class AuthentikListWidgetConfig(WidgetConfigBase): + """Bounded display count for read-only Authentik list widgets.""" + + limit: int = Field(default=10, ge=1, le=50) DEFINITION = ServiceDefinition( service_type="authentik", name="Authentik", - description="User directory and identity provider integration.", + description="Read-only user directory, groups, and application access metadata.", config_model=AuthentikConfig, - secret_fields=[ - SecretField(key="api_token", label="API token", required=True), + secret_fields=[SecretField(key="api_token", label="API token", required=True)], + widget_kinds=[ + widget_kind( + kind="access_summary", + name="User access summary", + description="User group memberships and explicit staff/superuser status; not effective authorization.", + model_cls=AuthentikListWidgetConfig, + default_config={"limit": 10}, + refresh_interval_ms=60_000, + ), + widget_kind( + kind="groups", + name="Groups", + description="Read-only Authentik group list.", + model_cls=AuthentikListWidgetConfig, + default_config={"limit": 10}, + refresh_interval_ms=60_000, + ), + widget_kind( + kind="applications", + name="Applications", + description="Read-only Authentik application list.", + model_cls=AuthentikListWidgetConfig, + default_config={"limit": 10}, + refresh_interval_ms=60_000, + ), ], - widget_kinds=[], test_callable=test_connection, ) diff --git a/backend/src/media_library_viewer_api/routers/authentik_users.py b/backend/src/media_library_viewer_api/routers/authentik_users.py index 7d579c0..08758a7 100644 --- a/backend/src/media_library_viewer_api/routers/authentik_users.py +++ b/backend/src/media_library_viewer_api/routers/authentik_users.py @@ -1,10 +1,7 @@ -"""Authentik directory + messaging router. +"""Read-only Authentik directory, access metadata, and 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. +Directory data is service-scoped and fails gracefully so the service page can +render a useful empty/error state when Authentik is unavailable. """ from __future__ import annotations @@ -12,7 +9,7 @@ from __future__ import annotations import logging from typing import Any -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from media_library_viewer_api.clients.authentik import AuthentikClient @@ -30,7 +27,7 @@ router = APIRouter(prefix="/api/services/authentik", tags=["authentik"]) class MessageRequest(BaseModel): - """Compose-request body for the Authentik messaging endpoint.""" + """Compose-request body for the existing Authentik messaging endpoint.""" recipient_emails: list[str] subject: str @@ -38,39 +35,99 @@ class MessageRequest(BaseModel): 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) + return AuthentikClient( + base_url=str(service.config.get("base_url") or "").rstrip("/"), + api_token=str(service.secrets.get("api_token") or ""), + timeout=timeout, + ) -def _empty(error: str) -> dict[str, Any]: +def _empty_directory(error: str) -> dict[str, Any]: return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error} +def _empty_collection(error: str) -> dict[str, Any]: + return {"items": [], "total": 0, "error": error} + + +def _service_or_error(store: SettingsStore, service_id: str) -> ServiceRecord | None: + return resolve_service_record(store, "authentik", service_id) + + @router.get("/{service_id}/users") def get_authentik_users( service_id: str, search: str | None = None, - page: int = 1, - page_size: int = 50, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), store: SettingsStore = Depends(get_settings_store), ) -> dict[str, Any]: - """Paginated Authentik user directory for a specific service instance.""" - service = resolve_service_record(store, "authentik", service_id) + """Paginated raw directory users for the existing messaging surface.""" + service = _service_or_error(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") - + return _empty_directory("Authentik service not configured") try: - client = _build_client(service) - return client.users(search=search, page=page, page_size=page_size) + return _build_client(service).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") + return _empty_directory("Authentik is unreachable") + + +@router.get("/{service_id}/access-summary") +def get_authentik_access_summary( + service_id: str, + search: str | None = None, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + """User groups plus explicit staff/superuser flags, not effective permissions.""" + service = _service_or_error(store, service_id) + if service is None: + return _empty_directory("Authentik service not configured") + try: + return _build_client(service).access_summaries(search=search, page=page, page_size=page_size) + except Exception: + logger.exception("Authentik access summary query failed for service %s", service_id) + return _empty_directory("Authentik is unreachable") + + +@router.get("/{service_id}/groups") +def get_authentik_groups( + service_id: str, + limit: int = Query(default=100, ge=1, le=200), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + """Display-safe, service-scoped Authentik group list.""" + service = _service_or_error(store, service_id) + if service is None: + return _empty_collection("Authentik service not configured") + try: + return _build_client(service).groups(limit=limit) + except Exception: + logger.exception("Authentik groups query failed for service %s", service_id) + return _empty_collection("Authentik is unreachable") + + +@router.get("/{service_id}/applications") +def get_authentik_applications( + service_id: str, + limit: int = Query(default=100, ge=1, le=200), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + """Display-safe Authentik applications without provider or policy details.""" + service = _service_or_error(store, service_id) + if service is None: + return _empty_collection("Authentik service not configured") + try: + return _build_client(service).applications(limit=limit) + except Exception: + logger.exception("Authentik applications query failed for service %s", service_id) + return _empty_collection("Authentik is unreachable") @router.get("/{service_id}/message/status") @@ -80,8 +137,7 @@ def get_authentik_message_status( mail_queue: MailQueue = Depends(get_mail_queue), ) -> dict[str, Any]: """Mail-queue status snapshot for the Authentik messaging tab.""" - service = resolve_service_record(store, "authentik", service_id) - if service is None: + if _service_or_error(store, service_id) is None: return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"} return mail_queue.status() @@ -94,20 +150,16 @@ def post_authentik_message( 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, "authentik", service_id) - if service is None: + if _service_or_error(store, service_id) is None: return {"status": "error", "error": "Authentik service not configured"} - - recipients = [r.strip() for r in body.recipient_emails if r.strip()] + recipients = [recipient.strip() for recipient in body.recipient_emails if recipient.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, @@ -115,8 +167,4 @@ def post_authentik_message( 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), - } + return {"status": "queued", "request_id": request_id, "recipient_count": len(recipients)} diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index e175d69..2b4f987 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -19,6 +19,7 @@ from typing import Any, Protocol import requests +from media_library_viewer_api.clients.authentik import AuthentikClient from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.qbittorrent import QbittorrentClient from media_library_viewer_api.domain.dashboard import ( @@ -316,6 +317,36 @@ class AlertmanagerWidgetSource: return {"error": f"Alertmanager query failed: {exc}"} +class AuthentikWidgetSource: + """Fetch bounded, display-safe Authentik directory metadata.""" + + async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: + try: + if service is None: + return {"error": "Authentik widget is missing its service"} + base_url = str(service.config.get("base_url") or "") + api_token = str(service.secrets.get("api_token") or "") + timeout = _safe_int(service.config.get("timeout_seconds") or 60, 60) + limit = max(1, min(_safe_int(config.get("limit") or 10, 10), 50)) + client = await asyncio.wait_for( + asyncio.to_thread(AuthentikClient, base_url, api_token, timeout), timeout=timeout + ) + if widget_kind == "access_summary": + return await asyncio.wait_for( + asyncio.to_thread(client.access_summaries, page=1, page_size=limit), timeout=timeout + ) + if widget_kind == "groups": + return await asyncio.wait_for(asyncio.to_thread(client.groups, limit=limit), timeout=timeout) + if widget_kind == "applications": + return await asyncio.wait_for(asyncio.to_thread(client.applications, limit=limit), timeout=timeout) + return {"error": f"Unknown Authentik widget kind: {widget_kind}"} + except asyncio.TimeoutError as _timeout_error: + return {"error": "Authentik data fetch timed out"} + except Exception as exc: + logger.exception("authentik adapter failed") + return {"error": f"Authentik data fetch failed: {exc}"} + + class JellyfinWidgetSource: """Fetch Jellyfin sessions and map them to activity rows.""" @@ -531,6 +562,7 @@ SERVICE_ADAPTERS: dict[str, WidgetSource] = { "qbittorrent": QbittorrentWidgetSource(), "alertmanager": AlertmanagerWidgetSource(), "jellyfin": JellyfinWidgetSource(), + "authentik": AuthentikWidgetSource(), "remote_machine": SshTaskWidgetSource(), } diff --git a/backend/tests/test_authentik_client.py b/backend/tests/test_authentik_client.py index b318de0..2f8ed88 100644 --- a/backend/tests/test_authentik_client.py +++ b/backend/tests/test_authentik_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from pathlib import Path from unittest.mock import MagicMock, patch @@ -19,7 +20,7 @@ TEST_KEY = Fernet.generate_key().decode() @pytest.fixture(autouse=True) -def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None: +def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: """Provide a stable MANAGE_ENCRYPTION_KEY for every test.""" monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY) reset_encryption_key_cache() @@ -28,7 +29,7 @@ def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture() -def store(tmp_path: Path) -> SettingsStore: +def store(tmp_path: Path) -> Generator[SettingsStore, None, None]: s = SettingsStore(tmp_path / "settings.sqlite") s.ensure_defaults() app.dependency_overrides[get_settings_store] = lambda: s @@ -181,3 +182,103 @@ class TestAuthentikUsersEndpoint: data = response.json() assert data["items"] == [] assert "error" in data + + +class TestAuthentikAccessMetadata: + @patch.object(AuthentikClient, "get") + def test_groups_and_applications_paginate_and_whitelist_fields(self, mock_get: MagicMock) -> None: + def payload(path: str, **params: object) -> dict[str, object]: + if path == "/core/groups/": + if params["page"] == 1: + return {"pagination": {"count": 2}, "results": [{"pk": 1, "name": "Admins"}]} + return {"pagination": {"count": 2}, "results": [{"id": "g2", "display_name": "Readers"}]} + return { + "pagination": {"count": 1}, + "results": [ + { + "pk": 3, + "name": "Portal", + "slug": "portal", + "meta_launch_url": "https://portal.example.com", + "provider": {"client_secret": "must-not-leak"}, + "policy_engine_mode": "any", + } + ], + } + + mock_get.side_effect = payload + auth = AuthentikClient(base_url="https://auth.example.com", api_token="t") + assert auth.groups(limit=2)["items"] == [{"id": "1", "name": "Admins"}, {"id": "g2", "name": "Readers"}] + application = auth.applications(limit=1)["items"][0] + assert application == { + "id": "3", + "name": "Portal", + "slug": "portal", + "launch_url": "https://portal.example.com", + } + assert "provider" not in application + + @patch.object(AuthentikClient, "get") + def test_access_summary_uses_group_references_without_user_detail_calls(self, mock_get: MagicMock) -> None: + def payload(path: str, **params: object) -> dict[str, object]: + if path == "/core/users/": + return { + "pagination": {"count": 1}, + "results": [ + { + "pk": 7, + "username": "alice", + "name": "Alice", + "groups": [1, {"id": "missing"}], + "is_superuser": True, + "is_staff": False, + } + ], + } + assert path == "/core/groups/" + return {"pagination": {"count": 1}, "results": [{"pk": 1, "name": "Admins"}]} + + mock_get.side_effect = payload + result = AuthentikClient(base_url="https://auth.example.com", api_token="t").access_summaries() + assert result["items"][0]["groups"] == [ + {"id": "1", "name": "Admins", "known": True}, + {"id": "missing", "name": "Unknown group (missing)", "known": False}, + ] + assert bool(result["items"][0]["is_superuser"]) + assert all(call.args[0] in {"/core/users/", "/core/groups/"} for call in mock_get.call_args_list) + + +class TestAuthentikAccessEndpoints: + def test_not_configured_access_collections_return_empty_envelopes(self, store: SettingsStore) -> None: + client = TestClient(app) + for path in ("access-summary", "groups", "applications"): + response = client.get(f"/api/services/authentik/missing/{path}") + assert response.status_code == 200 + assert response.json()["items"] == [] + assert response.json()["error"] == "Authentik service not configured" + + @patch("media_library_viewer_api.routers.authentik_users.AuthentikClient") + def test_access_summary_endpoint_returns_normalized_data( + self, mock_client_cls: MagicMock, store: SettingsStore + ) -> None: + mock_client = MagicMock() + mock_client.access_summaries.return_value = { + "items": [{"id": "1", "groups": []}], + "total": 1, + "page": 1, + "page_size": 25, + } + mock_client_cls.return_value = mock_client + service = store.upsert_service( + { + "service_type": "authentik", + "name": "Main", + "config": {"base_url": "https://auth.example.com"}, + "enabled": True, + }, + secret_values={"api_token": "secret-token"}, + ) + response = TestClient(app).get(f"/api/services/authentik/{service['id']}/access-summary?page_size=25") + assert response.status_code == 200 + assert response.json()["items"] == [{"id": "1", "groups": []}] + mock_client.access_summaries.assert_called_once_with(search=None, page=1, page_size=25) diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index b182ba5..57f4b8b 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -28,6 +28,13 @@ from media_library_viewer_api.services.secrets import ( ) from media_library_viewer_api.services.settings_store import SettingsStore + +def _definition(service_type: str): + definition = get_service_definition(service_type) + assert definition + return definition + + TEST_KEY = Fernet.generate_key().decode() @@ -73,7 +80,7 @@ def test_registry_contains_eight_service_types(): def test_jellyseerr_absorbed_into_jellyfin(): """Jellyseerr is no longer its own service type (absorbed into Jellyfin).""" assert "jellyseerr" not in SERVICE_DEFINITIONS - jellyfin = get_service_definition("jellyfin") + jellyfin = _definition("jellyfin") jellyfin_config = jellyfin.config_schema["properties"] assert "jellyseerr_url" in jellyfin_config # jellyseerr_api_key moved from config to a secret field. @@ -82,8 +89,8 @@ def test_jellyseerr_absorbed_into_jellyfin(): def test_backups_service_definition(): - definition = get_service_definition("backups") - assert definition is not None + definition = _definition("backups") + assert definition assert definition.secret_fields == [] assert {wk.kind for wk in definition.widget_kinds} == {"summary"} schema = definition.config_schema @@ -91,35 +98,43 @@ def test_backups_service_definition(): def test_authentik_service_definition(): - definition = get_service_definition("authentik") - assert definition is not None + definition = _definition("authentik") + assert definition assert {sf.key for sf in definition.secret_fields} == {"api_token"} - assert definition.secret_fields[0].required is True - assert definition.widget_kinds == [] + assert definition.secret_fields[0].required + assert {widget.kind for widget in definition.widget_kinds} == { + "access_summary", + "groups", + "applications", + } schema = definition.config_schema assert "base_url" in schema["properties"] assert "timeout_seconds" in schema["properties"] def test_definitions_declare_widget_kinds(): - assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"} - assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} - assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == { + assert {wk.kind for wk in _definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"} + assert {wk.kind for wk in _definition("alertmanager").widget_kinds} == {"active_alerts"} + assert {wk.kind for wk in _definition("jellyfin").widget_kinds} == { "activity", "now_playing", "stat", "stats_overview", } - assert get_service_definition("nextcloud").widget_kinds == [] - assert get_service_definition("authentik").widget_kinds == [] - assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"} - assert {wk.kind for wk in get_service_definition("remote_machine").widget_kinds} == {"task_output"} + assert _definition("nextcloud").widget_kinds == [] + assert {widget.kind for widget in _definition("authentik").widget_kinds} == { + "access_summary", + "groups", + "applications", + } + assert {wk.kind for wk in _definition("backups").widget_kinds} == {"summary"} + assert {wk.kind for wk in _definition("remote_machine").widget_kinds} == {"task_output"} def test_widget_kind_lookup(): - assert get_widget_kind("prometheus", "metric") is not None - assert get_widget_kind("prometheus", "missing") is None - assert get_widget_kind("unknown", "metric") is None + assert get_widget_kind("prometheus", "metric") + assert not get_widget_kind("prometheus", "missing") + assert not get_widget_kind("unknown", "metric") def test_chart_widget_kinds_expose_unit_and_scale_options(): @@ -128,25 +143,28 @@ def test_chart_widget_kinds_expose_unit_and_scale_options(): scales = ["auto", "k", "m", "g", "t"] prom_chart = get_widget_kind("prometheus", "chart") - assert prom_chart is not None + assert prom_chart prom_props = prom_chart.config_schema["properties"] assert prom_props["unit"]["enum"] == units assert prom_props["scale"]["enum"] == scales qbit_speed = get_widget_kind("qbittorrent", "speed") - assert qbit_speed is not None + assert qbit_speed qbit_props = qbit_speed.config_schema["properties"] assert qbit_props["unit"]["enum"] == units assert qbit_props["scale"]["enum"] == scales # qBittorrent speed data is bytes/sec by default. assert qbit_speed.default_config["unit"] == "bytes_per_sec" # totals/active are not graphs and stay option-less. - assert "unit" not in get_widget_kind("qbittorrent", "totals").config_schema["properties"] - assert "unit" not in get_widget_kind("qbittorrent", "active").config_schema["properties"] + qbit_totals = get_widget_kind("qbittorrent", "totals") + qbit_active = get_widget_kind("qbittorrent", "active") + assert qbit_totals and qbit_active + assert "unit" not in qbit_totals.config_schema["properties"] + assert "unit" not in qbit_active.config_schema["properties"] def test_service_config_schema_is_json_schema(): - schema = get_service_definition("prometheus").config_schema + schema = _definition("prometheus").config_schema assert schema["type"] == "object" assert "grafana_url" in schema["properties"] @@ -321,7 +339,7 @@ def test_service_test_uses_stored_secrets_when_not_reentered(client): }, ) assert res.status_code == 200 - assert res.json()["ok"] is True + assert res.json()["ok"] # The stored grafana_api_key was used for the request (not empty). headers = mock_post.call_args.kwargs["headers"] assert headers["Authorization"] == "Bearer secret-token" @@ -354,16 +372,16 @@ def test_invalid_config_rejected(client): ) def test_service_base_url_requires_http_schema(bad_url): """Every service base_url must include an http:// or https:// schema.""" - model = get_service_definition("prometheus").config_model + model = _definition("prometheus").config_model with pytest.raises(ValidationError): model.model_validate({"grafana_url": bad_url, "timeout_seconds": 5}) @pytest.mark.parametrize("service_type", ["alertmanager", "jellyfin", "authentik", "nextcloud"]) def test_service_base_url_accepts_absolute_urls(service_type): - model = get_service_definition(service_type).config_model + model = _definition(service_type).config_model instance = model.model_validate({"base_url": "https://example.com"}) - assert instance.base_url == "https://example.com" + assert getattr(instance, "base_url") == "https://example.com" def test_unknown_secret_field_rejected(client): @@ -451,13 +469,14 @@ def test_delete_service_cascades_to_widgets(client, tmp_path): ) store.delete_service(service["id"]) - assert store.get_service(service["id"]) is None + assert not store.get_service(service["id"]) with store.connect() as conn: remaining = conn.execute( "SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?", (service["id"],), ).fetchone() - assert int(remaining[0]) == 0 + assert remaining is not None + assert remaining[0] == 0 # --------------------------------------------------------------------------- @@ -593,6 +612,7 @@ def test_jellyseerr_migrates_into_single_jellyfin(tmp_path): # Jellyfin config gained jellyseerr_url; the api key is now a secret. migrated = store.get_service(jellyfin["id"]) + assert migrated assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com" assert "jellyseerr_api_key" not in migrated["config"] assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "js-key" @@ -621,11 +641,13 @@ def test_jellyseerr_api_key_migrates_from_config_to_secret(tmp_path): store.ensure_defaults() # runs the config->secret migration migrated = store.get_service(jellyfin["id"]) + assert migrated assert "jellyseerr_api_key" not in migrated["config"] assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key" # Idempotent: a second run keeps it in secrets, doesn't wipe it. store.ensure_defaults() migrated = store.get_service(jellyfin["id"]) + assert migrated assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key" diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 7f35a86..5ca3f78 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -5,7 +5,7 @@ from __future__ import annotations import time from types import SimpleNamespace from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from cryptography.fernet import Fernet @@ -16,6 +16,7 @@ from media_library_viewer_api.main import app from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.widgets.sources import ( AlertmanagerWidgetSource, + AuthentikWidgetSource, BackupsWidgetSource, JellyfinWidgetSource, ServiceRecord, @@ -416,6 +417,31 @@ async def test_static_adapter(): assert result == {"text": "hi"} +@pytest.mark.asyncio +async def test_authentik_adapter_returns_bounded_access_summaries(): + client = MagicMock() + client.access_summaries.return_value = {"items": [{"id": "u1", "groups": []}], "total": 1} + service = ServiceRecord( + id="auth", + service_type="authentik", + name="Auth", + config={"base_url": "https://auth.example.com", "timeout_seconds": 5}, + secrets={"api_token": "token"}, + ) + with patch("media_library_viewer_api.widgets.sources.AuthentikClient", return_value=client): + result = await AuthentikWidgetSource().fetch(service, "access_summary", {"limit": 100}) + assert result["items"] == [{"id": "u1", "groups": []}] + client.access_summaries.assert_called_once_with(page=1, page_size=50) + + +def test_authentik_definition_declares_read_only_widget_kinds(): + from media_library_viewer_api.integrations.registry import get_service_definition + + definition = get_service_definition("authentik") + assert definition is not None + assert {kind.kind for kind in definition.widget_kinds} == {"access_summary", "groups", "applications"} + + @pytest.mark.asyncio async def test_backups_adapter(client): store = app.dependency_overrides[get_settings_store]() diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index a332cdd..1326070 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -134,6 +134,16 @@ fully removed (web-ui-rework; see decision log 2026-06-17). - The File Browser should persist its current directory and selected file across reloads and tab switches. - Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys. +### Authentik Directory and Access Metadata + +- Authentik is the read-only identity-directory source for its service page and dashboard widgets. +- Provide read-only user access summaries showing group membership and explicit staff/superuser status. +- Label the summary as **access metadata**, not complete effective authorization: conditional or expression-based Authentik policies are not evaluated by Manage. +- Provide read-only groups and applications lists. Application entries may include safe display metadata such as name, slug, launch URL, and policy-engine mode, but must never expose provider configuration, tokens, or raw policy data. +- The configured Authentik API token must have read access to users, groups, and applications. +- Authentik data reads must remain service-instance scoped and tolerate unavailable upstream services with an empty/error state. +- Authentik widgets are read-only and support bounded display limits for access summaries, groups, and applications. + ### Remote Filesystem over SSH - Connect to a remote media server via SSH. diff --git a/frontend/src/api/authentik.ts b/frontend/src/api/authentik.ts index 14a874f..cc09a96 100644 --- a/frontend/src/api/authentik.ts +++ b/frontend/src/api/authentik.ts @@ -1,4 +1,4 @@ -/** API client for the Authentik service (directory + messaging). */ +/** API client for Authentik directory, access metadata, and messaging. */ import { get, post } from "./shared"; export interface AuthentikUser { @@ -19,6 +19,49 @@ export interface AuthentikUsersResponse { error?: string; } +export interface AuthentikGroupReference { + id: string; + name: string; + known: boolean; +} + +export interface AuthentikAccessSummary { + id: string; + username: string; + name: string; + email: string; + is_active: boolean; + is_superuser: boolean; + is_staff: boolean; + groups: AuthentikGroupReference[]; +} + +export interface AuthentikAccessSummaryResponse { + items: AuthentikAccessSummary[]; + total: number; + page: number; + page_size: number; + error?: string; +} + +export interface AuthentikGroup { + id: string; + name: string; +} + +export interface AuthentikApplication { + id: string; + name: string; + slug: string; + launch_url: string; +} + +export interface AuthentikCollectionResponse { + items: T[]; + total: number; + error?: string; +} + export async function fetchAuthentikUsers( serviceId: string, params: { search?: string; page?: number; page_size?: number }, @@ -33,6 +76,40 @@ export async function fetchAuthentikUsers( ); } +export async function fetchAuthentikAccessSummary( + serviceId: string, + params: { search?: string; page?: number; page_size?: number }, +): Promise { + return get( + `/api/services/authentik/${serviceId}/access-summary`, + { + search: params.search ?? "", + page: String(params.page ?? 1), + page_size: String(params.page_size ?? 50), + }, + ); +} + +export async function fetchAuthentikGroups( + serviceId: string, + limit = 100, +): Promise> { + return get>( + `/api/services/authentik/${serviceId}/groups`, + { limit: String(limit) }, + ); +} + +export async function fetchAuthentikApplications( + serviceId: string, + limit = 100, +): Promise> { + return get>( + `/api/services/authentik/${serviceId}/applications`, + { limit: String(limit) }, + ); +} + export interface AuthentikMessageInput { recipient_emails: string[]; subject: string; diff --git a/frontend/src/hooks/useAuthentik.ts b/frontend/src/hooks/useAuthentik.ts index d783f7d..648ae2e 100644 --- a/frontend/src/hooks/useAuthentik.ts +++ b/frontend/src/hooks/useAuthentik.ts @@ -1,6 +1,9 @@ -/** Hooks for the Authentik directory + messaging tabs. */ +/** Hooks for Authentik directory, access metadata, and messaging tabs. */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + fetchAuthentikAccessSummary, + fetchAuthentikApplications, + fetchAuthentikGroups, fetchAuthentikMessageStatus, fetchAuthentikUsers, sendAuthentikMessage, @@ -17,6 +20,33 @@ export function useAuthentikUsers( }); } +export function useAuthentikAccessSummary( + serviceId: string, + params: { search?: string; page?: number; page_size?: number }, +) { + return useQuery({ + queryKey: ["authentik", "access-summary", serviceId, params], + queryFn: () => fetchAuthentikAccessSummary(serviceId, params), + staleTime: 10_000, + }); +} + +export function useAuthentikGroups(serviceId: string, limit = 100) { + return useQuery({ + queryKey: ["authentik", "groups", serviceId, limit], + queryFn: () => fetchAuthentikGroups(serviceId, limit), + staleTime: 30_000, + }); +} + +export function useAuthentikApplications(serviceId: string, limit = 100) { + return useQuery({ + queryKey: ["authentik", "applications", serviceId, limit], + queryFn: () => fetchAuthentikApplications(serviceId, limit), + staleTime: 30_000, + }); +} + export function useSendAuthentikMessage(serviceId: string) { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/integrations/registry.test.ts b/frontend/src/integrations/registry.test.ts index 5f212c0..5d268d4 100644 --- a/frontend/src/integrations/registry.test.ts +++ b/frontend/src/integrations/registry.test.ts @@ -12,6 +12,7 @@ describe("service registry", () => { it("registers the backend service types", () => { expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([ "alertmanager", + "authentik", "jellyfin", "nextcloud", "prometheus", @@ -30,6 +31,11 @@ describe("service registry", () => { expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([ "active_alerts", ]); + expect(SERVICE_REGISTRY.authentik.widgets.map((w) => w.kind)).toEqual([ + "access_summary", + "groups", + "applications", + ]); expect(SERVICE_REGISTRY.remote_machine.widgets.map((w) => w.kind)).toEqual([ "task_output", ]); @@ -41,10 +47,17 @@ describe("service registry", () => { }); it("exposes unit/scale options on graph widget kinds", () => { - const propsOf = (binding: { configSchema: Record } | undefined) => - (binding?.configSchema as { properties?: Record } | undefined) - ?.properties ?? {}; - const chart = getServiceBinding("prometheus")?.widgets.find((w) => w.kind === "chart"); + const propsOf = ( + binding: { configSchema: Record } | undefined, + ) => + ( + binding?.configSchema as + | { properties?: Record } + | undefined + )?.properties ?? {}; + const chart = getServiceBinding("prometheus")?.widgets.find( + (w) => w.kind === "chart", + ); const speed = getServiceBinding("qbittorrent")?.widgets.find( (w) => w.kind === "speed", ); diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index 0ed845c..a0c95f0 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -1,5 +1,8 @@ import type { ComponentType } from "react"; import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget"; +import { AuthentikAccessSummaryWidget } from "../widgets/AuthentikAccessSummaryWidget"; +import { AuthentikApplicationsWidget } from "../widgets/AuthentikApplicationsWidget"; +import { AuthentikGroupsWidget } from "../widgets/AuthentikGroupsWidget"; import { BackupsWidget } from "../widgets/BackupsWidget"; import { MetricChartWidget } from "../widgets/MetricChartWidget"; import { MetricGaugeWidget } from "../widgets/MetricGaugeWidget"; @@ -76,6 +79,53 @@ const AXIS_FORMAT_PROPERTIES = { }; export const SERVICE_REGISTRY: Record = { + authentik: { + serviceType: "authentik", + name: "Authentik", + description: "Read-only user directory, group, and application metadata.", + widgets: [ + { + kind: "access_summary", + name: "User access summary", + description: + "Group membership and explicit privileged flags; not effective authorization.", + refreshIntervalMs: 60_000, + defaultConfig: { limit: 10 }, + configSchema: { + type: "object", + properties: { limit: { type: "integer", minimum: 1, maximum: 50 } }, + required: [], + }, + component: AuthentikAccessSummaryWidget, + }, + { + kind: "groups", + name: "Groups", + description: "Read-only Authentik group list.", + refreshIntervalMs: 60_000, + defaultConfig: { limit: 10 }, + configSchema: { + type: "object", + properties: { limit: { type: "integer", minimum: 1, maximum: 50 } }, + required: [], + }, + component: AuthentikGroupsWidget, + }, + { + kind: "applications", + name: "Applications", + description: "Read-only Authentik application list.", + refreshIntervalMs: 60_000, + defaultConfig: { limit: 10 }, + configSchema: { + type: "object", + properties: { limit: { type: "integer", minimum: 1, maximum: 50 } }, + required: [], + }, + component: AuthentikApplicationsWidget, + }, + ], + }, alertmanager: { serviceType: "alertmanager", name: "Alertmanager", diff --git a/frontend/src/pages/service-tabs/ApplicationsTab.tsx b/frontend/src/pages/service-tabs/ApplicationsTab.tsx new file mode 100644 index 0000000..ef14319 --- /dev/null +++ b/frontend/src/pages/service-tabs/ApplicationsTab.tsx @@ -0,0 +1,80 @@ +/** ApplicationsTab — read-only Authentik application directory. */ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAuthentikApplications } from "../../hooks/useAuthentik"; +import type { ServiceInstance } from "../../types"; + +export function ApplicationsTab({ instance }: { instance: ServiceInstance }) { + const { data, isLoading } = useAuthentikApplications(instance.id); + const applications = data?.items ?? []; + return ( +
+ + + Application metadata only; providers, outposts, policies, and + effective access evaluation are not shown. + + + {data?.error ? ( + + {data.error} + + ) : null} +
+ + + + Application + Slug + Launch URL + + + + {isLoading && applications.length === 0 ? ( + + + + + + ) : null} + {!isLoading && applications.length === 0 ? ( + + + No applications found. + + + ) : null} + {applications.map((application) => ( + + + {application.name} + + + {application.slug || "—"} + + + {application.launch_url || "—"} + + + ))} + +
+
+ {data && data.total > applications.length ? ( +

+ Showing the first {applications.length} of {data.total} applications. +

+ ) : null} +
+ ); +} diff --git a/frontend/src/pages/service-tabs/GroupsTab.tsx b/frontend/src/pages/service-tabs/GroupsTab.tsx new file mode 100644 index 0000000..faf828f --- /dev/null +++ b/frontend/src/pages/service-tabs/GroupsTab.tsx @@ -0,0 +1,66 @@ +/** GroupsTab — read-only Authentik group directory. */ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAuthentikGroups } from "../../hooks/useAuthentik"; +import type { ServiceInstance } from "../../types"; + +export function GroupsTab({ instance }: { instance: ServiceInstance }) { + const { data, isLoading } = useAuthentikGroups(instance.id); + const groups = data?.items ?? []; + return ( +
+ {data?.error ? ( + + {data.error} + + ) : null} +
+ + + + Group + ID + + + + {isLoading && groups.length === 0 ? ( + + + + + + ) : null} + {!isLoading && groups.length === 0 ? ( + + + No groups found. + + + ) : null} + {groups.map((group) => ( + + {group.name} + + {group.id} + + + ))} + +
+
+ {data && data.total > groups.length ? ( +

+ Showing the first {groups.length} of {data.total} groups. +

+ ) : null} +
+ ); +} diff --git a/frontend/src/pages/service-tabs/UsersTab.tsx b/frontend/src/pages/service-tabs/UsersTab.tsx index e8840dc..06e422a 100644 --- a/frontend/src/pages/service-tabs/UsersTab.tsx +++ b/frontend/src/pages/service-tabs/UsersTab.tsx @@ -1,4 +1,4 @@ -/** UsersTab — Authentik user directory for the Authentik service page. */ +/** UsersTab — Authentik access metadata, not an effective-permissions calculation. */ import { useState } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; @@ -13,7 +13,7 @@ import { TableRow, } from "@/components/ui/table"; import type { ServiceInstance } from "../../types"; -import { useAuthentikUsers } from "../../hooks/useAuthentik"; +import { useAuthentikAccessSummary } from "../../hooks/useAuthentik"; const PAGE_SIZE = 25; @@ -21,14 +21,11 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) { const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const [committedSearch, setCommittedSearch] = useState(""); - - const { data, isLoading } = useAuthentikUsers(instance.id, { + const { data, isLoading } = useAuthentikAccessSummary(instance.id, { search: committedSearch, page, page_size: PAGE_SIZE, }); - - const error = data?.error; const users = data?.items ?? []; const total = data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); @@ -40,19 +37,25 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) { return (
- {error ? ( + + + Shows Authentik group membership and explicit staff/superuser flags. + This is access metadata, not a complete effective-authorization + calculation. + + + {data?.error ? ( - {error} + {data.error} ) : null} -
setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleSearch(); + onChange={(event) => setSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleSearch(); }} className="max-w-xs" /> @@ -60,52 +63,75 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) { Search
-
Name Username - Email - Status + Groups + Privileges + Status {isLoading && users.length === 0 ? ( - + Loading… - ) : users.length === 0 ? ( + ) : null} + {!isLoading && users.length === 0 ? ( - + No users found. - ) : ( - users.map((user) => ( - - - {user.name || "—"} - - {user.username} - - {user.email || "—"} - - - - {user.is_active ? "Active" : "Inactive"} - - - - )) - )} + ) : null} + {users.map((user) => ( + + + {user.name || "—"} + + {user.username || "—"} + +
+ {user.groups.length ? ( + user.groups.map((group) => ( + + {group.name} + + )) + ) : ( + None + )} +
+
+ +
+ {user.is_superuser ? ( + Superuser + ) : null} + {user.is_staff ? Staff : null} + {!user.is_superuser && !user.is_staff ? ( + None + ) : null} +
+
+ + + {user.is_active ? "Active" : "Inactive"} + + +
+ ))}
- {total > 0 ? (
@@ -115,7 +141,7 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {