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
+69
View File
@@ -0,0 +1,69 @@
"""Create webhooks table for outgoing webhook subscriptions.
Revision ID: 0042_webhooks
Revises: 0041_custom_field_definitions
Create Date: 2026-07-26
Stores outgoing webhook subscriptions with target URL, event subscriptions,
and delivery settings (retry count, timeout, HMAC secret).
"""
from __future__ import annotations
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers
revision: str = "0042_webhooks"
down_revision: Union[str, None] = "0041_custom_field_definitions"
branch_labels: Union[str, None] = None
depends_on: Union[str, None] = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
CREATE TABLE IF NOT EXISTS webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
url VARCHAR(500) NOT NULL,
events JSONB NOT NULL DEFAULT '[]',
secret VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
retry_count INTEGER NOT NULL DEFAULT 3,
timeout_seconds INTEGER NOT NULL DEFAULT 30,
created_by UUID,
updated_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
)
"""
)
)
# Indexes
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_webhooks_tenant "
"ON webhooks (tenant_id)"
)
)
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_webhooks_tenant_active "
"ON webhooks (tenant_id, is_active)"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP INDEX IF EXISTS ix_webhooks_tenant_active"))
conn.execute(sa.text("DROP INDEX IF EXISTS ix_webhooks_tenant"))
conn.execute(sa.text("DROP TABLE IF EXISTS webhooks"))
+64
View File
@@ -0,0 +1,64 @@
"""Create backups table for database backup tracking.
Revision ID: 0042_backups
Revises: 0041_custom_field_definitions
Create Date: 2026-07-26
Stores database backup records per tenant with status tracking.
"""
from __future__ import annotations
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers
revision: str = "0043_backups"
down_revision: Union[str, None] = "0042_webhooks"
branch_labels: Union[str, None] = None
depends_on: Union[str, None] = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
CREATE TABLE IF NOT EXISTS backups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
filename VARCHAR(255) NOT NULL,
size_bytes BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
error_message TEXT,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
)
"""
)
)
# Indexes
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_backups_tenant "
"ON backups (tenant_id)"
)
)
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_backups_tenant_status "
"ON backups (tenant_id, status)"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP INDEX IF EXISTS ix_backups_tenant_status"))
conn.execute(sa.text("DROP INDEX IF EXISTS ix_backups_tenant"))
conn.execute(sa.text("DROP TABLE IF EXISTS backups"))
+98
View File
@@ -0,0 +1,98 @@
"""Webhook dispatcher — subscribes to the event bus and dispatches to matching webhooks."""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.core.db import get_engine, get_session_factory
from app.core.event_bus import EventBus, get_event_bus
from app.models.webhook import Webhook
from app.services.webhook_service import send_webhook
logger = logging.getLogger(__name__)
async def _dispatch_event(payload: dict[str, Any]) -> None:
"""Handle an event from the event bus: find matching webhooks and dispatch.
The payload is expected to contain:
- event_name: str
- tenant_id: str (UUID)
- data: dict (the actual event data)
"""
event_name = payload.get("event_name", "")
tenant_id_str = payload.get("tenant_id", "")
data = payload.get("data", {})
if not event_name or not tenant_id_str:
logger.warning("webhook_dispatcher: missing event_name or tenant_id in payload")
return
try:
tenant_id = uuid.UUID(tenant_id_str)
except (ValueError, TypeError):
logger.warning(f"webhook_dispatcher: invalid tenant_id: {tenant_id_str}")
return
# Find active webhooks for this tenant that subscribe to this event
session_factory = get_session_factory()
async with session_factory() as db:
stmt = select(Webhook).where(
Webhook.tenant_id == tenant_id,
Webhook.is_active == True, # noqa: E712
Webhook.events.any(event_name),
)
result = await db.execute(stmt)
webhooks = list(result.scalars().all())
if not webhooks:
return
# Dispatch to all matching webhooks concurrently
tasks = []
for webhook in webhooks:
tasks.append(_dispatch_single(webhook, event_name, data))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def _dispatch_single(
webhook: Webhook,
event_name: str,
data: dict[str, Any],
) -> None:
"""Send a webhook and log the result."""
try:
result = await send_webhook(webhook, event_name, data)
if result["success"]:
logger.info(
f"Webhook {webhook.id} sent successfully to {webhook.url} "
f"for event {event_name} (status={result['status_code']})"
)
else:
logger.warning(
f"Webhook {webhook.id} failed for {webhook.url} "
f"event {event_name}: {result.get('error')}"
)
except Exception as exc:
logger.error(
f"Webhook {webhook.id} dispatch error for {webhook.url}: {exc}"
)
def register_webhook_event_handlers(event_bus: EventBus | None = None) -> None:
"""Register webhook dispatcher on the global event bus.
Subscribes to all events via the wildcard '*' handler and filters
by event name internally. Should be called during application startup.
"""
bus = event_bus or get_event_bus()
bus.subscribe("*", _dispatch_event)
logger.info("Webhook dispatcher registered on event bus (wildcard '*' handler)")
+4
View File
@@ -52,6 +52,8 @@ from app.routes import (
custom_field_definitions,
custom_fields,
saved_filters,
webhooks,
backups,
)
@@ -331,9 +333,11 @@ def create_app() -> FastAPI:
app.include_router(addresses.router)
app.include_router(bank_accounts.router)
app.include_router(audit.router)
app.include_router(backups.router)
app.include_router(custom_field_definitions.router)
app.include_router(custom_fields.router)
app.include_router(saved_filters.router)
app.include_router(webhooks.router)
# ── Register plugin routes for all built-in plugins ──
# Routes are registered here (before app start); activation status
+4
View File
@@ -21,7 +21,9 @@ from app.models.system_settings import SystemSettings
from app.models.tax import TaxRate
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.backup import Backup
from app.models.custom_field_definition import CustomFieldDefinition
from app.models.webhook import Webhook
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
__all__ = [
@@ -55,7 +57,9 @@ __all__ = [
"PluginMigration",
"AIConversation",
"AIMessage",
"Backup",
"CustomFieldDefinition",
"Webhook",
"Workflow",
"WorkflowInstance",
"WorkflowStepHistory",
+46
View File
@@ -0,0 +1,46 @@
"""Backup model — tracks database backup records per tenant."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, String, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class Backup(Base, TenantMixin):
"""Database backup record scoped to a tenant.
Tracks pg_dump backups with status, file location, and error details.
"""
__tablename__ = "backups"
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Identity ──
filename: Mapped[str] = mapped_column(String(255), nullable=False)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True, default=None)
# ── Status ──
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending"
) # pending, completed, failed
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
# ── Audit ──
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.utcnow
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
+43
View File
@@ -0,0 +1,43 @@
"""Webhook model — outgoing webhook subscriptions for event-driven notifications."""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, Integer, JSON, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class Webhook(Base, TenantMixin):
"""Outgoing webhook subscription.
Each webhook defines a target URL, a list of events to subscribe to,
and delivery settings (retry count, timeout, HMAC secret).
"""
__tablename__ = "webhooks"
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Target ──
url: Mapped[str] = mapped_column(String(500), nullable=False)
events: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
secret: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
# ── Delivery Settings ──
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
# ── Audit ──
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
+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
+31
View File
@@ -0,0 +1,31 @@
"""Pydantic schemas for Backup CRUD."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class BackupResponse(BaseModel):
"""Schema for returning a backup record."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tenant_id: uuid.UUID
filename: str
size_bytes: int | None = None
status: str # pending, completed, failed
error_message: str | None = None
created_by: uuid.UUID | None = None
created_at: datetime
completed_at: datetime | None = None
class BackupListResponse(BaseModel):
"""Schema for listing backups."""
backups: list[BackupResponse]
total: int
+49
View File
@@ -0,0 +1,49 @@
"""Pydantic schemas for Webhook CRUD."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class WebhookCreate(BaseModel):
"""Schema for creating a new webhook."""
url: str = Field(..., min_length=1, max_length=500, description="Target URL for the webhook")
events: list[str] = Field(..., min_length=1, description="List of event names to subscribe to")
secret: str | None = Field(default=None, max_length=255, description="HMAC secret for payload signing")
is_active: bool = Field(default=True, description="Whether the webhook is active")
retry_count: int = Field(default=3, ge=0, le=10, description="Number of retry attempts on failure")
timeout_seconds: int = Field(default=30, ge=1, le=120, description="Request timeout in seconds")
class WebhookUpdate(BaseModel):
"""Schema for updating an existing webhook."""
url: str | None = Field(default=None, max_length=500)
events: list[str] | None = Field(default=None, min_length=1)
secret: str | None = Field(default=None, max_length=255)
is_active: bool | None = Field(default=None)
retry_count: int | None = Field(default=None, ge=0, le=10)
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
class WebhookResponse(BaseModel):
"""Schema for returning a webhook."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tenant_id: uuid.UUID
url: str
events: list[str]
secret: str | None = None
is_active: bool = True
retry_count: int = 3
timeout_seconds: int = 30
created_by: uuid.UUID | None = None
updated_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
+268
View File
@@ -0,0 +1,268 @@
"""Service for database backup and restore operations."""
from __future__ import annotations
import asyncio
import logging
import os
import uuid
from datetime import datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.backup import Backup
logger = logging.getLogger(__name__)
BACKUP_DIR = Path("/tmp/leocrm-backups")
def _ensure_backup_dir() -> None:
"""Ensure the backup directory exists."""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
def _get_database_url() -> str:
"""Get DATABASE_URL from environment."""
url = os.environ.get("DATABASE_URL")
if not url:
raise RuntimeError("DATABASE_URL environment variable is not set")
return url
def _parse_pg_url(url: str) -> dict[str, str]:
"""Parse a PostgreSQL connection URL into components for pg_dump/pg_restore.
Handles formats:
postgresql://user:pass@host:port/dbname
postgresql+asyncpg://user:pass@host:port/dbname
"""
# Remove async driver prefix if present
if "+asyncpg" in url:
url = url.replace("+asyncpg", "")
if "+psycopg2" in url:
url = url.replace("+psycopg2", "")
# Parse the URL manually to avoid dependency on urllib parsing quirks
# Format: postgresql://user:pass@host:port/dbname
rest = url.split("://", 1)[1] if "://" in url else url
user_info, rest = rest.split("@", 1) if "@" in rest else ("", rest)
user = ""
password = ""
if ":" in user_info:
user, password = user_info.split(":", 1)
else:
user = user_info
host_port, dbname = rest.split("/", 1) if "/" in rest else (rest, "")
host = host_port
port = "5432"
if ":" in host_port:
host, port = host_port.split(":", 1)
return {
"host": host,
"port": port,
"user": user,
"password": password,
"dbname": dbname,
}
async def list_backups(
db: AsyncSession,
tenant_id: uuid.UUID,
) -> list[Backup]:
"""List all backups for a tenant, ordered by creation date descending."""
stmt = (
select(Backup)
.where(Backup.tenant_id == tenant_id)
.order_by(Backup.created_at.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
async def create_backup(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID | None = None,
) -> Backup:
"""Create a database backup using pg_dump.
Creates a backup record, runs pg_dump to a file, then updates the record
with the file size and status.
"""
_ensure_backup_dir()
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
filename = f"leocrm_backup_{tenant_id}_{timestamp}.dump"
filepath = BACKUP_DIR / filename
# Create initial pending record
backup = Backup(
tenant_id=tenant_id,
filename=filename,
status="pending",
created_by=user_id,
)
db.add(backup)
await db.flush()
await db.refresh(backup)
try:
database_url = _get_database_url()
pg = _parse_pg_url(database_url)
# Build pg_dump command
env = os.environ.copy()
if pg["password"]:
env["PGPASSWORD"] = pg["password"]
cmd = [
"pg_dump",
"--host", pg["host"],
"--port", pg["port"],
"--username", pg["user"],
"--format", "custom",
"--file", str(filepath),
pg["dbname"],
]
logger.info("Running pg_dump: %s to %s", cmd, filepath)
process = await asyncio.create_subprocess_exec(
*cmd,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "pg_dump failed with unknown error"
logger.error("pg_dump failed: %s", error_msg)
backup.status = "failed"
backup.error_message = error_msg
await db.flush()
await db.refresh(backup)
return backup
# Get file size
size_bytes = filepath.stat().st_size if filepath.exists() else 0
# Update backup record
backup.status = "completed"
backup.size_bytes = size_bytes
backup.completed_at = datetime.utcnow()
await db.flush()
await db.refresh(backup)
logger.info("Backup completed: %s (%d bytes)", filename, size_bytes)
return backup
except Exception as exc:
logger.exception("Backup creation failed")
backup.status = "failed"
backup.error_message = str(exc)
await db.flush()
await db.refresh(backup)
return backup
async def restore_backup(
db: AsyncSession,
tenant_id: uuid.UUID,
backup_id: uuid.UUID,
) -> Backup:
"""Restore a database backup using pg_restore.
WARNING: This is a destructive operation. It drops and recreates the database.
"""
stmt = select(Backup).where(
Backup.id == backup_id,
Backup.tenant_id == tenant_id,
)
result = await db.execute(stmt)
backup = result.scalar_one_or_none()
if backup is None:
raise ValueError("Backup not found")
if backup.status != "completed":
raise ValueError(f"Backup status is '{backup.status}', cannot restore")
filepath = BACKUP_DIR / backup.filename
if not filepath.exists():
raise FileNotFoundError(f"Backup file not found: {filepath}")
try:
database_url = _get_database_url()
pg = _parse_pg_url(database_url)
env = os.environ.copy()
if pg["password"]:
env["PGPASSWORD"] = pg["password"]
cmd = [
"pg_restore",
"--host", pg["host"],
"--port", pg["port"],
"--username", pg["user"],
"--dbname", pg["dbname"],
"--clean",
"--if-exists",
"--no-owner",
"--no-acl",
str(filepath),
]
logger.info("Running pg_restore: %s", cmd)
process = await asyncio.create_subprocess_exec(
*cmd,
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "pg_restore failed with unknown error"
logger.error("pg_restore failed: %s", error_msg)
raise RuntimeError(f"Restore failed: {error_msg}")
logger.info("Restore completed from backup: %s", backup.filename)
return backup
except Exception as exc:
logger.exception("Restore failed")
raise
async def delete_backup(
db: AsyncSession,
tenant_id: uuid.UUID,
backup_id: uuid.UUID,
) -> bool:
"""Delete a backup record and its file."""
stmt = select(Backup).where(
Backup.id == backup_id,
Backup.tenant_id == tenant_id,
)
result = await db.execute(stmt)
backup = result.scalar_one_or_none()
if backup is None:
return False
# Delete the file if it exists
filepath = BACKUP_DIR / backup.filename
if filepath.exists():
filepath.unlink()
await db.delete(backup)
await db.flush()
return True
+183
View File
@@ -0,0 +1,183 @@
"""CRUD service for Webhook subscriptions and delivery."""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import uuid
from typing import Any
import httpx
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.webhook import Webhook
logger = logging.getLogger(__name__)
async def list_webhooks(
db: AsyncSession,
tenant_id: uuid.UUID,
event: str | None = None,
) -> list[Webhook]:
"""List webhooks for a tenant, optionally filtered by event."""
stmt = select(Webhook).where(
Webhook.tenant_id == tenant_id,
)
if event:
stmt = stmt.where(Webhook.events.any(event))
stmt = stmt.order_by(Webhook.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
) -> Webhook | None:
"""Get a single webhook by ID."""
stmt = select(Webhook).where(
Webhook.id == webhook_id,
Webhook.tenant_id == tenant_id,
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def create_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> Webhook:
"""Create a new webhook subscription."""
webhook = Webhook(
tenant_id=tenant_id,
url=data["url"],
events=data["events"],
secret=data.get("secret"),
is_active=data.get("is_active", True),
retry_count=data.get("retry_count", 3),
timeout_seconds=data.get("timeout_seconds", 30),
created_by=user_id,
updated_by=user_id,
)
db.add(webhook)
await db.flush()
await db.refresh(webhook)
return webhook
async def update_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
data: dict[str, Any],
user_id: uuid.UUID | None = None,
) -> Webhook | None:
"""Update an existing webhook subscription."""
webhook = await get_webhook(db, tenant_id, webhook_id)
if webhook is None:
return None
update_fields = ["url", "events", "secret", "is_active", "retry_count", "timeout_seconds"]
for field in update_fields:
if field in data:
setattr(webhook, field, data[field])
if user_id is not None:
webhook.updated_by = user_id
await db.flush()
await db.refresh(webhook)
return webhook
async def delete_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
) -> bool:
"""Delete a webhook subscription."""
stmt = select(Webhook).where(
Webhook.id == webhook_id,
Webhook.tenant_id == tenant_id,
)
result = await db.execute(stmt)
webhook = result.scalar_one_or_none()
if webhook is None:
return False
await db.delete(webhook)
await db.flush()
return True
def _sign_payload(payload: bytes, secret: str) -> str:
"""Create HMAC-SHA256 signature for a payload."""
return hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
async def send_webhook(
webhook: Webhook,
event_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Send a webhook HTTP POST with HMAC signature.
Returns a dict with 'success' (bool), 'status_code' (int | None),
and 'error' (str | None).
"""
body = {
"event": event_name,
"payload": payload,
"timestamp": __import__("datetime").datetime.utcnow().isoformat() + "Z",
}
body_bytes = json.dumps(body, default=str).encode("utf-8")
headers = {
"Content-Type": "application/json",
"User-Agent": "LeoCRM-Webhook/1.0",
}
if webhook.secret:
signature = _sign_payload(body_bytes, webhook.secret)
headers["X-Webhook-Signature"] = f"sha256={signature}"
try:
async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client:
response = await client.post(
webhook.url,
content=body_bytes,
headers=headers,
)
return {
"success": response.is_success,
"status_code": response.status_code,
"error": None,
}
except httpx.TimeoutException:
return {"success": False, "status_code": None, "error": "timeout"}
except httpx.RequestError as exc:
return {"success": False, "status_code": None, "error": str(exc)}
async def retry_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
event_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Retry sending a webhook with the given event and payload."""
webhook = await get_webhook(db, tenant_id, webhook_id)
if webhook is None:
return {"success": False, "status_code": None, "error": "webhook_not_found"}
return await send_webhook(webhook, event_name, payload)
+96
View File
@@ -0,0 +1,96 @@
/**
* Backup API client — database backup management.
*
* Features:
* - List backups
* - Create backup
* - Restore backup
* - Delete backup
*/
import { apiGet, apiPost, apiDelete } from './client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface Backup {
id: string;
tenant_id: string;
filename: string;
size_bytes: number | null;
status: 'pending' | 'completed' | 'failed';
error_message: string | null;
created_by: string | null;
created_at: string;
completed_at: string | null;
}
export interface BackupListResponse {
backups: Backup[];
total: number;
}
// ─── API Functions ──────────────────────────────────────────────────────────
export async function fetchBackups(): Promise<BackupListResponse> {
return apiGet<BackupListResponse>('/backups');
}
export async function createBackup(): Promise<Backup> {
return apiPost<Backup>('/backups');
}
export async function restoreBackup(backupId: string): Promise<Backup> {
return apiPost<Backup>(`/backups/${backupId}/restore`);
}
export async function deleteBackup(backupId: string): Promise<void> {
return apiDelete<void>(`/backups/${backupId}`);
}
// ─── React Query Hooks ─────────────────────────────────────────────────────
export function useBackups() {
return useQuery<BackupListResponse>({
queryKey: ['backups'],
queryFn: fetchBackups,
refetchInterval: (query) => {
// Auto-refresh while any backup is pending
const data = query.state.data;
if (data && data.backups.some((b) => b.status === 'pending')) {
return 3000; // Poll every 3 seconds
}
return false;
},
});
}
export function useCreateBackup() {
const queryClient = useQueryClient();
return useMutation<Backup, Error>({
mutationFn: createBackup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
},
});
}
export function useRestoreBackup() {
const queryClient = useQueryClient();
return useMutation<Backup, Error, string>({
mutationFn: restoreBackup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
},
});
}
export function useDeleteBackup() {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: deleteBackup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
},
});
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Webhooks API client and React Query hooks.
*/
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export interface Webhook {
id: string;
tenant_id: string;
url: string;
events: string[];
secret: string | null;
is_active: boolean;
retry_count: number;
timeout_seconds: number;
created_by: string | null;
updated_by: string | null;
created_at: string;
updated_at: string;
}
export interface CreateWebhookPayload {
url: string;
events: string[];
secret?: string | null;
is_active?: boolean;
retry_count?: number;
timeout_seconds?: number;
}
export interface UpdateWebhookPayload {
url?: string;
events?: string[];
secret?: string | null;
is_active?: boolean;
retry_count?: number;
timeout_seconds?: number;
}
export interface WebhookTestResult {
success: boolean;
status_code: number | null;
error: string | null;
}
// ─── API Functions ──────────────────────────────────────────────────────────
export async function fetchWebhooks(event?: string): Promise<Webhook[]> {
const params = event ? `?event=${encodeURIComponent(event)}` : '';
return apiGet<Webhook[]>(`/webhooks${params}`);
}
export async function fetchWebhook(id: string): Promise<Webhook> {
return apiGet<Webhook>(`/webhooks/${id}`);
}
export async function createWebhook(data: CreateWebhookPayload): Promise<Webhook> {
return apiPost<Webhook>('/webhooks', data);
}
export async function updateWebhook(id: string, data: UpdateWebhookPayload): Promise<Webhook> {
return apiPatch<Webhook>(`/webhooks/${id}`, data);
}
export async function deleteWebhook(id: string): Promise<void> {
return apiDelete<void>(`/webhooks/${id}`);
}
export async function testWebhook(id: string): Promise<WebhookTestResult> {
return apiPost<WebhookTestResult>(`/webhooks/${id}/test`);
}
// ─── React Query Hooks ───────────────────────────────────────────────────────
export function useWebhooks(event?: string) {
return useQuery({
queryKey: ['webhooks', event],
queryFn: () => fetchWebhooks(event),
});
}
export function useWebhook(id: string) {
return useQuery({
queryKey: ['webhooks', id],
queryFn: () => fetchWebhook(id),
enabled: !!id,
});
}
export function useCreateWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateWebhookPayload) => createWebhook(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['webhooks'] });
},
});
}
export function useUpdateWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateWebhookPayload }) =>
updateWebhook(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['webhooks'] });
},
});
}
export function useDeleteWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteWebhook(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['webhooks'] });
},
});
}
export function useTestWebhook() {
return useMutation({
mutationFn: (id: string) => testWebhook(id),
});
}
@@ -11,6 +11,8 @@ import { PluginRegistry } from '@/components/plugins/PluginRegistry';
import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator';
import { WindowContainer } from '@/components/window/WindowContainer';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { WelcomeDialog } from '@/components/onboarding/WelcomeDialog';
import { OnboardingTour } from '@/components/onboarding/OnboardingTour';
export function AppShell() {
const location = useLocation();
@@ -50,6 +52,8 @@ export function AppShell() {
<AIUIControlIndicator />
<WindowContainer />
<ToastContainer />
<WelcomeDialog open={false} />
<OnboardingTour />
</div>
);
}
@@ -0,0 +1,372 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useOnboardingStore } from '@/store/onboardingStore';
import { useUpsertUserPreference } from '@/api/userPreferences';
import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react';
/**
* OnboardingTour — Custom guided tour with CSS overlay and positioned tooltips.
* No external dependency (react-joyride types unavailable for TS strict mode).
*
* 8 steps targeting sidebar items, search, contacts, calendar, AI assistant, settings.
* Each step highlights a target element via a cut-out overlay and shows a tooltip.
*/
interface TourStep {
target: string;
titleKey: string;
titleFallback: string;
descKey: string;
descFallback: string;
placement?: 'bottom' | 'top' | 'left' | 'right' | 'center';
}
const TOUR_STEPS: TourStep[] = [
{
target: '[data-testid="sidebar"]',
titleKey: 'onboarding.step1Title',
titleFallback: 'Navigation',
descKey: 'onboarding.step1Desc',
descFallback: 'In der Seitenleiste finden Sie alle Hauptbereiche von LeoCRM. Klicken Sie auf einen Eintrag, um dorthin zu navigieren.',
placement: 'right',
},
{
target: 'a[href="/dashboard"]',
titleKey: 'onboarding.step2Title',
titleFallback: 'Dashboard',
descKey: 'onboarding.step2Desc',
descFallback: 'Das Dashboard gibt Ihnen einen Überblick über wichtige Kennzahlen und aktuelle Aktivitäten.',
placement: 'right',
},
{
target: 'a[href="/contacts"]',
titleKey: 'onboarding.step3Title',
titleFallback: 'Kontakte',
descKey: 'onboarding.step3Desc',
descFallback: 'Verwalten Sie hier alle Firmen und Personen. Legen Sie neue Kontakte an oder bearbeiten Sie bestehende.',
placement: 'right',
},
{
target: '[data-testid="search-dropdown"]',
titleKey: 'onboarding.step4Title',
titleFallback: 'Globale Suche',
descKey: 'onboarding.step4Desc',
descFallback: 'Mit der globalen Suche finden Sie schnell Kontakte, Termine und andere Einträge in LeoCRM.',
placement: 'bottom',
},
{
target: 'a[href="/calendar"]',
titleKey: 'onboarding.step5Title',
titleFallback: 'Kalender',
descKey: 'onboarding.step5Desc',
descFallback: 'Im Kalender verwalten Sie Termine, Veranstaltungen und Wochenpläne.',
placement: 'right',
},
{
target: 'a[href="/ai-assistant"]',
titleKey: 'onboarding.step6Title',
titleFallback: 'KI-Assistent',
descKey: 'onboarding.step6Desc',
descFallback: 'Der KI-Assistent hilft Ihnen bei Automatisierung, Texterstellung und intelligente Vorschläge.',
placement: 'right',
},
{
target: '[data-testid="topbar"]',
titleKey: 'onboarding.step7Title',
titleFallback: 'Top-Bar',
descKey: 'onboarding.step7Desc',
descFallback: 'Hier erreichen Sie Benachrichtigungen, Ihr Benutzerprofil und weitere Einstellungen.',
placement: 'bottom',
},
{
target: 'a[href="/settings"]',
titleKey: 'onboarding.step8Title',
titleFallback: 'Einstellungen',
descKey: 'onboarding.step8Desc',
descFallback: 'Passen Sie LeoCRM an Ihre Bedürfnisse an: Profil, Sprache, Design und mehr.',
placement: 'right',
},
];
interface Rect {
top: number;
left: number;
width: number;
height: number;
}
const HIGHLIGHT_PADDING = 8;
export function OnboardingTour() {
const { t } = useTranslation();
const { isActive, step, next, prev, skip, complete } = useOnboardingStore();
const upsertPreference = useUpsertUserPreference();
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
const [targetMissing, setTargetMissing] = useState(false);
const currentStep = TOUR_STEPS[step];
const totalSteps = TOUR_STEPS.length;
const isLastStep = step >= totalSteps - 1;
const updatePosition = useCallback(() => {
if (!isActive || !currentStep) return;
const el = document.querySelector(currentStep.target) as HTMLElement | null;
if (!el) {
setTargetMissing(true);
setTargetRect(null);
setTooltipPos({ top: window.innerHeight / 2 - 120, left: window.innerWidth / 2 - 200 });
return;
}
setTargetMissing(false);
const rect = el.getBoundingClientRect();
const padded: Rect = {
top: rect.top - HIGHLIGHT_PADDING,
left: rect.left - HIGHLIGHT_PADDING,
width: rect.width + HIGHLIGHT_PADDING * 2,
height: rect.height + HIGHLIGHT_PADDING * 2,
};
setTargetRect(padded);
// Calculate tooltip position based on placement
const tooltipWidth = 360;
const tooltipHeight = 220;
const margin = 16;
let top: number;
let left: number;
const placement = currentStep.placement || 'bottom';
switch (placement) {
case 'right':
top = rect.top + rect.height / 2 - tooltipHeight / 2;
left = rect.right + margin;
break;
case 'left':
top = rect.top + rect.height / 2 - tooltipHeight / 2;
left = rect.left - tooltipWidth - margin;
break;
case 'top':
top = rect.top - tooltipHeight - margin;
left = rect.left + rect.width / 2 - tooltipWidth / 2;
break;
case 'bottom':
top = rect.bottom + margin;
left = rect.left + rect.width / 2 - tooltipWidth / 2;
break;
default:
top = window.innerHeight / 2 - tooltipHeight / 2;
left = window.innerWidth / 2 - tooltipWidth / 2;
}
// Clamp to viewport
top = Math.max(margin, Math.min(top, window.innerHeight - tooltipHeight - margin));
left = Math.max(margin, Math.min(left, window.innerWidth - tooltipWidth - margin));
setTooltipPos({ top, left });
}, [isActive, currentStep, step]);
useEffect(() => {
if (!isActive) return;
// Small delay to allow DOM to settle after potential route changes
const timer = setTimeout(updatePosition, 50);
return () => clearTimeout(timer);
}, [isActive, step, updatePosition]);
useEffect(() => {
if (!isActive) return;
const handler = () => updatePosition();
window.addEventListener('resize', handler);
window.addEventListener('scroll', handler, true);
return () => {
window.removeEventListener('resize', handler);
window.removeEventListener('scroll', handler, true);
};
}, [isActive, updatePosition]);
// Keyboard navigation
useEffect(() => {
if (!isActive) return;
const handler = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowRight':
case 'Enter':
e.preventDefault();
handleNext();
break;
case 'ArrowLeft':
e.preventDefault();
if (step > 0) prev();
break;
case 'Escape':
e.preventDefault();
handleSkip();
break;
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, step]);
const handleNext = useCallback(() => {
if (isLastStep) {
complete();
upsertPreference.mutate({ key: 'onboarding_completed', value: true });
} else {
next();
}
}, [isLastStep, complete, next, upsertPreference]);
const handleSkip = useCallback(() => {
skip();
upsertPreference.mutate({ key: 'onboarding_completed', value: true });
}, [skip, upsertPreference]);
if (!isActive || !currentStep) return null;
const title = t(currentStep.titleKey, currentStep.titleFallback);
const description = t(currentStep.descKey, currentStep.descFallback);
return (
<>
{/* Dark overlay with cut-out for highlighted element */}
<div
className="fixed inset-0 z-[55] pointer-events-auto"
aria-hidden="true"
data-testid="onboarding-overlay"
onClick={handleSkip}
style={{
backgroundColor: 'rgba(15, 23, 42, 0.65)',
// Use box-shadow trick to create a cut-out: huge shadow from the highlight rect
...(targetRect && !targetMissing
? {
boxShadow: `0 0 0 9999px rgba(15, 23, 42, 0.65)`,
borderRadius: '8px',
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
inset: 'auto',
backgroundColor: 'transparent',
transition: 'all 0.3s ease',
}
: {}),
}}
/>
{/* Highlight border around target */}
{targetRect && !targetMissing && (
<div
className="fixed z-[56] pointer-events-none rounded-lg ring-2 ring-primary-500 ring-offset-2 ring-offset-transparent"
aria-hidden="true"
style={{
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
transition: 'all 0.3s ease',
}}
/>
)}
{/* Tooltip card */}
{tooltipPos && (
<div
className="fixed z-[57] w-[360px] bg-white rounded-xl shadow-2xl border border-secondary-200 overflow-hidden"
role="dialog"
aria-modal="false"
aria-labelledby="tour-step-title"
data-testid="onboarding-tooltip"
style={{
top: tooltipPos.top,
left: tooltipPos.left,
transition: 'all 0.3s ease',
}}
>
{/* Header with step indicator + close */}
<div className="flex items-center justify-between px-5 py-3 bg-secondary-50 border-b border-secondary-100">
<span className="text-xs font-semibold text-primary-600 uppercase tracking-wider">
{t('onboarding.stepProgress', 'Schritt {{current}} von {{total}}', {
current: step + 1,
total: totalSteps,
})}
</span>
<button
onClick={handleSkip}
className="p-1 rounded-md hover:bg-secondary-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400"
aria-label={t('onboarding.skip', 'Überspringen')}
>
<X className="w-4 h-4" strokeWidth={2} />
</button>
</div>
{/* Progress bar */}
<div className="h-1 bg-secondary-100">
<div
className="h-full bg-primary-500 transition-all duration-300"
style={{ width: `${((step + 1) / totalSteps) * 100}%` }}
/>
</div>
{/* Body */}
<div className="px-5 py-4">
<h3 id="tour-step-title" className="text-lg font-bold text-secondary-900 mb-2">
{title}
</h3>
<p className="text-sm text-secondary-600 leading-relaxed">
{description}
</p>
{targetMissing && (
<p className="text-xs text-warning-600 mt-2 italic">
{t('onboarding.elementNotFound', 'Dieses Element ist derzeit nicht sichtbar. Klicken Sie auf Weiter, um fortzufahren.')}
</p>
)}
</div>
{/* Footer with navigation buttons */}
<div className="flex items-center justify-between px-5 py-3 bg-secondary-50 border-t border-secondary-100">
<button
onClick={handleSkip}
className="text-sm text-secondary-500 hover:text-secondary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 rounded min-h-touch px-2"
>
{t('onboarding.skipTour', 'Tour überspringen')}
</button>
<div className="flex items-center gap-2">
{step > 0 && (
<button
onClick={prev}
className="flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-secondary-600 hover:bg-secondary-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 min-h-touch"
aria-label={t('onboarding.back', 'Zurück')}
>
<ChevronLeft className="w-4 h-4" strokeWidth={2} />
{t('onboarding.back', 'Zurück')}
</button>
)}
<button
onClick={handleNext}
className="flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-semibold bg-primary-600 text-white hover:bg-primary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch shadow-sm"
aria-label={isLastStep ? t('onboarding.finish', 'Fertig') : t('onboarding.next', 'Weiter')}
>
{isLastStep ? (
<>
<Check className="w-4 h-4" strokeWidth={2} />
{t('onboarding.finish', 'Fertig')}
</>
) : (
<>
{t('onboarding.next', 'Weiter')}
<ChevronRight className="w-4 h-4" strokeWidth={2} />
</>
)}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,137 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOnboardingStore } from '@/store/onboardingStore';
import { Sparkles, X, ChevronRight } from 'lucide-react';
/**
* WelcomeDialog — shown on first login when onboarding has not been
* completed or skipped. Presents a brief intro and offers to start the
* guided tour or skip it.
*
* Visibility is controlled by the parent component (AppShell). This
* component only handles the dialog UI and delegates actions to the
* onboarding store.
*/
export interface WelcomeDialogProps {
/** Controls whether the dialog is visible. */
open: boolean;
/** Called when the dialog should close without starting the tour. */
onClose?: () => void;
}
export function WelcomeDialog({ open, onClose }: WelcomeDialogProps) {
const { t } = useTranslation();
const startTour = useOnboardingStore((s) => s.start);
const skip = useOnboardingStore((s) => s.skip);
useEffect(() => {
if (!open) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleSkip();
}
};
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (!open) return null;
const handleStartTour = () => {
startTour();
onClose?.();
};
const handleSkip = () => {
skip();
onClose?.();
};
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-secondary-900/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="welcome-dialog-title"
data-testid="welcome-dialog"
>
<div className="relative w-full max-w-lg mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
{/* Decorative header banner */}
<div className="bg-gradient-to-br from-primary-600 to-accent-600 px-8 py-8 text-white relative">
<button
onClick={handleSkip}
className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-white/20 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
aria-label={t('onboarding.skip', 'Überspringen')}
>
<X className="w-5 h-5" strokeWidth={2} />
</button>
<div className="flex items-center gap-3 mb-3">
<Sparkles className="w-8 h-8" strokeWidth={2} />
<span className="text-sm font-medium uppercase tracking-wider opacity-90">
{t('onboarding.welcomeBadge', 'Neu hier?')}
</span>
</div>
<h2 id="welcome-dialog-title" className="text-2xl font-bold">
{t('onboarding.welcomeTitle', 'Willkommen bei LeoCRM!')}
</h2>
</div>
{/* Body */}
<div className="px-8 py-6">
<p className="text-secondary-600 text-base leading-relaxed mb-4">
{t(
'onboarding.welcomeIntro',
'LeoCRM ist Ihre zentrale Plattform für Kontaktverwaltung, Kalender, KI-gestützte Automatisierung und mehr. Lernen Sie in einer kurzen Tour die wichtigsten Funktionen kennen.'
)}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6">
<FeatureCard
icon="🗂️"
title={t('onboarding.featureContacts', 'Kontakte')}
desc={t('onboarding.featureContactsDesc', 'Verwalten Sie Firmen und Personen')}
/>
<FeatureCard
icon="📅"
title={t('onboarding.featureCalendar', 'Kalender')}
desc={t('onboarding.featureCalendarDesc', 'Termine und Veranstaltungen im Blick')}
/>
<FeatureCard
icon="🤖"
title={t('onboarding.featureAI', 'KI-Assistent')}
desc={t('onboarding.featureAIDesc', 'Intelligente Automatisierung')}
/>
</div>
<div className="flex flex-col sm:flex-row gap-3 sm:justify-end">
<button
onClick={handleSkip}
className="px-5 py-2.5 rounded-lg text-sm font-medium text-secondary-600 hover:bg-secondary-100 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 min-h-touch"
>
{t('onboarding.skip', 'Überspringen')}
</button>
<button
onClick={handleStartTour}
className="flex items-center justify-center gap-2 px-6 py-2.5 rounded-lg text-sm font-semibold bg-primary-600 text-white hover:bg-primary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch shadow-sm"
>
{t('onboarding.startTour', 'Tour starten')}
<ChevronRight className="w-4 h-4" strokeWidth={2} />
</button>
</div>
</div>
</div>
</div>
);
}
function FeatureCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
return (
<div className="flex flex-col items-center text-center p-3 rounded-lg bg-secondary-50 border border-secondary-100">
<span className="text-2xl mb-1" aria-hidden="true">{icon}</span>
<span className="text-sm font-semibold text-secondary-800">{title}</span>
<span className="text-xs text-secondary-500 mt-0.5">{desc}</span>
</div>
);
}
+2
View File
@@ -22,6 +22,8 @@ export function SettingsPage() {
{ to: '/settings/ai-settings', label: 'KI Einstellungen', icon: '\ud83e\udde0' },
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
{ to: '/settings/custom-fields', label: 'Custom Fields', icon: '\ud83d\udccb' },
{ to: '/settings/webhooks', label: 'Webhooks', icon: '\ud83d\udd14' },
{ to: '/settings/backup', label: 'Backup & Restore', icon: '\ud83d\udcbe' },
];
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
+499
View File
@@ -0,0 +1,499 @@
/**
* SettingsBackup page — Backup & Restore management.
*
* Features:
* - "Backup jetzt erstellen" button (POST)
* - List of backups: date, size, status badge, restore/delete buttons
* - Restore dialog with warning text + type "RESTORE" to confirm
* - Auto-refresh when backup is pending
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { Plus, Download, Trash2, AlertTriangle, HardDrive, CheckCircle, XCircle, Clock } from 'lucide-react';
import { useBackups, useCreateBackup, useRestoreBackup, useDeleteBackup, type Backup } from '@/api/backups';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatFileSize(bytes: number | null): string {
if (bytes === null || bytes === undefined) return '—';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
const d = new Date(dateStr);
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function StatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const config: Record<string, { icon: React.ReactNode; label: string; classes: string }> = {
pending: {
icon: <Clock className="h-3.5 w-3.5" aria-hidden="true" />,
label: t('backup.statusPending', 'Wird erstellt...'),
classes: 'bg-amber-50 text-amber-700 border-amber-200',
},
completed: {
icon: <CheckCircle className="h-3.5 w-3.5" aria-hidden="true" />,
label: t('backup.statusCompleted', 'Abgeschlossen'),
classes: 'bg-green-50 text-green-700 border-green-200',
},
failed: {
icon: <XCircle className="h-3.5 w-3.5" aria-hidden="true" />,
label: t('backup.statusFailed', 'Fehlgeschlagen'),
classes: 'bg-danger-50 text-danger-700 border-danger-200',
},
};
const c = config[status] || config.pending;
return (
<span
className={clsx(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',
c.classes
)}
>
{c.icon}
{c.label}
</span>
);
}
// ─── Restore Confirmation Modal ─────────────────────────────────────────────
interface RestoreModalProps {
open: boolean;
backup: Backup | null;
onConfirm: () => void;
onCancel: () => void;
isRestoring: boolean;
}
function RestoreModal({ open, backup, onConfirm, onCancel, isRestoring }: RestoreModalProps) {
const { t } = useTranslation();
const [confirmText, setConfirmText] = useState('');
const handleConfirm = useCallback(() => {
if (confirmText === 'RESTORE') {
onConfirm();
setConfirmText('');
}
}, [confirmText, onConfirm]);
const handleClose = useCallback(() => {
setConfirmText('');
onCancel();
}, [onCancel]);
return (
<Modal open={open} onClose={handleClose} title={t('backup.restoreTitle', 'Backup wiederherstellen')} size="md">
<div className="space-y-4">
{/* Warning */}
<div className="rounded-md border border-danger-200 bg-danger-50 p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<AlertTriangle className="h-5 w-5 text-danger-600" aria-hidden="true" />
</div>
<div>
<h3 className="text-sm font-semibold text-danger-800">
{t('backup.restoreWarningTitle', 'Achtung: Destruktiver Vorgang')}
</h3>
<p className="mt-1 text-sm text-danger-700">
{t(
'backup.restoreWarningText',
'Die Wiederherstellung überschreibt die aktuelle Datenbank vollständig. ' +
'Alle seit dem Backup vorgenommenen Änderungen gehen verloren. ' +
'Dieser Vorgang kann nicht rückgängig gemacht werden.'
)}
</p>
</div>
</div>
</div>
{/* Backup info */}
{backup && (
<div className="rounded-md bg-secondary-50 border border-secondary-200 p-3 text-sm">
<p className="text-secondary-700">
<span className="font-medium">{t('backup.restoreFile', 'Backup-Datei:')}</span>{' '}
{backup.filename}
</p>
<p className="text-secondary-700 mt-1">
<span className="font-medium">{t('backup.restoreDate', 'Erstellt am:')}</span>{' '}
{formatDate(backup.created_at)}
</p>
<p className="text-secondary-700 mt-1">
<span className="font-medium">{t('backup.restoreSize', 'Größe:')}</span>{' '}
{formatFileSize(backup.size_bytes)}
</p>
</div>
)}
{/* Confirmation input */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('backup.restoreConfirmLabel', 'Geben Sie "RESTORE" ein, um zu bestätigen:')}
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="RESTORE"
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-danger-500 focus:border-danger-500',
'motion-safe:transition-colors text-secondary-900 placeholder-secondary-400'
)}
autoFocus
data-testid="restore-confirm-input"
/>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={handleClose} disabled={isRestoring}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
type="button"
variant="danger"
onClick={handleConfirm}
isLoading={isRestoring}
disabled={confirmText !== 'RESTORE'}
icon={<Download className="h-4 w-4" />}
data-testid="restore-confirm-btn"
>
{t('backup.restoreBtn', 'Wiederherstellen')}
</Button>
</div>
</div>
</Modal>
);
}
// ─── Delete Confirmation Modal ──────────────────────────────────────────────
interface DeleteModalProps {
open: boolean;
backup: Backup | null;
onConfirm: () => void;
onCancel: () => void;
isDeleting: boolean;
}
function DeleteModal({ open, backup, onConfirm, onCancel, isDeleting }: DeleteModalProps) {
const { t } = useTranslation();
return (
<Modal open={open} onClose={onCancel} title={t('backup.deleteTitle', 'Backup löschen')} size="sm">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 rounded-full bg-danger-100 p-2">
<AlertTriangle className="h-5 w-5 text-danger-600" aria-hidden="true" />
</div>
<div>
<p className="text-sm text-secondary-700">
{t('backup.deleteConfirm', 'Möchten Sie das Backup')}{' '}
<span className="font-semibold text-secondary-900">{backup?.filename}</span>{' '}
{t('backup.deleteConfirmEnd', 'wirklich löschen?')}
</p>
<p className="text-sm text-secondary-500 mt-1">
{t('backup.deleteWarning', 'Die Backup-Datei wird dauerhaft entfernt.')}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isDeleting}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
type="button"
variant="danger"
onClick={onConfirm}
isLoading={isDeleting}
icon={<Trash2 className="h-4 w-4" />}
data-testid="backup-delete-confirm"
>
{t('common.delete', 'Löschen')}
</Button>
</div>
</div>
</Modal>
);
}
// ─── SettingsBackup Page ─────────────────────────────────────────────────────
export function SettingsBackupPage() {
const { t } = useTranslation();
// ─── State ────────────────────────────────────────────────────────────────
const [restoringBackup, setRestoringBackup] = useState<Backup | null>(null);
const [showRestoreModal, setShowRestoreModal] = useState(false);
const [deletingBackup, setDeletingBackup] = useState<Backup | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
// ─── Queries ──────────────────────────────────────────────────────────────
const { data, isLoading, isError, error } = useBackups();
const backups = data?.backups ?? [];
// ─── Mutations ────────────────────────────────────────────────────────────
const createMutation = useCreateBackup();
const restoreMutation = useRestoreBackup();
const deleteMutation = useDeleteBackup();
// ─── Handlers ─────────────────────────────────────────────────────────────
const handleCreateBackup = useCallback(() => {
createMutation.mutate();
}, [createMutation]);
const handleRestoreClick = useCallback((backup: Backup) => {
setRestoringBackup(backup);
setShowRestoreModal(true);
}, []);
const handleRestoreConfirm = useCallback(() => {
if (restoringBackup) {
restoreMutation.mutate(restoringBackup.id, {
onSuccess: () => {
setShowRestoreModal(false);
setRestoringBackup(null);
},
});
}
}, [restoringBackup, restoreMutation]);
const handleRestoreCancel = useCallback(() => {
setShowRestoreModal(false);
setRestoringBackup(null);
}, []);
const handleDeleteClick = useCallback((backup: Backup) => {
setDeletingBackup(backup);
setShowDeleteModal(true);
}, []);
const handleDeleteConfirm = useCallback(() => {
if (deletingBackup) {
deleteMutation.mutate(deletingBackup.id, {
onSuccess: () => {
setShowDeleteModal(false);
setDeletingBackup(null);
},
});
}
}, [deletingBackup, deleteMutation]);
const handleDeleteCancel = useCallback(() => {
setShowDeleteModal(false);
setDeletingBackup(null);
}, []);
// ─── Render ───────────────────────────────────────────────────────────────
return (
<div className="space-y-4" data-testid="settings-backup-page">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-secondary-900">
{t('backup.title', 'Backup & Restore')}
</h1>
<p className="text-sm text-secondary-500 mt-0.5">
{t('backup.subtitle', 'Erstellen und verwalten Sie Datenbank-Backups')}
</p>
</div>
<Button
variant="primary"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreateBackup}
isLoading={createMutation.isPending}
disabled={createMutation.isPending}
data-testid="backup-create-btn"
>
{t('backup.createBtn', 'Backup jetzt erstellen')}
</Button>
</div>
{/* Error banner */}
{isError && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-4 py-3 text-sm text-danger-700">
{error?.message || t('backup.loadError', 'Fehler beim Laden der Backups.')}
</div>
)}
{/* Create error */}
{createMutation.isError && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-4 py-3 text-sm text-danger-700">
{createMutation.error?.message || t('backup.createError', 'Fehler beim Erstellen des Backups.')}
</div>
)}
{/* Restore error */}
{restoreMutation.isError && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-4 py-3 text-sm text-danger-700">
{restoreMutation.error?.message || t('backup.restoreError', 'Fehler bei der Wiederherstellung.')}
</div>
)}
{/* Backups list */}
<Card>
{isLoading ? (
<div className="py-12 text-center">
<div className="inline-flex items-center gap-2 text-secondary-500">
<HardDrive className="h-5 w-5 animate-pulse" aria-hidden="true" />
{t('common.loading', 'Laden...')}
</div>
</div>
) : backups.length === 0 ? (
<div className="py-12 text-center">
<div className="mx-auto mb-3 w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center">
<HardDrive className="h-6 w-6 text-secondary-400" aria-hidden="true" />
</div>
<p className="text-sm text-secondary-500 mb-3">
{t('backup.empty', 'Noch keine Backups vorhanden.')}
</p>
<Button
variant="secondary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreateBackup}
isLoading={createMutation.isPending}
>
{t('backup.createFirst', 'Erstes Backup erstellen')}
</Button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full" data-testid="backups-table">
<thead>
<tr className="border-b border-secondary-200">
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.date', 'Datum')}
</th>
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.filename', 'Dateiname')}
</th>
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.size', 'Größe')}
</th>
<th className="text-center text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.status', 'Status')}
</th>
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
{t('common.actions', 'Aktionen')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{backups.map((backup) => (
<tr
key={backup.id}
className="hover:bg-secondary-50 transition-colors"
data-testid={`backup-row-${backup.id}`}
>
{/* Date */}
<td className="px-3 py-3">
<span className="text-sm text-secondary-900">
{formatDate(backup.created_at)}
</span>
</td>
{/* Filename */}
<td className="px-3 py-3 max-w-xs">
<span className="text-sm text-secondary-700 truncate block">
{backup.filename}
</span>
</td>
{/* Size */}
<td className="px-3 py-3 text-right">
<span className="text-sm text-secondary-700 font-mono">
{formatFileSize(backup.size_bytes)}
</span>
</td>
{/* Status */}
<td className="px-3 py-3 text-center">
<StatusBadge status={backup.status} />
</td>
{/* Actions */}
<td className="px-3 py-3 text-right">
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => handleRestoreClick(backup)}
disabled={backup.status !== 'completed'}
className={clsx(
'inline-flex items-center justify-center rounded-md p-1.5',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
'min-h-touch min-w-touch',
backup.status === 'completed'
? 'text-secondary-400 hover:text-primary-600 hover:bg-primary-50'
: 'text-secondary-300 cursor-not-allowed'
)}
aria-label={t('backup.restoreLabel', 'Backup wiederherstellen')}
data-testid={`backup-restore-btn-${backup.id}`}
>
<Download className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => handleDeleteClick(backup)}
className={clsx(
'inline-flex items-center justify-center rounded-md p-1.5',
'text-secondary-400 hover:text-danger-600 hover:bg-danger-50',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500',
'min-h-touch min-w-touch'
)}
aria-label={t('backup.deleteLabel', 'Backup löschen')}
data-testid={`backup-delete-btn-${backup.id}`}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* Restore Modal */}
<RestoreModal
open={showRestoreModal}
backup={restoringBackup}
onConfirm={handleRestoreConfirm}
onCancel={handleRestoreCancel}
isRestoring={restoreMutation.isPending}
/>
{/* Delete Modal */}
<DeleteModal
open={showDeleteModal}
backup={deletingBackup}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
isDeleting={deleteMutation.isPending}
/>
</div>
);
}
+628
View File
@@ -0,0 +1,628 @@
/**
* SettingsWebhooks page — Webhook management.
*
* Features:
* - Table listing all webhooks with URL, events, status toggle, test/edit/delete buttons
* - Create/edit modal: URL, events (multi-select checkboxes), secret, retry_count, timeout
* - Test button sends test payload, shows result
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import clsx from 'clsx';
import { Plus, Pencil, Trash2, AlertTriangle, Send, Webhook as WebhookIcon, CheckCircle, XCircle } from 'lucide-react';
import {
fetchWebhooks,
createWebhook,
updateWebhook,
deleteWebhook,
testWebhook,
useWebhooks,
useCreateWebhook,
useUpdateWebhook,
useDeleteWebhook,
useTestWebhook,
type Webhook,
type CreateWebhookPayload,
type UpdateWebhookPayload,
type WebhookTestResult,
} from '@/api/webhooks';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
// ─── Available Events ───────────────────────────────────────────────────────
const AVAILABLE_EVENTS = [
'contact.created',
'contact.updated',
'contact.deleted',
'company.created',
'company.updated',
'company.deleted',
'user.created',
'user.updated',
'deal.created',
'deal.updated',
'deal.deleted',
'task.created',
'task.updated',
'task.deleted',
'note.created',
'note.updated',
'note.deleted',
];
// ─── Webhook Form Modal (shared for create & edit) ───────────────────────────
interface WebhookFormModalProps {
open: boolean;
onClose: () => void;
webhook?: Webhook | null;
onSubmit: (data: CreateWebhookPayload | UpdateWebhookPayload) => void;
isSubmitting: boolean;
error?: string | null;
}
function WebhookFormModal({ open, onClose, webhook, onSubmit, isSubmitting, error }: WebhookFormModalProps) {
const { t } = useTranslation();
const isEdit = !!webhook;
const [url, setUrl] = useState(webhook?.url ?? '');
const [events, setEvents] = useState<string[]>(webhook?.events ?? []);
const [secret, setSecret] = useState(webhook?.secret ?? '');
const [retryCount, setRetryCount] = useState(webhook?.retry_count ?? 3);
const [timeoutSeconds, setTimeoutSeconds] = useState(webhook?.timeout_seconds ?? 30);
// Reset form when modal opens or webhook changes
React.useEffect(() => {
if (open) {
setUrl(webhook?.url ?? '');
setEvents(webhook?.events ?? []);
setSecret(webhook?.secret ?? '');
setRetryCount(webhook?.retry_count ?? 3);
setTimeoutSeconds(webhook?.timeout_seconds ?? 30);
}
}, [open, webhook]);
const toggleEvent = useCallback((event: string) => {
setEvents((prev) =>
prev.includes(event)
? prev.filter((e) => e !== event)
: [...prev, event]
);
}, []);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const trimmedUrl = url.trim();
if (!trimmedUrl || events.length === 0) return;
const data: CreateWebhookPayload | UpdateWebhookPayload = {
url: trimmedUrl,
events,
secret: secret.trim() || null,
retry_count: retryCount,
timeout_seconds: timeoutSeconds,
};
if (!isEdit) {
(data as CreateWebhookPayload).is_active = true;
}
onSubmit(data);
},
[url, events, secret, retryCount, timeoutSeconds, isEdit, onSubmit]
);
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? t('webhooks.editTitle', 'Webhook bearbeiten') : t('webhooks.createTitle', 'Neuer Webhook')}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* URL */}
<Input
label={t('webhooks.url', 'URL')}
value={url}
onChange={(e) => setUrl(e.target.value)}
required
placeholder={t('webhooks.urlPlaceholder', 'https://example.com/webhook')}
autoFocus
data-testid="webhook-form-url"
/>
{/* Events multi-select */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
{t('webhooks.events', 'Events')}
</label>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto border border-secondary-200 rounded-md p-2">
{AVAILABLE_EVENTS.map((event) => (
<label
key={event}
className={clsx(
'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm',
'hover:bg-secondary-50 transition-colors',
events.includes(event) ? 'bg-primary-50 text-primary-700' : 'text-secondary-700'
)}
>
<input
type="checkbox"
checked={events.includes(event)}
onChange={() => toggleEvent(event)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span>{event}</span>
</label>
))}
</div>
{events.length === 0 && (
<p className="text-xs text-danger-600 mt-1">
{t('webhooks.eventsRequired', 'Mindestens ein Event erforderlich')}
</p>
)}
</div>
{/* Secret */}
<Input
label={t('webhooks.secret', 'Secret (optional)')}
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={t('webhooks.secretPlaceholder', 'HMAC-Signing Secret')}
type="password"
data-testid="webhook-form-secret"
/>
{/* Retry Count & Timeout */}
<div className="grid grid-cols-2 gap-4">
<Input
label={t('webhooks.retryCount', 'Retry Count')}
value={String(retryCount)}
onChange={(e) => setRetryCount(parseInt(e.target.value) || 3)}
type="number"
min={0}
max={10}
data-testid="webhook-form-retry"
/>
<Input
label={t('webhooks.timeout', 'Timeout (s)')}
value={String(timeoutSeconds)}
onChange={(e) => setTimeoutSeconds(parseInt(e.target.value) || 30)}
type="number"
min={1}
max={120}
data-testid="webhook-form-timeout"
/>
</div>
{/* Error */}
{error && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={isSubmitting}
disabled={!url.trim() || events.length === 0}
icon={<Plus className="h-4 w-4" />}
data-testid="webhook-form-submit"
>
{isEdit ? t('common.save', 'Speichern') : t('webhooks.create', 'Erstellen')}
</Button>
</div>
</form>
</Modal>
);
}
// ─── Delete Confirmation Modal ──────────────────────────────────────────────
interface DeleteModalProps {
open: boolean;
webhook: Webhook | null;
onConfirm: () => void;
onCancel: () => void;
isDeleting: boolean;
}
function DeleteModal({ open, webhook, onConfirm, onCancel, isDeleting }: DeleteModalProps) {
const { t } = useTranslation();
return (
<Modal open={open} onClose={onCancel} title={t('webhooks.deleteTitle', 'Webhook löschen')} size="sm">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 rounded-full bg-danger-100 p-2">
<AlertTriangle className="h-5 w-5 text-danger-600" aria-hidden="true" />
</div>
<div>
<p className="text-sm text-secondary-700">
{t('webhooks.deleteConfirm', 'Möchten Sie den Webhook')}{' '}
<span className="font-semibold text-secondary-900 break-all">{webhook?.url}</span>{' '}
{t('webhooks.deleteConfirmEnd', 'wirklich löschen?')}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isDeleting}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
type="button"
variant="danger"
onClick={onConfirm}
isLoading={isDeleting}
data-testid="webhook-delete-confirm"
>
{t('webhooks.delete', 'Löschen')}
</Button>
</div>
</div>
</Modal>
);
}
// ─── Test Result Modal ──────────────────────────────────────────────────────
interface TestResultModalProps {
open: boolean;
result: WebhookTestResult | null;
onClose: () => void;
}
function TestResultModal({ open, result, onClose }: TestResultModalProps) {
const { t } = useTranslation();
if (!result) return null;
return (
<Modal open={open} onClose={onClose} title={t('webhooks.testResult', 'Test-Ergebnis')} size="sm">
<div className="space-y-4">
<div className={clsx(
'flex items-start gap-3',
result.success ? 'text-success-700' : 'text-danger-700'
)}>
<div className={clsx(
'flex-shrink-0 rounded-full p-2',
result.success ? 'bg-success-100' : 'bg-danger-100'
)}>
{result.success
? <CheckCircle className="h-5 w-5" aria-hidden="true" />
: <XCircle className="h-5 w-5" aria-hidden="true" />
}
</div>
<div>
<p className="text-sm font-medium">
{result.success
? t('webhooks.testSuccess', 'Webhook erfolgreich gesendet')
: t('webhooks.testFailed', 'Webhook fehlgeschlagen')
}
</p>
{result.status_code && (
<p className="text-sm mt-1">
{t('webhooks.statusCode', 'Status-Code')}: {result.status_code}
</p>
)}
{result.error && (
<p className="text-sm mt-1">
{t('webhooks.error', 'Fehler')}: {result.error}
</p>
)}
</div>
</div>
<div className="flex items-center justify-end pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t('common.close', 'Schließen')}
</Button>
</div>
</div>
</Modal>
);
}
// ─── Main Page Component ─────────────────────────────────────────────────────
export function SettingsWebhooksPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: webhooks = [], isLoading, error } = useWebhooks();
const createMutation = useCreateWebhook();
const updateMutation = useUpdateWebhook();
const deleteMutation = useDeleteWebhook();
const testMutation = useTestWebhook();
// Modal state
const [formModalOpen, setFormModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<Webhook | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deletingWebhook, setDeletingWebhook] = useState<Webhook | null>(null);
const [testResultModalOpen, setTestResultModalOpen] = useState(false);
const [testResult, setTestResult] = useState<WebhookTestResult | null>(null);
const [formError, setFormError] = useState<string | null>(null);
// Handlers
const handleCreate = useCallback(() => {
setEditingWebhook(null);
setFormError(null);
setFormModalOpen(true);
}, []);
const handleEdit = useCallback((webhook: Webhook) => {
setEditingWebhook(webhook);
setFormError(null);
setFormModalOpen(true);
}, []);
const handleFormSubmit = useCallback(
(data: CreateWebhookPayload | UpdateWebhookPayload) => {
setFormError(null);
if (editingWebhook) {
updateMutation.mutate(
{ id: editingWebhook.id, data: data as UpdateWebhookPayload },
{
onSuccess: () => {
setFormModalOpen(false);
setEditingWebhook(null);
},
onError: (err: any) => {
setFormError(err?.message || t('webhooks.updateError', 'Fehler beim Aktualisieren'));
},
}
);
} else {
createMutation.mutate(data as CreateWebhookPayload, {
onSuccess: () => {
setFormModalOpen(false);
},
onError: (err: any) => {
setFormError(err?.message || t('webhooks.createError', 'Fehler beim Erstellen'));
},
});
}
},
[editingWebhook, createMutation, updateMutation, t]
);
const handleDeleteClick = useCallback((webhook: Webhook) => {
setDeletingWebhook(webhook);
setDeleteModalOpen(true);
}, []);
const handleDeleteConfirm = useCallback(() => {
if (!deletingWebhook) return;
deleteMutation.mutate(deletingWebhook.id, {
onSuccess: () => {
setDeleteModalOpen(false);
setDeletingWebhook(null);
},
});
}, [deletingWebhook, deleteMutation]);
const handleTest = useCallback(
(webhook: Webhook) => {
testMutation.mutate(webhook.id, {
onSuccess: (result) => {
setTestResult(result);
setTestResultModalOpen(true);
},
onError: (err: any) => {
setTestResult({ success: false, status_code: null, error: err?.message || 'Unknown error' });
setTestResultModalOpen(true);
},
});
},
[testMutation]
);
const handleToggleActive = useCallback(
(webhook: Webhook) => {
updateMutation.mutate({
id: webhook.id,
data: { is_active: !webhook.is_active },
});
},
[updateMutation]
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-secondary-900">
{t('webhooks.title', 'Webhooks')}
</h1>
<p className="mt-1 text-sm text-secondary-500">
{t('webhooks.description', 'Verwalten Sie ausgehende Webhook-Abonnements für Ereignisbenachrichtigungen')}
</p>
</div>
<Button
variant="primary"
onClick={handleCreate}
icon={<Plus className="h-4 w-4" />}
data-testid="webhook-create-btn"
>
{t('webhooks.add', '+ Neuer Webhook')}
</Button>
</div>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent" />
</div>
)}
{/* Error */}
{error && (
<Card>
<div className="p-4 text-center text-danger-600">
{t('webhooks.loadError', 'Fehler beim Laden der Webhooks')}
</div>
</Card>
)}
{/* Empty state */}
{!isLoading && !error && webhooks.length === 0 && (
<Card>
<div className="flex flex-col items-center justify-center py-12 text-center">
<WebhookIcon className="h-12 w-12 text-secondary-300 mb-4" />
<h3 className="text-lg font-medium text-secondary-900">
{t('webhooks.noWebhooks', 'Keine Webhooks')}
</h3>
<p className="mt-1 text-sm text-secondary-500 max-w-sm">
{t('webhooks.noWebhooksDesc', 'Erstellen Sie Ihren ersten Webhook, um Ereignisbenachrichtigungen an externe Dienste zu senden.')}
</p>
<Button
variant="primary"
className="mt-4"
onClick={handleCreate}
icon={<Plus className="h-4 w-4" />}
>
{t('webhooks.createFirst', 'Ersten Webhook erstellen')}
</Button>
</div>
</Card>
)}
{/* Webhook list */}
{!isLoading && !error && webhooks.length > 0 && (
<div className="space-y-3">
{webhooks.map((webhook) => (
<Card key={webhook.id}>
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={clsx(
'inline-block w-2 h-2 rounded-full',
webhook.is_active ? 'bg-success-500' : 'bg-secondary-300'
)}
aria-hidden="true"
/>
<span className="text-sm font-medium text-secondary-900 break-all">
{webhook.url}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{webhook.events.slice(0, 5).map((event) => (
<span
key={event}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary-50 text-primary-700"
>
{event}
</span>
))}
{webhook.events.length > 5 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-600">
+{webhook.events.length - 5}
</span>
)}
</div>
<div className="mt-1 text-xs text-secondary-400">
{t('webhooks.retryCount', 'Retries')}: {webhook.retry_count} |{' '}
{t('webhooks.timeout', 'Timeout')}: {webhook.timeout_seconds}s
</div>
</div>
<div className="flex items-center gap-1 ml-4 flex-shrink-0">
{/* Toggle active */}
<button
type="button"
onClick={() => handleToggleActive(webhook)}
className={clsx(
'relative inline-flex h-6 w-10 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
webhook.is_active ? 'bg-success-500' : 'bg-secondary-200'
)}
role="switch"
aria-checked={webhook.is_active}
aria-label={t('webhooks.toggleActive', 'Aktiv/Inaktiv umschalten')}
>
<span
className={clsx(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
webhook.is_active ? 'translate-x-4' : 'translate-x-0'
)}
/>
</button>
{/* Test */}
<button
type="button"
onClick={() => handleTest(webhook)}
className="p-1.5 rounded-md text-secondary-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
title={t('webhooks.test', 'Testen')}
data-testid="webhook-test-btn"
>
<Send className="h-4 w-4" />
</button>
{/* Edit */}
<button
type="button"
onClick={() => handleEdit(webhook)}
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 transition-colors"
title={t('common.edit', 'Bearbeiten')}
data-testid="webhook-edit-btn"
>
<Pencil className="h-4 w-4" />
</button>
{/* Delete */}
<button
type="button"
onClick={() => handleDeleteClick(webhook)}
className="p-1.5 rounded-md text-secondary-400 hover:text-danger-600 hover:bg-danger-50 transition-colors"
title={t('common.delete', 'Löschen')}
data-testid="webhook-delete-btn"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
</Card>
))}
</div>
)}
{/* Modals */}
<WebhookFormModal
open={formModalOpen}
onClose={() => { setFormModalOpen(false); setEditingWebhook(null); setFormError(null); }}
webhook={editingWebhook}
onSubmit={handleFormSubmit}
isSubmitting={createMutation.isPending || updateMutation.isPending}
error={formError}
/>
<DeleteModal
open={deleteModalOpen}
webhook={deletingWebhook}
onConfirm={handleDeleteConfirm}
onCancel={() => { setDeleteModalOpen(false); setDeletingWebhook(null); }}
isDeleting={deleteMutation.isPending}
/>
<TestResultModal
open={testResultModalOpen}
result={testResult}
onClose={() => { setTestResultModalOpen(false); setTestResult(null); }}
/>
</div>
);
}
export default SettingsWebhooksPage;
+4
View File
@@ -58,6 +58,8 @@ const ImportExportPage = React.lazy(() => import('@/pages/ImportExport').then(m
const TagsPage = React.lazy(() => import('@/pages/Tags').then(m => ({ default: m.TagsPage })));
const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m => ({ default: m.CustomFieldsPage })));
const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage })));
const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage })));
/** Centered spinner fallback for lazy-loaded routes */
function PageLoader() {
@@ -161,6 +163,8 @@ const router = createBrowserRouter([
{ path: 'ai-settings', element: withSuspense(<SettingsAIPage />) },
{ path: 'menu', element: withSuspense(<SettingsMenuOrderPage />) },
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
{ path: '*', element: <PluginRouteRenderer /> },
],
},
+102
View File
@@ -0,0 +1,102 @@
import { create } from 'zustand';
/**
* Onboarding Store — manages the guided tour state for first-time users.
* Persists to localStorage so progress survives page reloads.
*/
const STORAGE_KEY = 'leocrm_onboarding';
export interface OnboardingState {
isActive: boolean;
step: number;
completed: boolean;
skipped: boolean;
start: () => void;
next: () => void;
prev: () => void;
skip: () => void;
complete: () => void;
goToStep: (n: number) => void;
reset: () => void;
}
interface PersistedData {
step: number;
completed: boolean;
skipped: boolean;
}
function loadPersisted(): Partial<OnboardingState> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const data: PersistedData = JSON.parse(raw);
return {
step: data.step ?? 0,
completed: data.completed ?? false,
skipped: data.skipped ?? false,
};
} catch {
return {};
}
}
function persist(state: OnboardingState): void {
try {
const data: PersistedData = {
step: state.step,
completed: state.completed,
skipped: state.skipped,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// localStorage may be unavailable in some contexts
}
}
const initial = loadPersisted();
export const useOnboardingStore = create<OnboardingState>((set, get) => ({
isActive: false,
step: initial.step ?? 0,
completed: initial.completed ?? false,
skipped: initial.skipped ?? false,
start: () => {
set({ isActive: true, step: 0, completed: false, skipped: false });
persist(get());
},
next: () => {
const s = get();
set({ step: s.step + 1 });
persist(get());
},
prev: () => {
const s = get();
set({ step: Math.max(0, s.step - 1) });
persist(get());
},
skip: () => {
set({ isActive: false, skipped: true });
persist(get());
},
complete: () => {
set({ isActive: false, completed: true, step: 0 });
persist(get());
},
goToStep: (n: number) => {
set({ step: Math.max(0, n) });
persist(get());
},
reset: () => {
set({ isActive: false, step: 0, completed: false, skipped: false });
persist(get());
},
}));