Phase 4: Webhooks, Backup/Restore UI, Onboarding/Tutorial

- Webhooks Backend: model, schema, service (HMAC-SHA256, httpx), routes, event bus dispatcher, migration 0042
- Webhooks Frontend: SettingsWebhooksPage (CRUD, test button, event multi-select), API client
- Backup/Restore Backend: model, schema, service (pg_dump/pg_restore), routes (admin-only), migration 0043
- Backup/Restore Frontend: SettingsBackupPage (create, list, restore dialog with RESTORE confirmation, auto-refresh)
- Onboarding: OnboardingTour (8 steps, custom CSS overlay), WelcomeDialog, onboardingStore (zustand + localStorage)
- Onboarding integrated into AppShell
- Routes: /settings/webhooks, /settings/backup registered
- Settings nav: Webhooks, Backup & Restore entries added
- Migration conflict fixed: 0042_webhooks → 0043_backups chain
This commit is contained in:
Agent Zero
2026-07-26 03:17:40 +02:00
parent 10dcc8ae90
commit 79ece0fe2e
23 changed files with 3094 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
"""API routes for Backup management."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.schemas.backup import BackupListResponse, BackupResponse
from app.services import backup_service
router = APIRouter(prefix="/api/v1/backups", tags=["backups"])
@router.get(
"",
response_model=BackupListResponse,
dependencies=[Depends(require_permission("automation:admin"))],
)
async def list_backups(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all backups for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
backups = await backup_service.list_backups(db, tenant_id)
return BackupListResponse(
backups=[BackupResponse.model_validate(b) for b in backups],
total=len(backups),
)
@router.post(
"",
response_model=BackupResponse,
status_code=201,
dependencies=[Depends(require_permission("automation:admin"))],
)
async def create_backup(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new database backup."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
backup = await backup_service.create_backup(db, tenant_id, user_id=user_id)
return backup
@router.post(
"/{backup_id}/restore",
response_model=BackupResponse,
dependencies=[Depends(require_permission("automation:admin"))],
)
async def restore_backup(
backup_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Restore a database backup.
WARNING: This is a destructive operation. It drops and recreates the database.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
b_id = uuid.UUID(backup_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid backup_id", "code": "invalid_id"}) from None
try:
backup = await backup_service.restore_backup(db, tenant_id, b_id)
return backup
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_state"}) from exc
except FileNotFoundError as exc:
raise HTTPException(404, detail={"detail": str(exc), "code": "file_not_found"}) from exc
except RuntimeError as exc:
raise HTTPException(500, detail={"detail": str(exc), "code": "restore_failed"}) from exc
@router.delete(
"/{backup_id}",
status_code=204,
dependencies=[Depends(require_permission("automation:admin"))],
)
async def delete_backup(
backup_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a backup record and its file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
b_id = uuid.UUID(backup_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid backup_id", "code": "invalid_id"}) from None
deleted = await backup_service.delete_backup(db, tenant_id, b_id)
if not deleted:
raise HTTPException(404, detail={"detail": "Backup not found", "code": "not_found"})
return None
+161
View File
@@ -0,0 +1,161 @@
"""API routes for Webhook CRUD and testing."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.schemas.webhook import (
WebhookCreate,
WebhookResponse,
WebhookUpdate,
)
from app.services import webhook_service
router = APIRouter(prefix="/api/v1/webhooks", tags=["webhooks"])
@router.get(
"",
response_model=list[WebhookResponse],
dependencies=[Depends(require_permission("automation:read"))],
)
async def list_webhooks(
event: str | None = Query(None, description="Filter by event name"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all webhooks for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
webhooks = await webhook_service.list_webhooks(db, tenant_id, event=event)
return webhooks
@router.post(
"",
response_model=WebhookResponse,
status_code=201,
dependencies=[Depends(require_permission("automation:write"))],
)
async def create_webhook(
body: WebhookCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new webhook subscription."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
webhook = await webhook_service.create_webhook(
db, tenant_id, user_id, body.model_dump()
)
return webhook
@router.get(
"/{webhook_id}",
response_model=WebhookResponse,
dependencies=[Depends(require_permission("automation:read"))],
)
async def get_webhook(
webhook_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a single webhook by ID."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
wh_id = uuid.UUID(webhook_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None
webhook = await webhook_service.get_webhook(db, tenant_id, wh_id)
if webhook is None:
raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"})
return webhook
@router.patch(
"/{webhook_id}",
response_model=WebhookResponse,
dependencies=[Depends(require_permission("automation:write"))],
)
async def update_webhook(
webhook_id: str,
body: WebhookUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update an existing webhook subscription."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
wh_id = uuid.UUID(webhook_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None
update_data = {k: v for k, v in body.model_dump().items() if v is not None}
if not update_data:
raise HTTPException(400, detail={"detail": "No fields to update", "code": "no_updates"})
webhook = await webhook_service.update_webhook(
db, tenant_id, wh_id, update_data, user_id=user_id
)
if webhook is None:
raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"})
return webhook
@router.delete(
"/{webhook_id}",
status_code=204,
dependencies=[Depends(require_permission("automation:write"))],
)
async def delete_webhook(
webhook_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a webhook subscription."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
wh_id = uuid.UUID(webhook_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None
deleted = await webhook_service.delete_webhook(db, tenant_id, wh_id)
if not deleted:
raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"})
return None
@router.post(
"/{webhook_id}/test",
dependencies=[Depends(require_permission("automation:write"))],
)
async def test_webhook(
webhook_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Send a test payload to a webhook to verify connectivity."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
wh_id = uuid.UUID(webhook_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None
webhook = await webhook_service.get_webhook(db, tenant_id, wh_id)
if webhook is None:
raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"})
test_payload = {
"type": "test",
"message": "This is a test webhook from LeoCRM",
"webhook_id": str(webhook.id),
}
result = await webhook_service.send_webhook(webhook, "webhook.test", test_payload)
return result