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 5e19a3f..dff359a 100644 --- a/backend/src/media_library_viewer_api/routers/authentik_users.py +++ b/backend/src/media_library_viewer_api/routers/authentik_users.py @@ -1,9 +1,10 @@ -"""Authentik directory router — user lookup for the Authentik service page. +"""Authentik directory + messaging router. Resolves an ``authentik`` service instance from the registry, builds an :class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and -proxies a paginated directory query. Graceful "not configured" / "unreachable" -payloads (matching the monitoring router's pattern) so the UI always renders. +proxies paginated directory queries plus message-compose (email enqueue). +Graceful "not configured" / "unreachable" payloads (matching the monitoring +router's pattern) so the UI always renders. """ from __future__ import annotations @@ -12,9 +13,13 @@ import logging from typing import Any from fastapi import APIRouter, Depends +from pydantic import BaseModel from media_library_viewer_api.clients.authentik import AuthentikClient -from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.config import get_settings +from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store +from media_library_viewer_api.services.mail_queue import MailQueue +from media_library_viewer_api.services.mailer import validate_smtp_settings from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record @@ -23,6 +28,14 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/services/authentik", tags=["authentik"]) +class MessageRequest(BaseModel): + """Compose-request body for the Authentik messaging endpoint.""" + + recipient_emails: list[str] + subject: str + html_body: str + + def _resolve_service_record( store: SettingsStore, service_id: str | None = None, @@ -80,3 +93,52 @@ def get_authentik_users( except Exception: logger.exception("Authentik users query failed for service %s", service_id) return _empty("Authentik is unreachable") + + +@router.get("/{service_id}/message/status") +def get_authentik_message_status( + service_id: str, + store: SettingsStore = Depends(get_settings_store), + mail_queue: MailQueue = Depends(get_mail_queue), +) -> dict[str, Any]: + """Mail-queue status snapshot for the Authentik messaging tab.""" + service = _resolve_service_record(store, service_id) + if service is None: + return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"} + return mail_queue.status() + + +@router.post("/{service_id}/message") +def post_authentik_message( + service_id: str, + body: MessageRequest, + store: SettingsStore = Depends(get_settings_store), + mail_queue: MailQueue = Depends(get_mail_queue), +) -> dict[str, Any]: + """Enqueue an email to Authentik-sourced recipients via the mail queue.""" + service = _resolve_service_record(store, service_id) + if service is None: + return {"status": "error", "error": "Authentik service not configured"} + + recipients = [r.strip() for r in body.recipient_emails if r.strip()] + if not recipients: + return {"status": "error", "error": "No recipients with valid email addresses."} + + settings = get_settings() + try: + validate_smtp_settings(settings) + except ValueError as exc: + return {"status": "error", "error": f"SMTP settings invalid: {exc}"} + + request_id = mail_queue.enqueue( + settings=settings, + recipients=recipients, + subject=body.subject, + html_body=body.html_body, + ) + logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients)) + return { + "status": "queued", + "request_id": request_id, + "recipient_count": len(recipients), + } diff --git a/frontend/src/api/authentik.ts b/frontend/src/api/authentik.ts new file mode 100644 index 0000000..14a874f --- /dev/null +++ b/frontend/src/api/authentik.ts @@ -0,0 +1,65 @@ +/** API client for the Authentik service (directory + messaging). */ +import { get, post } from "./shared"; + +export interface AuthentikUser { + pk: number; + username: string; + name: string; + email: string; + is_active: boolean; + avatar: string | null; + [key: string]: unknown; +} + +export interface AuthentikUsersResponse { + items: AuthentikUser[]; + total: number; + page: number; + page_size: number; + error?: string; +} + +export async function fetchAuthentikUsers( + serviceId: string, + params: { search?: string; page?: number; page_size?: number }, +): Promise { + return get( + `/api/services/authentik/${serviceId}/users`, + { + search: params.search ?? "", + page: String(params.page ?? 1), + page_size: String(params.page_size ?? 50), + }, + ); +} + +export interface AuthentikMessageInput { + recipient_emails: string[]; + subject: string; + html_body: string; +} + +export interface AuthentikMessageResponse { + status: string; + request_id?: string; + recipient_count?: number; + error?: string; +} + +export async function sendAuthentikMessage( + serviceId: string, + input: AuthentikMessageInput, +): Promise { + return post( + `/api/services/authentik/${serviceId}/message`, + input, + ); +} + +export async function fetchAuthentikMessageStatus( + serviceId: string, +): Promise> { + return get>( + `/api/services/authentik/${serviceId}/message/status`, + ); +} diff --git a/frontend/src/hooks/useAuthentik.ts b/frontend/src/hooks/useAuthentik.ts new file mode 100644 index 0000000..d783f7d --- /dev/null +++ b/frontend/src/hooks/useAuthentik.ts @@ -0,0 +1,43 @@ +/** Hooks for the Authentik directory + messaging tabs. */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + fetchAuthentikMessageStatus, + fetchAuthentikUsers, + sendAuthentikMessage, +} from "../api/authentik"; + +export function useAuthentikUsers( + serviceId: string, + params: { search?: string; page?: number; page_size?: number }, +) { + return useQuery({ + queryKey: ["authentik", "users", serviceId, params], + queryFn: () => fetchAuthentikUsers(serviceId, params), + staleTime: 10_000, + }); +} + +export function useSendAuthentikMessage(serviceId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: { + recipient_emails: string[]; + subject: string; + html_body: string; + }) => sendAuthentikMessage(serviceId, input), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["authentik", "message-status", serviceId], + }); + }, + }); +} + +export function useAuthentikMessageStatus(serviceId: string) { + return useQuery({ + queryKey: ["authentik", "message-status", serviceId], + queryFn: () => fetchAuthentikMessageStatus(serviceId), + refetchInterval: 5_000, + staleTime: 0, + }); +} diff --git a/frontend/src/pages/service-tabs/MessagingTab.tsx b/frontend/src/pages/service-tabs/MessagingTab.tsx new file mode 100644 index 0000000..34c071b --- /dev/null +++ b/frontend/src/pages/service-tabs/MessagingTab.tsx @@ -0,0 +1,129 @@ +/** MessagingTab — compose email to Authentik users via the mail queue. */ +import { useState } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + useAuthentikUsers, + useSendAuthentikMessage, +} from "../../hooks/useAuthentik"; +import type { ServiceInstance } from "../../types"; + +const DEFAULT_BODY = "

Hello,

Best,
Manage

"; + +export function MessagingTab({ instance }: { instance: ServiceInstance }) { + const [search, setSearch] = useState(""); + const [selectedEmails, setSelectedEmails] = useState>(new Set()); + const [subject, setSubject] = useState(""); + const [htmlBody, setHtmlBody] = useState(DEFAULT_BODY); + + const { data } = useAuthentikUsers(instance.id, { + search, + page: 1, + page_size: 100, + }); + const sendMessage = useSendAuthentikMessage(instance.id); + + const users = (data?.items ?? []).filter((u) => u.email); + const error = data?.error; + + function toggleEmail(email: string) { + setSelectedEmails((prev) => { + const next = new Set(prev); + if (next.has(email)) next.delete(email); + else next.add(email); + return next; + }); + } + + function handleSend() { + if (!subject.trim() || selectedEmails.size === 0) return; + sendMessage.mutate({ + recipient_emails: Array.from(selectedEmails), + subject: subject.trim(), + html_body: htmlBody, + }); + } + + const canSend = + subject.trim() !== "" && selectedEmails.size > 0 && !sendMessage.isPending; + + return ( +
+ {error ? ( + + {error} + + ) : null} + + {sendMessage.data ? ( + + + {sendMessage.data.status === "queued" + ? `Message queued (${sendMessage.data.recipient_count ?? 0} recipients, request ${sendMessage.data.request_id?.slice(0, 8) ?? ""}).` + : `Error: ${sendMessage.data.error ?? "unknown"}`} + + + ) : null} + +
+ + setSearch(e.target.value)} + className="max-w-md" + /> + {users.length > 0 ? ( +
+ {users.slice(0, 20).map((user) => ( + + ))} +
+ ) : null} + {selectedEmails.size > 0 ? ( +

+ {selectedEmails.size} recipient + {selectedEmails.size === 1 ? "" : "s"} selected. +

+ ) : null} +
+ +
+ + setSubject(e.target.value)} + /> +
+ +
+ +