feat: add Authentik access widgets
This commit is contained in:
@@ -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"]}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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]()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<T> {
|
||||
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<AuthentikAccessSummaryResponse> {
|
||||
return get<AuthentikAccessSummaryResponse>(
|
||||
`/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<AuthentikCollectionResponse<AuthentikGroup>> {
|
||||
return get<AuthentikCollectionResponse<AuthentikGroup>>(
|
||||
`/api/services/authentik/${serviceId}/groups`,
|
||||
{ limit: String(limit) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAuthentikApplications(
|
||||
serviceId: string,
|
||||
limit = 100,
|
||||
): Promise<AuthentikCollectionResponse<AuthentikApplication>> {
|
||||
return get<AuthentikCollectionResponse<AuthentikApplication>>(
|
||||
`/api/services/authentik/${serviceId}/applications`,
|
||||
{ limit: String(limit) },
|
||||
);
|
||||
}
|
||||
|
||||
export interface AuthentikMessageInput {
|
||||
recipient_emails: string[];
|
||||
subject: string;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown> } | undefined) =>
|
||||
(binding?.configSchema as { properties?: Record<string, { enum?: string[] }> } | undefined)
|
||||
?.properties ?? {};
|
||||
const chart = getServiceBinding("prometheus")?.widgets.find((w) => w.kind === "chart");
|
||||
const propsOf = (
|
||||
binding: { configSchema: Record<string, unknown> } | undefined,
|
||||
) =>
|
||||
(
|
||||
binding?.configSchema as
|
||||
| { properties?: Record<string, { enum?: string[] }> }
|
||||
| undefined
|
||||
)?.properties ?? {};
|
||||
const chart = getServiceBinding("prometheus")?.widgets.find(
|
||||
(w) => w.kind === "chart",
|
||||
);
|
||||
const speed = getServiceBinding("qbittorrent")?.widgets.find(
|
||||
(w) => w.kind === "speed",
|
||||
);
|
||||
|
||||
@@ -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<string, ServiceBinding> = {
|
||||
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",
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Application metadata only; providers, outposts, policies, and
|
||||
effective access evaluation are not shown.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Application</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>Launch URL</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && applications.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{!isLoading && applications.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-muted-foreground">
|
||||
No applications found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{applications.map((application) => (
|
||||
<TableRow
|
||||
key={application.id || application.slug || application.name}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{application.name}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{application.slug || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm truncate text-muted-foreground">
|
||||
{application.launch_url || "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{data && data.total > applications.length ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing the first {applications.length} of {data.total} applications.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Group</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && groups.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{!isLoading && groups.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-muted-foreground">
|
||||
No groups found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">{group.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{group.id}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{data && data.total > groups.length ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing the first {groups.length} of {data.total} groups.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Shows Authentik group membership and explicit staff/superuser flags.
|
||||
This is access metadata, not a complete effective-authorization
|
||||
calculation.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search users…"
|
||||
value={search}
|
||||
onChange={(e) => 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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="w-24">Status</TableHead>
|
||||
<TableHead>Groups</TableHead>
|
||||
<TableHead>Privileges</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
Loading…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
) : null}
|
||||
{!isLoading && users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
No users found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.pk}>
|
||||
<TableCell className="font-medium">
|
||||
{user.name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.is_active ? "default" : "secondary"}>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
) : null}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id || user.username}>
|
||||
<TableCell className="font-medium">
|
||||
{user.name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{user.username || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex max-w-sm flex-wrap gap-1">
|
||||
{user.groups.length ? (
|
||||
user.groups.map((group) => (
|
||||
<Badge
|
||||
key={group.id}
|
||||
variant={group.known ? "secondary" : "destructive"}
|
||||
>
|
||||
{group.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">None</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.is_superuser ? (
|
||||
<Badge variant="destructive">Superuser</Badge>
|
||||
) : null}
|
||||
{user.is_staff ? <Badge>Staff</Badge> : null}
|
||||
{!user.is_superuser && !user.is_staff ? (
|
||||
<span className="text-muted-foreground">None</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.is_active ? "default" : "secondary"}>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{total > 0 ? (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
@@ -115,7 +141,7 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
@@ -123,7 +149,9 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
|
||||
@@ -15,22 +15,28 @@ const instance: ServiceInstance = {
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useAuthentik", () => ({
|
||||
useAuthentikUsers: vi.fn(() => ({
|
||||
useAuthentikAccessSummary: vi.fn(() => ({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
pk: 1,
|
||||
id: "1",
|
||||
username: "alice",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
is_active: true,
|
||||
is_superuser: true,
|
||||
is_staff: false,
|
||||
groups: [{ id: "admins", name: "Admins", known: true }],
|
||||
},
|
||||
{
|
||||
pk: 2,
|
||||
id: "2",
|
||||
username: "bob",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
is_active: false,
|
||||
is_superuser: false,
|
||||
is_staff: true,
|
||||
groups: [{ id: "gone", name: "Unknown group (gone)", known: false }],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
@@ -42,13 +48,17 @@ vi.mock("../../../hooks/useAuthentik", () => ({
|
||||
}));
|
||||
|
||||
describe("UsersTab", () => {
|
||||
it("renders the directory table with users", () => {
|
||||
it("renders group membership and explicit privilege metadata", () => {
|
||||
render(<UsersTab instance={instance} />);
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inactive")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admins")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unknown group (gone)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Superuser")).toBeInTheDocument();
|
||||
expect(screen.getByText("Staff")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/not a complete effective-authorization calculation/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders search input and pagination", () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ import { FilesTab } from "./FilesTab";
|
||||
import { ActionsTab } from "./ActionsTab";
|
||||
import { JobsTab } from "./JobsTab";
|
||||
import { UsersTab } from "./UsersTab";
|
||||
import { GroupsTab } from "./GroupsTab";
|
||||
import { ApplicationsTab } from "./ApplicationsTab";
|
||||
import { MessagingTab } from "./MessagingTab";
|
||||
import { QbittorrentTab } from "./QbittorrentTab";
|
||||
|
||||
@@ -52,6 +54,8 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
|
||||
case "authentik":
|
||||
return [
|
||||
{ label: "Users", Component: UsersTab },
|
||||
{ label: "Groups", Component: GroupsTab },
|
||||
{ label: "Applications", Component: ApplicationsTab },
|
||||
{ label: "Messaging", Component: MessagingTab },
|
||||
];
|
||||
case "alertmanager":
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { AuthentikAccessSummary } from "../api/authentik";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function AuthentikAccessSummaryWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const payload = data?.data as
|
||||
| { items?: AuthentikAccessSummary[] }
|
||||
| undefined;
|
||||
const users = payload?.items ?? [];
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : users.length ? (
|
||||
<ul className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<li
|
||||
key={user.id || user.username}
|
||||
className="rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{user.name || user.username || "Unknown user"}
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
{user.is_superuser ? (
|
||||
<Badge variant="destructive">Superuser</Badge>
|
||||
) : null}
|
||||
{user.is_staff ? <Badge>Staff</Badge> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{user.groups.length ? (
|
||||
user.groups.map((group) => (
|
||||
<Badge
|
||||
key={group.id}
|
||||
variant={group.known ? "secondary" : "destructive"}
|
||||
>
|
||||
{group.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
No group references
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No user access metadata found.
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { AuthentikApplication } from "../api/authentik";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function AuthentikApplicationsWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const payload = data?.data as { items?: AuthentikApplication[] } | undefined;
|
||||
const applications = payload?.items ?? [];
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : applications.length ? (
|
||||
<ul className="space-y-1">
|
||||
{applications.map((application) => (
|
||||
<li
|
||||
key={application.id || application.slug || application.name}
|
||||
className="rounded-md border px-2 py-1 text-sm"
|
||||
>
|
||||
<span className="font-medium">{application.name}</span>
|
||||
{application.slug ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{application.slug}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No applications found.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { AuthentikGroup } from "../api/authentik";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function AuthentikGroupsWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const payload = data?.data as { items?: AuthentikGroup[] } | undefined;
|
||||
const groups = payload?.items ?? [];
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : groups.length ? (
|
||||
<ul className="space-y-1">
|
||||
{groups.map((group) => (
|
||||
<li key={group.id} className="rounded-md border px-2 py-1 text-sm">
|
||||
{group.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No groups found.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
||||
export { AuthentikAccessSummaryWidget } from "./AuthentikAccessSummaryWidget";
|
||||
export { AuthentikApplicationsWidget } from "./AuthentikApplicationsWidget";
|
||||
export { AuthentikGroupsWidget } from "./AuthentikGroupsWidget";
|
||||
export { BackupsWidget } from "./BackupsWidget";
|
||||
export { MetricChartWidget } from "./MetricChartWidget";
|
||||
export { MetricGaugeWidget } from "./MetricGaugeWidget";
|
||||
|
||||
Reference in New Issue
Block a user