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:
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user