feat: UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish

- Remove top paginator from dashboard events, keep only bottom
- Fix test message locale: pass UI locale to email/matrix bot tests
- Convert webhook auth mode from text input to icon grid selector
- Generate secure UUID tokens for webhook URLs instead of sequential IDs
- Move Recent Payloads into per-provider expandable container (lazy-loaded)
- Make template config languages dynamic via app settings instead of hardcoded
- Change default dev port to 5175
This commit is contained in:
2026-04-11 02:14:15 +03:00
parent 6b2211353d
commit 734e5c9340
29 changed files with 278 additions and 154 deletions
@@ -34,6 +34,22 @@ _LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
async def _get_provider_by_token(
session: AsyncSession, token: str, expected_type: str,
) -> ServiceProvider:
"""Look up a provider by its webhook_token and expected type."""
result = await session.exec(
select(ServiceProvider).where(
ServiceProvider.webhook_token == token,
ServiceProvider.type == expected_type,
)
)
provider = result.first()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
return provider
# ---------------------------------------------------------------------------
# HMAC-SHA256 validation
# ---------------------------------------------------------------------------
@@ -168,16 +184,14 @@ async def _dispatch_webhook_event(
# Gitea webhook endpoint
# ---------------------------------------------------------------------------
@router.post("/gitea/{provider_id}")
async def gitea_webhook(provider_id: int, request: Request):
@router.post("/gitea/{token}")
async def gitea_webhook(token: str, request: Request):
"""Receive a Gitea webhook, parse it, filter, and dispatch notifications."""
engine = get_engine()
# --- Load provider and validate signature ---
async with AsyncSession(engine) as session:
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.type != "gitea":
raise HTTPException(status_code=404, detail="Provider not found")
provider = await _get_provider_by_token(session, token, "gitea")
webhook_secret = (provider.config or {}).get("webhook_secret", "")
@@ -211,7 +225,7 @@ async def gitea_webhook(provider_id: int, request: Request):
# --- Dispatch ---
dispatched = await _dispatch_webhook_event(
engine=engine,
provider_id=provider_id,
provider_id=provider.id,
provider_name=provider.name,
provider_config=provider.config or {},
event=event,
@@ -239,16 +253,14 @@ def _verify_planka_token(expected_token: str, request: Request) -> bool:
return False
@router.post("/planka/{provider_id}")
async def planka_webhook(provider_id: int, request: Request):
@router.post("/planka/{token}")
async def planka_webhook(token: str, request: Request):
"""Receive a Planka webhook, parse it, filter, and dispatch notifications."""
engine = get_engine()
# --- Load provider and validate token ---
async with AsyncSession(engine) as session:
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.type != "planka":
raise HTTPException(status_code=404, detail="Provider not found")
provider = await _get_provider_by_token(session, token, "planka")
webhook_secret = (provider.config or {}).get("webhook_secret", "")
@@ -279,7 +291,7 @@ async def planka_webhook(provider_id: int, request: Request):
# --- Dispatch ---
dispatched = await _dispatch_webhook_event(
engine=engine,
provider_id=provider_id,
provider_id=provider.id,
provider_name=provider.name,
provider_config=provider.config or {},
event=event,
@@ -394,17 +406,16 @@ async def _save_webhook_log(
_LOGGER.warning("Failed to save webhook payload log for provider %d", provider_id, exc_info=True)
@router.post("/webhook/{provider_id}")
async def generic_webhook(provider_id: int, request: Request):
@router.post("/webhook/{token}")
async def generic_webhook(token: str, request: Request):
"""Receive a generic webhook, extract variables via JSONPath, and dispatch notifications."""
engine = get_engine()
# --- Load provider and validate auth ---
async with AsyncSession(engine) as session:
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.type != "webhook":
raise HTTPException(status_code=404, detail="Provider not found")
provider = await _get_provider_by_token(session, token, "webhook")
provider_id = provider.id
provider_config = provider.config or {}
provider_name = provider.name