commit 4ce4204b6ce8db066d499c942108356b841055a8 Author: wehub-resource-sync Date: Mon Jul 13 12:24:08 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2f12f7b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Test coverage +coverage/ + +# Environment files +.env +.env.local +.env.*.local + +# Data files (sessions, media, database) +data/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* + +# Temporary files +tmp/ +temp/ + +# Docker +.docker/ + +# Debug +.pnpm-debug.log* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18cd8d0 --- /dev/null +++ b/.env.example @@ -0,0 +1,338 @@ +# ============================================================================= +# OpenWA - Environment Configuration +# ============================================================================= +# Copy this file to .env and customize as needed. +# This is the Single Source of Truth for all configuration. + +# ============================================================================= +# CORE SETTINGS +# ============================================================================= +NODE_ENV=production +# Port the app binds to when run directly (bare metal / `npm run start:prod`). In the bundled Docker +# Compose the container always listens on 2785; the API_PORT below is only the HOST-published port. +PORT=2785 +# Docker Compose only: the host-side port mapped to the container's 2785 (no effect on a bare-metal run). +API_PORT=2785 +LOG_LEVEL=info # error | warn | info | debug +# Console output format. Default: json in production (containers, log aggregators), +# human-readable pretty otherwise. Force one with: json | pretty +# LOG_FORMAT=pretty + +# Auto-start previously authenticated sessions on server boot. Recommended `true` for a SINGLE-instance +# production deployment so a crash-restart self-heals authenticated sessions; keep `false` if you run +# multiple replicas behind a scheduler (two replicas resurrecting one session risks a forced logout/ban — +# see docs/13-horizontal-scaling.md). +AUTO_START_SESSIONS=false +# 0 = unlimited. Set a positive integer to cap concurrently running/initializing sessions. +# Failed sessions still hold a slot until stopped (their engine stays resident); stop them to free capacity. +MAX_CONCURRENT_SESSIONS=0 + +# Graceful-shutdown drain: on SIGTERM/SIGINT the app flips readiness to 503 (so a load balancer stops +# routing) and keeps serving in-flight requests for this many ms before teardown. Default 3000 in +# production, 0 elsewhere (a dev hot-reload/Ctrl+C is not slowed). Capped at 30000. A second signal +# forces an immediate exit. In Docker, keep `stop_grace_period` >= this + your worst-case teardown. +# SHUTDOWN_DELAY_MS=3000 + +# Domain Configuration +DOMAIN=localhost + +# Host interface the dev compose binds the API/dashboard ports to. Defaults to 127.0.0.1 +# (localhost only). Set to 0.0.0.0 to reach the dev stack from another machine (e.g. a remote +# VPS) — put a TLS reverse proxy in front for anything internet-facing (the API key is sent in +# cleartext over plain HTTP). +# BIND_HOST=0.0.0.0 + +# Public URLs (for startup banner and external access) +# BASE_URL=https://api.yourdomain.com +# DASHBOARD_URL=https://dashboard.yourdomain.com + +# CORS Configuration: comma-separated allowed origins. The wildcard "*" is allowed +# only in development; in production it is REFUSED (cross-origin browser requests are +# blocked) — set explicit origin(s) there, e.g. https://dashboard.yourdomain.com +CORS_ORIGINS=* + +# SSL/TLS: terminate TLS at your own reverse proxy (nginx, Caddy, a cloud load +# balancer, or a k8s Ingress) in front of the API — see docs/12-troubleshooting-faq.md. + +# Trusted reverse proxies (comma-separated IPs/CIDRs) whose X-Forwarded-For +# header is trusted to determine the real client IP for API-key IP whitelisting. +# Leave empty (default) to ignore X-Forwarded-For and use the direct socket +# address, which prevents IP spoofing. If you run behind a reverse proxy AND use +# per-key allowedIps restrictions, set this to the proxy's address/subnet, e.g.: +# TRUSTED_PROXIES=172.18.0.0/16 + +# ============================================================================= +# ENGINE CONFIGURATION +# ============================================================================= +# Which WhatsApp engine to use (plugin-based) +# Options: whatsapp-web.js, baileys +# Left unset by default so the dashboard (Infrastructure > Engine) governs the active engine via +# data/.env.generated (defaults to whatsapp-web.js). Uncomment to pin an engine from the environment; +# a value set here always wins over the dashboard selection. +# ENGINE_TYPE=whatsapp-web.js + +# Engine-specific settings +SESSION_DATA_PATH=./data/sessions +PUPPETEER_HEADLESS=true +PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu +# Path to a system Chromium/Chrome binary. Leave unset to use the bundled Puppeteer browser. +# Required in the Docker image and on hosts without a bundled browser (e.g. Alpine/ARM). +# PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +# ── Container resource limits (docker-compose only) ─────────────────────────── +# These map to docker-compose `mem_limit` / `pids_limit` and only apply when running via the +# bundled compose files. They are read by docker-compose.yml, not by the app process. +# Override in your .env to tune for your fleet. + +# Memory ceiling for the openwa-api container. whatsapp-web.js runs a full Chromium per session, so +# raise this for multi-session deployments (e.g. 4g). Baileys (no Chromium) is far lighter. +OPENWA_MEM_LIMIT=2g + +# Per-container process (PID) ceiling — a fork-bomb guard, NOT an allocation (the kernel only +# rejects forks once the count is reached, so a higher limit is free for light containers). +# Default 2048 fits ~8-10 whatsapp-web.js sessions with startup-spike headroom; each Chromium is +# itself multi-process (browser + renderer + GPU + zygote + utilities) and WhatsApp Web is +# process-heavy, so the old default (512) could get a session's Chromium killed mid-spawn during +# startup — surfacing in the API as a `Code: null` launch failure (#636). Baileys is single-process +# and uses a handful of PIDs regardless. Raise for larger fleets; do NOT set -1 (drops the guard). +OPENWA_PIDS_LIMIT=2048 + +# Optional WhatsApp Web client version pin. Leave unset (or use "latest", "auto", or "off") to +# let whatsapp-web.js auto-select. If sessions get stuck at "authenticating" after scanning the QR, +# set this to a known-good version from https://github.com/wppconnect-team/wa-version (browse the +# html/ folder). WWEBJS_WEB_VERSION_REMOTE_PATH overrides the URL template (use {version} as the +# placeholder) if you self-host the version HTML. +# WWEBJS_WEB_VERSION=2.3000.1040641150-alpha + +# Extend the initial boot/inject wait (milliseconds) before QR generation. On slow first boots +# (WSL2 or low-resource containers) the default 30000ms can expire before WhatsApp Web finishes +# loading. Raise it (e.g. 120000) if startup times out. Unset keeps the default (30000). +# WWEBJS_AUTH_TIMEOUT_MS=120000 + +# Baileys engine (used when ENGINE_TYPE=baileys). WebSocket client, no Chromium. +# NOTE: the Baileys engine is loaded lazily (dynamic import, only when ENGINE_TYPE=baileys), so there is no +# global Node.js version floor. Node.js 22 LTS is recommended for all deployments. +BAILEYS_AUTH_DIR=./data/baileys +# NOTE: proxy (PROXY_URL/PROXY_TYPE) is not yet supported by the baileys engine; it is ignored. +# Pull the FULL message-history archive on connect (large). Off by default: the engine still enables the +# initial sync (contacts, chats, recent messages, lid mappings) without downloading the entire history. +BAILEYS_SYNC_FULL_HISTORY=false +# Surface the Baileys library's own logs for debugging (trace|debug|info|warn|error). Silent by default. +# `trace` dumps the decoded WhatsApp wire frames to stdout (context "baileys-wire"). +# BAILEYS_LOG_LEVEL=debug + +# Humanise single sends: show a "typing…" indicator and pause briefly (length-scaled, jittered) +# before each text send so messages don't look instantaneous (anti-ban). ON by default — set to +# false to disable. SIMULATE_TYPING_MAX_MS caps the pause (default 5000). Does not affect bulk +# sends, which have their own delayBetweenMessages throttle. +# SIMULATE_TYPING=false +# SIMULATE_TYPING_MAX_MS=5000 + +# Inline @lid -> phone resolution (#263). When a sender is identified by a WhatsApp privacy id +# (@lid) instead of a phone number, attach a best-effort `senderPhone` (MSISDN digits, or null when +# the engine can't map it) to the message.received webhook + websocket payload. OFF by default — +# it adds a per-sender lookup (cached). The on-demand endpoint GET /sessions/:id/contacts/:id/phone +# works regardless of this flag. +# RESOLVE_LID_TO_PHONE=true + +# Observability — Prometheus scrape endpoint at GET /api/metrics. +# Disabled by default; set a token to enable. Scrapers must send `Authorization: Bearer `. +# METRICS_TOKEN=change-me-to-a-long-random-string + +# Audit-log retention. Logs older than this many days are pruned daily (and once at startup). +# Default 90; set to 0 to keep audit logs forever (disable pruning). +# AUDIT_RETENTION_DAYS=90 + +# ============================================================================= +# DATABASE +# ============================================================================= +# Options: sqlite, postgres +DATABASE_TYPE=sqlite +POSTGRES_BUILTIN=false # Use built-in PostgreSQL container? + +# PostgreSQL settings (ignored for sqlite, auto-configured if POSTGRES_BUILTIN=true) +DATABASE_HOST=localhost +DATABASE_PORT=5432 +# PostgreSQL database name ONLY. Leave unset for SQLite (DATABASE_TYPE=sqlite) to use the +# default file path ./data/openwa.sqlite — a bare value here would become the SQLite file PATH +# and, under a read-only container rootfs, trigger a SQLITE_CANTOPEN boot-loop (#677). +# DATABASE_NAME=openwa +# PostgreSQL schema (ignored for sqlite). Default 'public' keeps the historical behavior. +# Set to a dedicated schema to isolate OpenWA's tables + migration ledger (e.g. on managed +# Postgres where you get a project schema, or to share one DB across apps). The schema must +# already exist — the built-in container creates it; for external/managed Postgres create it +# once (CREATE SCHEMA openwa;). A missing schema fails fast at migration time. +POSTGRES_SCHEMA=public +DATABASE_USERNAME=openwa +# MUST set a strong, unique value before using Postgres — no default is shipped. +# Production refuses to start if this is empty or a known placeholder when DATABASE_TYPE=postgres. +DATABASE_PASSWORD= +DATABASE_SYNCHRONIZE=false # WARNING: Set false in production! (data DB) +DATABASE_LOGGING=false +# Auth/audit (main) DB schema management. Default ON (zero-config first boot). +# Set to "false" to manage the api_keys/audit_logs schema via the bundled main-owned +# migration instead of synchronize (migrationsRun creates them at boot). +MAIN_DATABASE_SYNCHRONIZE=true +DATABASE_SSL=false # Set true for managed Postgres (Supabase, Heroku, Render, Railway) +DATABASE_SSL_REJECT_UNAUTHORIZED=true # Set false to allow self-signed certs (only when DATABASE_SSL=true) +# Postgres pool/query timeouts (ms). Defaults are conservative; set any to 0 to disable. +DATABASE_STATEMENT_TIMEOUT_MS=30000 # Aborts a single runtime query that runs longer (server-side; not applied to migrations) +DATABASE_IDLE_TIMEOUT_MS=30000 # Closes idle pooled connections after this long +DATABASE_CONNECTION_TIMEOUT_MS=10000 # Fails fast if a new connection can't be acquired in time + +# ============================================================================= +# REDIS / QUEUE (Phase 2) +# ============================================================================= +REDIS_ENABLED=false # Enable Redis for queue and caching +REDIS_BUILTIN=false # Use built-in Redis container? +# Process webhooks/ingress through the BullMQ queue (needs a reachable Redis via REDIS_HOST/REDIS_PORT). +# Off by default (inline dispatch). In the bundled Docker Compose this is dashboard-managed +# (Infrastructure > Redis & Queue) — a host value here is not forwarded to the container. +# QUEUE_ENABLED=false +# Redis-backed caching, independent of the queue. Off by default (in-memory cache). +# CACHE_ENABLED=false + +# Redis settings (auto-configured if REDIS_BUILTIN=true) +REDIS_HOST=localhost +REDIS_PORT=6379 +# Fail Redis queue/cache connection attempts after this many ms. +REDIS_CONNECT_TIMEOUT_MS=5000 +# REDIS_USERNAME= +# REDIS_PASSWORD= + +# ============================================================================= +# STORAGE +# ============================================================================= +# Options: local, s3 +STORAGE_TYPE=local +MINIO_BUILTIN=false # Use built-in MinIO container? + +# Local storage path (if STORAGE_TYPE=local) +STORAGE_LOCAL_PATH=./data/media + +# S3/MinIO settings (if STORAGE_TYPE=s3, auto-configured if MINIO_BUILTIN=true) +S3_ENDPOINT=http://localhost:9000 +S3_BUCKET=openwa +S3_REGION=us-east-1 +# MUST set strong, unique values before using S3/MinIO — no defaults shipped. +# Production refuses to start if these are empty/placeholder when STORAGE_TYPE=s3 (external). +# Canonical names (what the app reads first and the dashboard writes); the legacy +# S3_ACCESS_KEY / S3_SECRET_KEY are still accepted as a fallback for older setups. +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= + +# ============================================================================= +# WEBHOOK +# ============================================================================= +WEBHOOK_TIMEOUT=10000 # Timeout in milliseconds +WEBHOOK_MAX_RETRIES=3 # Number of retry attempts +WEBHOOK_RETRY_DELAY=5000 # Delay between retries in ms +# Days to keep webhook delivery-failure records before pruning them (default 90; set <= 0 to disable). +# WEBHOOK_FAILURE_RETENTION_DAYS=90 +# Block outbound WEBHOOK deliveries to internal/reserved addresses (SSRF +# protection). ON by default; set to "false" only on closed networks. When on, +# webhook URLs resolving to loopback/private/link-local/metadata ranges are +# refused (at registration AND delivery) and redirects are not followed. +# (Server-side media-by-URL fetches are ALWAYS SSRF-guarded, regardless of this flag.) +WEBHOOK_SSRF_PROTECT=true + +# Expose the FULL sender `contact` field set (id, number, shortName, business flags, isBlocked, +# labels, …) on the message.received webhook + websocket payload. OFF by default — the payload keeps +# the minimal { name, pushName }. Opt in only if your consumer needs the richer data; all fields are +# read from the already-cached contact (no extra WhatsApp API calls). +# WEBHOOK_CONTACT_DETAILS=true +# Comma-separated hosts/IPs allowed to bypass SSRF protection for BOTH webhooks +# and media — escape-hatch for trusted internal targets (e.g. a localhost media +# store or a sidecar webhook receiver). +# SSRF_ALLOWED_HOSTS=localhost,minio +# Server-side media size/time limits: +# MEDIA_DOWNLOAD_ENABLED=true # Set to false to skip downloading inbound media entirely +# # (no decryption, no memory allocation, no storage). +# # NOTE: disabling also makes hasMedia report false and removes the +# # media field from webhooks and the dashboard. +# STORE_EPHEMERAL_MESSAGES=true # Set to false to skip persisting and dispatching incoming +# # WhatsApp disappearing messages (ephemeralDuration > 0). +# # Default: true (backward compatible — store everything). +# MEDIA_DOWNLOAD_MAX_BYTES=52428800 # cap remote-URL sends, inbound media, AND outbound base64 sends (default 50 MiB; oversized inbound media is dropped, message kept; oversized base64 is rejected with 400) +# MEDIA_DOWNLOAD_TIMEOUT_MS=30000 # abort a slow media download (default 30s) +# Storage import/export limits (ADMIN /infra/storage/* endpoints): +# STORAGE_IMPORT_MAX_BYTES=209715200 # per-entry cap for a tar.gz import; aborts on overflow (default 200 MiB) +# STORAGE_IMPORT_MAX_ENTRIES=100000 # max entries in an import archive; aborts beyond this (default 100000) +# STORAGE_EXPORT_TTL_MS=3600000 # auto-delete an export archive after this long (default 1h) + +# ============================================================================= +# RATE LIMITING (all TTLs are in MILLISECONDS) +# ============================================================================= +# The "medium" tier is the one enforced on the API; short/long are optional extra tiers. +RATE_LIMIT_MEDIUM_TTL=60000 # window in ms (default 60000 = 60s) +RATE_LIMIT_MEDIUM_LIMIT=100 # max requests per window +# RATE_LIMIT_SHORT_TTL=1000 # 1s burst window (default) +# RATE_LIMIT_SHORT_LIMIT=10 +# RATE_LIMIT_LONG_TTL=3600000 # 1h window (default) +# RATE_LIMIT_LONG_LIMIT=1000 + +# Per-instance fairness cap on the Integration Fabric ingress route (POST /api/ingress/:pluginId/:instanceId/*). +# Providers deliver every tenant's webhooks from one shared egress IP, so this is keyed on +# (pluginId, instanceId) instead of IP — a noisy tenant gets 429'd without throttling its neighbors. +# Independent of, and in addition to, the IP-keyed tiers above. +# INGRESS_INSTANCE_LIMIT=120 # max requests per instance per window (default 120) +# INGRESS_INSTANCE_TTL=60000 # window in ms (default 60000 = 60s) + +# ============================================================================= +# MCP (Model Context Protocol) — Agent / AI-assistant tool server +# ============================================================================= +# Off by default. When enabled, mounts a stateless Streamable-HTTP MCP server +# at POST /mcp on the same port. See docs/24-mcp-integration.md for details. +# MCP_ENABLED=true + +# Mount read-only tools only (no sends, no mutations). Recommended for observer agents. +# MCP_READONLY=true + +# Per-key sliding-window rate limit for tool calls. +# Any blank/non-positive/non-numeric value falls back to the default. +# MCP_RATE_LIMIT_MAX=60 # max tool calls per key per window (default 60) +# MCP_RATE_LIMIT_WINDOW_MS=60000 # window size in ms (default 60000 = 1 min) +# Pre-auth, per-IP sliding-window throttle for the /mcp mount (gates invalid-key requests before +# key validation, since the raw mount bypasses the global throttler). Same fallback rules. +# MCP_IP_RATE_LIMIT_MAX=120 # max requests per IP per window (default 120) +# MCP_IP_RATE_LIMIT_WINDOW_MS=60000 # window size in ms (default 60000 = 1 min) + +# ============================================================================= +# GLOBAL MESSAGE SEARCH +# ============================================================================= +# Global message search (optional). Default ON, using the built-in DB full-text +# provider (Postgres tsvector/GIN, SQLite FTS5) — zero external services. +SEARCH_ENABLED=true # set to false to disable the /search route + module entirely +SEARCH_PROVIDER=auto # auto | builtin-fts | none (plugin ids selectable once Spec 2 lands) +SEARCH_LIMIT_MAX=100 # hard cap on the `limit` query param + +# ============================================================================= +# PLUGINS +# ============================================================================= +PLUGINS_ENABLED=true # Enable plugin system +PLUGINS_DIR=./data/plugins # Plugin directory + +# ============================================================================= +# SECURITY +# ============================================================================= +# Master API key (leave empty to disable, or set to secure value) +API_MASTER_KEY= + +# First-boot default admin key. By default a cryptographically random key is +# generated (printed in the startup banner / written to data/.api-key). Set this +# to true ONLY for local development to seed the well-known, insecure +# `dev-admin-key` instead. Ignored when API_MASTER_KEY is set. +# ALLOW_DEV_API_KEY=true + +# Optional server-side pepper for API-key hashing (HMAC-SHA256 instead of plain SHA-256). +# Recommended in production. Note: setting or changing it invalidates all existing key hashes, +# so re-issue keys after enabling. Leave unset to keep the current (unpeppered) behaviour. +# API_KEY_PEPPER= + +# ============================================================================= +# DEVELOPER SETTINGS +# ============================================================================= +ENABLE_SWAGGER=true # Enable API documentation at /api/docs (set false to disable on exposed deployments) +BODY_SIZE_LIMIT=25mb # Max request body size (base64 media sends ride in the JSON body) +# CSP_UPGRADE_INSECURE_REQUESTS=false # Set false for an HTTP-only deployment on a trusted private network + # (default: on in production). Stops the browser upgrading the dashboard to https. diff --git a/.env.minimal b/.env.minimal new file mode 100644 index 0000000..934e978 --- /dev/null +++ b/.env.minimal @@ -0,0 +1,46 @@ +# =========================================== +# OpenWA - Environment Configuration (Minimal) +# =========================================== +# This is a minimal configuration for development +# and single-session personal bots using SQLite. + +# Auto-start previously authenticated sessions on server boot +AUTO_START_SESSIONS=false + +# Server +PORT=2785 +NODE_ENV=development + +# Database (SQLite - no external service required) +DATABASE_TYPE=sqlite +DATABASE_NAME=./data/openwa.sqlite +DATABASE_SYNCHRONIZE=true +DATABASE_LOGGING=false + +# WhatsApp Engine +ENGINE_TYPE=whatsapp-web.js +SESSION_DATA_PATH=./data/sessions +PUPPETEER_HEADLESS=true +PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage + +# Webhook +WEBHOOK_TIMEOUT=10000 +WEBHOOK_MAX_RETRIES=3 +WEBHOOK_RETRY_DELAY=5000 + +# Storage (Local filesystem) +STORAGE_TYPE=local +STORAGE_LOCAL_PATH=./data/media + +# Redis & Queue (disabled for minimal setup) +REDIS_ENABLED=false +REDIS_BUILTIN=false +QUEUE_ENABLED=false +CACHE_ENABLED=false + +# Built-in Docker services (all disabled for minimal setup) +POSTGRES_BUILTIN=false +MINIO_BUILTIN=false + +# API Security (optional for development) +# API_MASTER_KEY=your-master-api-key-here diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8663443 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Force LF line endings for files that must run inside Linux containers +*.sh text eol=lf +Dockerfile* text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..22d07aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,88 @@ +name: Bug report +description: Report a problem with OpenWA +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug! Please fill in the details below — the + version, deployment, and logs make a huge difference for triage. + + ⚠️ **Do not report security vulnerabilities here.** See + [SECURITY.md](https://github.com/rmyndharis/OpenWA/blob/main/SECURITY.md). + - type: checkboxes + attributes: + label: Pre-flight + options: + - label: I searched existing issues and this isn't a duplicate + required: true + - label: I'm on the latest released version (or noted my version below) + required: true + - type: input + id: version + attributes: + label: OpenWA version + description: e.g. 0.2.1 (shown on the dashboard Login screen) or a commit SHA + placeholder: "0.2.1" + validations: + required: true + - type: dropdown + id: deployment + attributes: + label: Deployment + options: + - Docker Compose + - Docker (manual run) + - Bare metal (npm) + - Other (describe below) + validations: + required: true + - type: dropdown + id: database + attributes: + label: Database + options: + - SQLite (default) + - PostgreSQL + validations: + required: true + - type: input + id: engine + attributes: + label: WhatsApp engine + description: The default is whatsapp-web.js; note the version if you changed it. + value: "whatsapp-web.js" + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug and its impact. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Exact steps (including the API call or dashboard action) to reproduce. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Server/container logs around the failure (redact API keys & secrets). + render: shell + - type: textarea + id: context + attributes: + label: Environment / additional context + description: OS, Node version, reverse proxy, anything else relevant. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e662971 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 📚 Documentation + url: https://github.com/rmyndharis/OpenWA/tree/main/docs + about: Setup, API specification, and operational guides. + - name: 🔒 Report a security vulnerability + url: https://github.com/rmyndharis/OpenWA/security/advisories/new + about: Please report vulnerabilities privately — not as a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..573c33f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature request +description: Suggest an idea or improvement for OpenWA +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! Please describe the problem first — that helps us find + the best solution, which isn't always the one initially imagined. + - type: checkboxes + attributes: + label: Pre-flight + options: + - label: I searched existing issues and this isn't already requested + required: true + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What are you trying to do, and what's getting in the way today? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What would you like to see? API shape, dashboard behavior, config, etc. + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: scope + attributes: + label: Scope + description: A quick reality check on engine capabilities. + options: + - label: >- + I understand some features are limited by the underlying WhatsApp engine + (e.g. interactive Buttons/List messages are not supported on whatsapp-web.js). diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c3c4b67 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,61 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + minor-and-patch: + update-types: + - minor + - patch + major: + update-types: + - major + labels: + - dependencies + ignore: + # Ignore major bumps for packages that need manual migration + # Uncomment specific packages below if you want to skip them entirely + # - dependency-name: "typescript" + # update-types: ["version-update:semver-major"] + + - package-ecosystem: npm + directory: /dashboard + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 3 + groups: + minor-and-patch: + update-types: + - minor + - patch + major: + update-types: + - major + labels: + - dependencies + - dashboard + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - ci + + # Base images (Dockerfile) + the compose stack's pinned images (socket-proxy, postgres, + # redis, minio). Keeps the security-critical docker-socket-proxy pin and the datastore + # images moving instead of drifting on a stale tag. + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 3 + labels: + - dependencies + - docker diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d13be4f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Description +Brief description of changes + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update + +## Checklist +- [ ] Tests added/updated +- [ ] Documentation updated +- [ ] Lint passes +- [ ] Self-reviewed + +## Screenshots (if applicable) + +## Related Issues +Closes # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3737406 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,240 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + NODE_VERSION: '22' + DOCKER_PLATFORMS: linux/amd64,linux/arm64 + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Security audit + run: npm audit --audit-level=critical + + - name: Run ESLint + run: npm run lint + + # `nest build` uses tsconfig.build.json which excludes **/*spec.ts, and ts-jest/eslint don't + # full-program type-check specs — so without this gate, type errors in spec files are invisible + # to CI. tsconfig.json already includes both `src` and `test`, so this is the cheapest full-program + # check covering specs (no separate tsconfig.spec.json needed). + - name: Type-check full program (including specs) + run: npx tsc --noEmit -p tsconfig.json + + - name: Check formatting + run: npm run format -- --check + + - name: Check version consistency (docs track package.json) + run: npm run check:versions + + # The committed openapi.json is the machine-readable API contract. Fail if a controller/DTO + # change landed without regenerating the snapshot (npm run openapi:export). The generator + # bootstraps the app hermetically (in-memory/temp SQLite, no listen), so this is a static check. + - name: Check OpenAPI snapshot is up to date + run: npm run openapi:check + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test -- --coverage + + - name: Run e2e smoke tests + run: npm run test:e2e + + - name: Upload coverage + uses: codecov/codecov-action@v7 + if: always() + with: + files: ./coverage/lcov.info + fail_ci_if_error: false + + test-postgres: + name: Test (PostgreSQL migrations) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: openwa + POSTGRES_PASSWORD: openwa + POSTGRES_DB: openwa + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U openwa" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build (compiles the data migrations to dist/) + run: npm run build + + # Applies the full data-migration chain to a real Postgres and asserts every generated-uuid PK + # has a DB DEFAULT — the dialect gap SQLite-only tests can't see. + - name: Migrate + uuid-default smoke against PostgreSQL + run: npm run test:pg-smoke + env: + DATABASE_TYPE: postgres + DATABASE_HOST: localhost + DATABASE_PORT: '5432' + DATABASE_USERNAME: openwa + DATABASE_PASSWORD: openwa + DATABASE_NAME: openwa + + # Runtime-proves BuiltInFtsProvider on Postgres (websearch_to_tsquery + ts_headline against the + # STORED body_ts tsvector). The spec self-skips unless DATABASE_TYPE=postgres, so it is a no-op + # in the default test job and only executes here against the postgres:16 service. + - name: Postgres FTS provider spec + run: npx jest src/database/migrations/__tests__/1782400000000-AddMessagesFts.pg.spec.ts + env: + DATABASE_TYPE: postgres + DATABASE_HOST: localhost + DATABASE_PORT: '5432' + DATABASE_USERNAME: openwa + DATABASE_PASSWORD: openwa + DATABASE_NAME: openwa + + dashboard: + name: Dashboard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: dashboard/package-lock.json + + - name: Install dependencies + run: cd dashboard && npm ci + + - name: Run ESLint + run: cd dashboard && npm run lint + + - name: Check i18n parity + run: cd dashboard && npm run i18n:check + + - name: Build dashboard + run: cd dashboard && npm run build + + - name: Run dashboard unit tests + run: cd dashboard && npm run test:unit + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test, dashboard] + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + retention-days: 7 + + docker: + name: Docker Build + runs-on: ubuntu-latest + needs: [build, test-postgres] + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + push: true + platforms: ${{ env.DOCKER_PLATFORMS }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # provenance is generated by default (build-push-action v7); pin it explicitly and opt into + # an SBOM attestation so each published image carries an in-toto SLSA provenance + SBOM pair, + # verifiable via `docker buildx imagetools inspect`. + provenance: true + sbom: true + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/java-sdk-release.yml b/.github/workflows/java-sdk-release.yml new file mode 100644 index 0000000..8691bf9 --- /dev/null +++ b/.github/workflows/java-sdk-release.yml @@ -0,0 +1,59 @@ +name: Java SDK Release + +# Publishes com.rmyndharis:openwa to Maven Central from sdk/java. Cleanly no-ops +# until the one-time setup is done (see sdk/java/README.md -> RELEASING): +# - MAVEN_CENTRAL_USERNAME / MAVEN_CENTRAL_PASSWORD (Sonatype Central user token halves) +# - GPG_PRIVATE_KEY (ASCII-armored signing key) +# - GPG_PASSPHRASE +# The SDK version is the pom on a dedicated tag (e.g. java-sdk-v0.1.0), +# NOT the monorepo v* app tags. + +on: + push: + tags: ['java-sdk-v*'] + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/java + steps: + - uses: actions/checkout@v7 + + - name: Guard — skip if publish credentials are absent + id: guard + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + run: | + if [ -z "${MAVEN_CENTRAL_USERNAME:-}" ]; then + echo "::notice::MAVEN_CENTRAL_USERNAME not set — skipping publish. See sdk/java/README.md -> RELEASING." + echo "publish=false" >> "$GITHUB_OUTPUT" + else + echo "publish=true" >> "$GITHUB_OUTPUT" + fi + + - name: Set up JDK + signing key + if: steps.guard.outputs.publish == 'true' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + server-id: central + server-username: MAVEN_CENTRAL_USERNAME + server-password: MAVEN_CENTRAL_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: GPG_PASSPHRASE + + - name: Deploy to Maven Central + if: steps.guard.outputs.publish == 'true' + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: mvn -B -Prelease deploy -DskipTests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..96411a4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,350 @@ +name: Release + +on: + push: + tags: ['v*'] + +env: + NODE_VERSION: '22' + DOCKER_PLATFORMS: linux/amd64,linux/arm64 + +permissions: + contents: write + packages: write + +jobs: + # ──── Release Gate (CI-strength) ────────────────────────────────── + # Mirror the CI workflow's gate jobs so a tag can never publish an image or GitHub Release that + # lint, the spec type-check, unit + e2e tests, the Postgres migration smoke, or the dashboard + # build/lint would have rejected. Each job below matches its CI counterpart; only the tag-match + # guard and the publish jobs (docker, release) are release-specific. + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # Fail fast (before tests, image build, or the GitHub Release) if the release is inconsistent: + # the tag must match package.json, and the docs/CHANGELOG version guard must pass. This stops a + # mis-tagged or under-documented release from ever publishing an image or a GitHub Release. + - name: Verify tag matches package.json version + env: + TAG: ${{ github.ref_name }} + run: | + PKG_VERSION="$(node -p "require('./package.json').version")" + TAG_VERSION="${TAG#v}" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag '$TAG' (version '$TAG_VERSION') does not match package.json version '$PKG_VERSION'. Bump package.json or fix the tag before releasing." + exit 1 + fi + echo "Tag $TAG matches package.json $PKG_VERSION" + + - name: Security audit + run: npm audit --audit-level=critical + + - name: Run ESLint + run: npm run lint + + # Same gap as CI: `nest build` excludes specs, so a spec-only type regression slips past every + # existing release gate. tsconfig.json includes both src and test, so this is the full-program + # check that also covers spec files. + - name: Type-check full program (including specs) + run: npx tsc --noEmit -p tsconfig.json + + - name: Check formatting + run: npm run format -- --check + + - name: Check version consistency (docs track package.json) + run: npm run check:versions + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test -- --coverage + + - name: Run e2e smoke tests + run: npm run test:e2e + + test-postgres: + name: Test (PostgreSQL migrations) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: openwa + POSTGRES_PASSWORD: openwa + POSTGRES_DB: openwa + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U openwa" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build (compiles the data migrations to dist/) + run: npm run build + + # Applies the full data-migration chain to a real Postgres and asserts every generated-uuid PK + # has a DB DEFAULT — the dialect gap SQLite-only tests can't see. + - name: Migrate + uuid-default smoke against PostgreSQL + run: npm run test:pg-smoke + env: + DATABASE_TYPE: postgres + DATABASE_HOST: localhost + DATABASE_PORT: '5432' + DATABASE_USERNAME: openwa + DATABASE_PASSWORD: openwa + DATABASE_NAME: openwa + + # Runtime-proves BuiltInFtsProvider on Postgres (websearch_to_tsquery + ts_headline against the + # STORED body_ts tsvector). The spec self-skips unless DATABASE_TYPE=postgres, so it is a no-op + # in the default test job and only executes here against the postgres:16 service. + - name: Postgres FTS provider spec + run: npx jest src/database/migrations/__tests__/1782400000000-AddMessagesFts.pg.spec.ts + env: + DATABASE_TYPE: postgres + DATABASE_HOST: localhost + DATABASE_PORT: '5432' + DATABASE_USERNAME: openwa + DATABASE_PASSWORD: openwa + DATABASE_NAME: openwa + + dashboard: + name: Dashboard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: dashboard/package-lock.json + + - name: Install dependencies + run: cd dashboard && npm ci + + - name: Run ESLint + run: cd dashboard && npm run lint + + - name: Check i18n parity + run: cd dashboard && npm run i18n:check + + - name: Build dashboard + run: cd dashboard && npm run build + + - name: Run dashboard unit tests + run: cd dashboard && npm run test:unit + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test, dashboard] + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + # ──── Create GitHub Release ─────────────────────────────────────── + release: + name: GitHub Release + runs-on: ubuntu-latest + # Gate the public Release on every CI-strength job AND a successfully built+pushed image that + # also boots, so a tag never produces a GitHub Release without passing the same gate CI enforces, + # a matching container image, and a runtime boot check on both architectures (boot-smoke runs the + # published image on amd64 + arm64). Trades a few minutes of buildx/boot latency for release + # safety. + needs: [lint, test, test-postgres, dashboard, build, docker, boot-smoke] + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Extract release notes from CHANGELOG + id: changelog + run: | + # Extract the section for this version from CHANGELOG.md + VERSION="${{ steps.version.outputs.VERSION }}" + NOTES=$(awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md) + if [ -z "$NOTES" ]; then + NOTES="Release v${VERSION}" + fi + # Use EOF delimiter for multi-line output + echo "NOTES<> $GITHUB_OUTPUT + echo "$NOTES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + # Author the release as a real user (the PAT owner) instead of github-actions[bot]. + # Add a repo secret RELEASE_PAT — a fine-grained PAT scoped to this repo with + # "Contents: read and write". Falls back to GITHUB_TOKEN (bot-authored) when the secret + # is absent, so the job never breaks before the token is configured. + token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }} + tag_name: ${{ github.ref_name }} + name: v${{ steps.version.outputs.VERSION }} + body: ${{ steps.changelog.outputs.NOTES }} + draft: false + prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} + + # ──── Docker Image with Version Tag ─────────────────────────────── + docker: + name: Docker Release + runs-on: ubuntu-latest + needs: [lint, test, test-postgres, dashboard, build] + steps: + - uses: actions/checkout@v7 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + push: true + platforms: ${{ env.DOCKER_PLATFORMS }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # provenance is generated by default (build-push-action v7); pin it explicitly and opt into + # an SBOM attestation so each release image carries an in-toto SLSA provenance + SBOM pair, + # verifiable via `docker buildx imagetools inspect`. + provenance: true + sbom: true + cache-from: type=gha + cache-to: type=gha,mode=max + + # ──── Boot Smoke (amd64 + arm64) ────────────────────────────────── + # The docker job proves the image BUILDS for both architectures; this proves the just-pushed image + # actually BOOTS and serves the liveness endpoint on both. A boot regression that only surfaces at + # runtime on one architecture (e.g. a native-dep/Chromium SIGTRAP on arm64 that a clean build still + # produces) cannot reach a public Release. Runs the published image — not a rebuild — against the + # dependency-free /api/health/live (NOT /ready, which needs a DB and would conflate boot failure + # with database setup). Both architectures are tried even if the first fails, so logs name both. + boot-smoke: + name: Boot smoke (amd64 + arm64) + runs-on: ubuntu-latest + needs: [docker] + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve published image ref + id: image + # metadata-action publishes the {{version}} tag (e.g. 0.8.17) from the v-prefixed git tag, so + # strip the leading "v" to match it. ${REPO,,} lowercases github.repository — GHCR normalizes + # the repo name to lowercase on push, and `docker run` rejects a mixed-case reference, so the + # pull target must be lowercase too (this repo is rmyndharis/OpenWA). + env: + REPO: ${{ github.repository }} + run: echo "ref=ghcr.io/${REPO,,}:${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" + + - name: Boot + liveness check on each architecture + env: + IMAGE: ${{ steps.image.outputs.ref }} + run: | + exit_code=0 + for platform in linux/amd64 linux/arm64; do + echo "::group::boot-smoke $platform" + docker rm -f openwa-smoke >/dev/null 2>&1 || true + if ! docker run --rm --platform "$platform" -d -p 2785:2785 --name openwa-smoke "$IMAGE" >/dev/null; then + echo "FAIL: $platform — could not start container (image pull/run error)" + exit_code=1 + echo "::endgroup::" + continue + fi + ok="" + for i in $(seq 1 40); do + if curl -sf http://127.0.0.1:2785/api/health/live >/dev/null 2>&1; then ok=1; break; fi + sleep 5 + done + if [ -n "$ok" ]; then + echo "OK: $platform — /api/health/live returned 200" + else + echo "FAIL: $platform — /api/health/live did not return 200 within ~200s; container logs follow:" + docker logs openwa-smoke 2>&1 | tail -60 || true + exit_code=1 + fi + docker stop openwa-smoke >/dev/null 2>&1 || true + docker rm -f openwa-smoke >/dev/null 2>&1 || true + echo "::endgroup::" + done + exit "$exit_code" diff --git a/.github/workflows/sdk-ci.yml b/.github/workflows/sdk-ci.yml new file mode 100644 index 0000000..c364e17 --- /dev/null +++ b/.github/workflows/sdk-ci.yml @@ -0,0 +1,75 @@ +name: SDK CI + +# Runs the JavaScript/Python/PHP client SDK suites. Path-filtered to SDK sources AND the server +# contract surfaces the SDK types mirror (DTOs + the engine interface), so a backend DTO change also +# re-runs the SDK suites and surfaces contract drift. The JavaScript job also builds and runs the +# dual-format packaging smoke test (require() + import() both consumable). +on: + push: + branches: [main, develop] + paths: ['sdk/**', '.github/workflows/sdk-ci.yml', 'src/**/dto/**', 'src/**/*.controller.ts', 'src/**/*.service.ts', 'src/engine/interfaces/whatsapp-engine.interface.ts'] + pull_request: + branches: [main, develop] + paths: ['sdk/**', '.github/workflows/sdk-ci.yml', 'src/**/dto/**', 'src/**/*.controller.ts', 'src/**/*.service.ts', 'src/engine/interfaces/whatsapp-engine.interface.ts'] + +jobs: + javascript: + name: JavaScript SDK + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/javascript + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: sdk/javascript/package-lock.json + - run: npm ci + - run: npm test + - run: npm run build + - run: npm run smoke # dual CJS+ESM consumable — must run after build + + python: + name: Python SDK + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/python + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: pip install -e '.[dev]' + - run: pytest + + php: + name: PHP SDK + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/php + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - run: composer install --no-interaction --no-progress + - run: ./vendor/bin/phpunit + + java: + name: Java SDK + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/java + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - run: mvn -B verify diff --git a/.github/workflows/split-php-sdk.yml b/.github/workflows/split-php-sdk.yml new file mode 100644 index 0000000..01e03f3 --- /dev/null +++ b/.github/workflows/split-php-sdk.yml @@ -0,0 +1,73 @@ +name: Split PHP SDK + +# Mirrors the PHP SDK (sdk/php) into the standalone repo rmyndharis/openwa-php, +# which is what Packagist installs (Packagist needs composer.json at a repo root +# and does not support subdirectories). Uses a dependency-free `git subtree +# split` + force-push — no third-party action. +# +# IMPORTANT: this only syncs the `main` branch (-> Packagist `dev-main`). The +# monorepo's `v*` tags are the APP version, NOT the SDK version, so they are +# deliberately NOT propagated. To cut a versioned SDK release, tag the mirror +# repo directly (see sdk/php — RELEASING below), e.g. `git tag 0.1.0`. +# +# One-time setup: +# 1. Create an empty GitHub repo: rmyndharis/openwa-php +# 2. Create a PAT (fine-grained, repo = openwa-php, Contents: read & write) +# 3. Add it to THIS repo as the secret PHP_SDK_SPLIT_TOKEN + +on: + push: + branches: [main] + paths: ['sdk/php/**'] + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: split-php-sdk + cancel-in-progress: false + +jobs: + test: + name: PHP SDK tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/php + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - run: composer install --no-interaction --no-progress + - run: ./vendor/bin/phpunit + + split: + name: Mirror sdk/php -> rmyndharis/openwa-php + # Never mirror an untested (red) SDK to Packagist dev-main. + needs: [test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # full history is required for `git subtree split` + persist-credentials: false # stop the GITHUB_TOKEN extraheader from shadowing the PAT in the push URL + + - name: Split and force-push to the mirror repo + env: + SPLIT_TOKEN: ${{ secrets.PHP_SDK_SPLIT_TOKEN }} + run: | + set -euo pipefail + if [ -z "${SPLIT_TOKEN:-}" ]; then + echo "::notice::PHP_SDK_SPLIT_TOKEN is not set — skipping. Create rmyndharis/openwa-php and add the secret to enable the mirror." + exit 0 + fi + git config user.name "rmyndharis" + git config user.email "yudhi@rmyndharis.com" + SPLIT_SHA="$(git subtree split --prefix=sdk/php HEAD)" + echo "Split SHA: $SPLIT_SHA" + git push --force \ + "https://x-access-token:${SPLIT_TOKEN}@github.com/rmyndharis/openwa-php.git" \ + "${SPLIT_SHA}:refs/heads/main" + echo "Mirrored sdk/php -> rmyndharis/openwa-php@main" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d815c7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# Dependencies +node_modules/ +node_modules +.pnp +.pnp.js + +# Build output +dist/ +build/ +.next/ +out/ + +# Testing +coverage/ + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +logs/ +*.log + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*.sublime-workspace +*.sublime-project + +# Data and storage +data/ +media/ +!src/**/media/ +uploads/ +.wwebjs_auth/ +.wwebjs_cache/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Docker +.docker/ + +# Misc +*.bak +*.tmp +*.temp + +# Git worktrees +.worktrees/ + +# AI Agents +.playwright-mcp/ +.remember/ +.agent/ +.claude/ +docs/plans/ +docs/superpowers/ +CLAUDE.md +_docs/ +.superpowers/ \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1a3466e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "auto" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0cb135f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2336 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + + +## [0.8.17] - 2026-07-13 + +### Added + +- **`AuditAction` emit-coverage gate.** A structural test now fails the build when an `AuditAction` + enum value is neither emitted at a real call site nor registered (with a reason) in a new + intentionally-unemitted registry. A declared audit event can no longer silently exist with no + emission site, and a new action cannot land without either wiring it or documenting why it is held + back. The registry is also checked for stale entries (an action that is in fact emitted) and empty + reasons, so it cannot decay into a dumping ground. + +- **Operator-tunable HTTP server timeouts.** The gateway now pins `requestTimeout`, + `headersTimeout`, and `keepAliveTimeout` on its HTTP server explicitly (previously Node's + implicit defaults), exposed via `REQUEST_TIMEOUT_MS` / `HEADERS_TIMEOUT_MS` / + `KEEPALIVE_TIMEOUT_MS` and logged at boot. Defaults match Node 22 (300s / 65s / 5s); + `headersTimeout` is normalized to stay above `keepAliveTimeout` (Node requires it), and the three + are validated as positive integers at boot. + +- **Committed OpenAPI snapshot + CI sync gate.** The gateway's OpenAPI document is now committed as + `openapi.json` (generated from the NestJS Swagger decorators via `npm run openapi:export`) and a CI + check (`npm run openapi:check`) fails when a controller/DTO change lands without regenerating it, so + the machine-readable contract can never silently drift from the code. SDK/API consumers now have a + versioned artifact at the repo root. + +- **Pre-release boot smoke on amd64 + arm64.** Cutting a release tag now runs the just-published + image on both `linux/amd64` and `linux/arm64` (via QEMU) and polls the dependency-free + `/api/health/live` endpoint before the GitHub Release is created — so a runtime-only boot regression + on one architecture (a clean build can still produce a native-dep/Chromium SIGTRAP on arm64) cannot + ship under a release. The Release job waits on the new boot-smoke job. + +- **SBOM attestation on published images.** Each image built by CI and on release now carries an + in-toto SBOM attestation alongside the SLSA provenance that `docker/build-push-action` already + generates by default. Both are verifiable with + `docker buildx imagetools inspect ghcr.io/rmyndharis/openwa:`. Provenance is now also pinned + explicitly (`provenance: true`) so the attestation pair is self-documenting rather than reliant on + the action default. + +- **HTTP RED metrics.** The `/api/metrics` endpoint now exposes + `http_requests_total{method,route,status}` and an `http_request_duration_seconds` histogram (per + route), recorded by a global interceptor. Route labels use the Express route pattern (bounded — + `/api/sessions/:id`, not the raw URL) with a `Controller#handler` fallback, and `/api/health` + + `/api/metrics` are not counted. Conventional unprefixed names so a generic RED dashboard or a 5xx + error-rate alert matches them directly. + +- **Request correlation ids (`X-Request-ID`).** Every inbound request now carries an id that + propagates through the whole request via AsyncLocalStorage, so each JSON log line and each + audit-log metadata blob stamps it — a request can be traced end-to-end. A valid client-supplied + `X-Request-ID` (alphanumeric + dash, ≤128 chars) is echoed; anything else (including a CRLF + header-injection attempt) is replaced with a generated UUID. The id is also set on the response. + +- **Engine capability matrix + drift gate.** A committed, source-verified matrix + (`src/engine/engine-capability-matrix.ts`) records, for every `IWhatsAppEngine` method on each + engine (whatsapp-web.js default, Baileys), whether it is `supported` or `not-available` — and for + the not-available ones, the root cause: `adapter-gap` (the underlying library supports it, OpenWA + just hasn't wired it — fixable) vs `library-limitation` (no first-class library API), with the + cited library symbol as evidence. A drift gate fails when a method's throw-availability changes. + `docs/engine-capability-matrix.md` inventories the unwired adapter-gaps as a prioritized capability + backlog. + +- **Delete-for-me on the Baileys engine.** `deleteMessage(…, forEveryone=false)` now performs a + delete-for-me via Baileys' `chatModify({ deleteForMe })` instead of returning 501. Revoke-for- + everyone (`forEveryone=true`) was already wired; this completes `deleteMessage` on the Baileys + engine for the most common delete mode. + +- **Status posts on the whatsapp-web.js engine.** `postTextStatus`, `postImageStatus`, and + `postVideoStatus` now work on whatsapp-web.js (the default engine) — they route through + `sendMessage('status@broadcast', …)` (text styling via `extra: { backgroundColor, fontStyle }`; + media via `MessageMedia` + `caption`). Previously these returned 501 on the default engine despite + the library supporting them; the stale "blocked upstream, #455" guard is removed (#455 is a closed + feature request, and whatsapp-web.js 1.34.7 ships a real status-send path). Caveat: the library has + no status-recipient arg, so `StatusPostOptions.recipients` is not honored on this engine (it + broadcasts to the account's status-privacy audience; a one-time warning is logged). The Baileys + engine continues to honor `recipients`. + +- **Chat labels on the Baileys engine.** `addLabelToChat` and `removeLabelFromChat` now work on + the Baileys engine — 1:1 to `sock.addChatLabel(chatId, labelId)` / `sock.removeChatLabel(chatId, + labelId)` instead of returning 501. WhatsApp-Business-only (rejects on personal accounts). Label + *listing* (`getLabels` / `getLabelById` / `getChatLabels`) remains unavailable on Baileys (no + first-class library API — see `docs/engine-capability-matrix.md`). + +- **Status delete on the whatsapp-web.js engine.** `deleteStatus(statusId)` now works on + whatsapp-web.js via `client.revokeStatusMessage(statusId)` instead of returning 501 — completing + the status lifecycle (post + delete) on the default engine. Own-status only (the library revokes + the caller's own status posts). + +- **Read contact stories on the whatsapp-web.js engine.** `getContactStatuses()` and + `getContactStatus(contactId)` now return contact "stories" (24h status posts) via + whatsapp-web.js `getBroadcasts()` / `getBroadcastById()` flattened to `Status[]` (contact via + `broadcast.getContact()`, type from `MessageTypes`, 24h TTL) instead of stubbing to `[]`. The + Baileys engine still cannot read stories — `fetchStatus` returns the *about* text, not stories + (documented as a library limitation). + +- **Channel lookup / subscribe / unsubscribe on the Baileys engine.** `getChannelById(id)`, + `subscribeToChannel(inviteCode)`, and `unsubscribeFromChannel(id)` now work via Baileys + `newsletterMetadata` (mapped to `Channel` with optional fields), `newsletterFollow` (subscribe, + resolving invite→jid first), and `newsletterUnfollow` (unsubscribe, 1:1). `getChannelById` on + Baileys resolves ANY channel by jid (richer than the whatsapp-web.js subscribed-list lookup). + `getChannelMessages` remains unsupported — `newsletterFetchMessages` returns a raw BinaryNode with + no library parser, so it stays a documented gap rather than an unverified walk. + +- **Bounded webhook fan-out.** An event matching N webhooks now delivers at most + `WEBHOOK_DISPATCH_CONCURRENCY` (default 16) concurrently instead of opening N outbound sockets at + once; the rest queue and run as slots free. Per-webhook isolation (`Promise.allSettled`) is + unchanged. The shared `ConcurrencyLimiter` was also promoted from `engine/adapters` to + `common/utils` (it has no engine-specific logic), so the webhook module no longer imports across the + engine boundary. + +- **Optional Redis-backed rate-limit storage.** When `REDIS_ENABLED=true`, API rate-limit counters + now persist to Redis (a new `RedisThrottlerStorage` implementing @nestjs/throttler v6's + `ThrottlerStorage`), so limits aggregate across replicas behind a load balancer instead of being + per-process. Default off (single-node deployments gain nothing and it adds a connection dependency). + Fail-OPEN on Redis error — rate limiting is a secondary control, and fail-closed would self-DoS the + API. + +### Fixed + +- **Silent delivery failure for 1:1 Baileys sends to LID-migrated contacts (ack 463).** On the + Baileys engine, a 1:1 send addressed by phone (`@c.us` / `@s.whatsapp.net`) to a + contact WhatsApp has migrated to LID addressing was rejected server-side with ack error 463 + (`NackCallerReachoutTimelocked` / "missing tctoken" — the privacy token is stored and honored + under the LID), while the same send addressed to the contact's `@lid` delivered. Because + Baileys generates the message id locally, the API still returned a `messageId`, so the + non-delivery was silent to the caller. The adapter now resolves phone-dialect 1:1 chat ids to + the contact's LID at the send boundary via `sock.signalRepository.lidMapping.getLIDForPN` (the + same mapping the Baileys send path consults), applied in `sendTextMessage`, `sendContent` (all + media/location/contact/poll sends), and `sendChatState`. Groups, broadcast, already-`@lid`, and + unmapped ids pass through unchanged (non-migrated contacts behave identically), and resolution + is best-effort: any lookup error falls back to the phone jid. The disappearing-timer lookup + still resolves under the LID, since `getEphemeralExpiration` already keys on the raw, engine, + and neutral forms of the jid. Thanks @isaacmendes. [#717] + +- **Diagnosable failure for a stale browser profile after a binary-changing upgrade.** Upgrading + across the v0.8.12 amd64 browser-binary switch (Debian Chromium → Chrome for Testing, #663) — or any + later change to the Chromium/Chrome binary — can leave an already-authenticated `whatsapp-web.js` + session's persistent browser profile incompatible with the new binary: on the next start the page + context is destroyed during injection and the engine fails with Puppeteer's opaque + `Execution context was destroyed`, which reads like a Puppeteer bug and gave no hint that the stale + profile was the cause. The `whatsapp-web.js` adapter now detects that error in its `initialize()` + catch and logs an advisory pointing the operator at the remedy (delete the session profile dir and + re-scan); the error still propagates unchanged, so existing failure handling is unaffected. The + profile is not auto-recovered — a tainted profile is not safely portable across Chromium major + versions (clearing only the cache subdirs is insufficient), so a one-time re-authentication is + required. [#708] + +- **OpenAPI export script under current env validation.** `scripts/export-openapi.ts` had been broken + since the SQLite `DATABASE_NAME` file-path validation tightened (it pinned an in-memory data DB, + which that rule rejects). The data connection now uses a temp-dir SQLite file that is removed on + exit, so the snapshot generator runs hermetically again. + + +## [0.8.16] - 2026-07-12 + +### Added + +- **Integration SDK v1 `response` contract for inbound routes.** A route may now declare a host-side + `preflight` (today: `session-alive`, returning 503 for a definitively-dead WhatsApp session) and a + declarative `ack` (status/body/headers) returned synchronously to the provider. The plugin ALWAYS runs + async (enqueued, full DLQ/retry); for routes declaring `response`, the ack is returned without awaiting + enqueue so a queue-disabled deployment cannot block the provider's deadline. A dead session (no live + engine or `FAILED`) on a concrete-scoped route now fails fast with 503 instead of being swallowed into + 202; recoverable statuses still 202+enqueue and let the worker fail fast. The inert `mode: 'sync-reply'` + value is deprecated in favor of `response` (kept for SDK v1 additive-only compatibility). Routes with no + `response` are byte-identical to today's default fast-ack. + +- **`standard-webhooks` ingress signature scheme.** A route may now declare + `signature.scheme: "standard-webhooks"` to verify [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) + payloads host-side (Supabase Auth's Send SMS hook, and any Svix-routed provider). The wire format is + fixed by the spec, so only `toleranceSec` (default 300s) and `dedupHeader` apply. The operator pastes + the provider's Svix secret (`v1,whsec_`) as the instance secret. This surfaces a bad signature + as a synchronous 401 and — because the `session-alive` preflight runs after verify — makes that preflight + safe to use (an unauthenticated caller can no longer probe liveness). Additive; existing + `hmac-sha256`/`shared-secret`/`none` behavior is unchanged. + +## [0.8.15] - 2026-07-11 + +- **WhatsApp Web sessions no longer wedge silently in `INITIALIZING` forever.** `engine.initialize()` was awaited with no timeout, and neither whatsapp-web.js nor Puppeteer bounds the initial browser launch/navigation (`page.goto` is called with `timeout: 0` and the web-version-cache fetch carries no timeout). If Chromium stalled under container memory pressure — realistic at the documented 2 GB Standard profile — the await never resolved or rejected: the session sat in `INITIALIZING` indefinitely, `GET /sessions/:id/qr` 400'd forever, and nothing was logged. The #635 abandoned-engine reaper is reactive (terminal `onError` / rejected re-init only), so nothing recovered it. `initializeEngine()` now races `engine.initialize()` against a deadline derived from the configured auth wait (floor 60 s, or `WWEBJS_AUTH_TIMEOUT_MS` + 30 s when an operator has raised that for slow first boots) so a legitimate slow init is never cut short; on timeout it force-kills the wedged browser, marks the session `DISCONNECTED` (retryable, so the existing reconnect backoff picks it up), and rethrows — surfacing the failure to a manual `POST /start` caller instead of hanging. The race's catch is scoped to the timeout only (`EngineInitTimeoutError`): a real init rejection (e.g. Chromium can't launch) propagates untouched so `start()`'s existing `FAILED`+reason diagnostics are preserved — handling both in one catch would downgrade real failures to `DISCONNECTED` and hide their reason. Thanks @INAPA-desarrolloTIC. [#667] + +- **Dashboard primary buttons were invisible until hover in light mode.** A leftover Vite template rule — `:root:not([data-theme='dark']) button { background-color:#f9f9f9 }` inside `@media (prefers-color-scheme: light)` — carried specificity `(0,2,1)` (`:root` + the `:not([data-theme])` argument + `button`), higher than every page-scoped `.X-page .btn-primary` rule `(0,2,0)`. In light mode (the default) it overrode the green design-system background with a near-white `#f9f9f9`, leaving the buttons' white text invisible until the leftover Vite `button:hover { border-color:#646cff }` ring revealed them on hover; the same `(0,2,1)` rule also pushed `.btn-secondary` and `.btn-icon` off their intended backgrounds. Removed that rule (Create-session and other primary buttons are green again), and cleared the remaining Vite scaffolding brand-colors from `index.css` (`a { color:#646cff }`, the purple `a:hover`, `button { background-color:#1a1a1a }`, `button:hover { border-color:#646cff }`) that competed with the App.css design system on import-order tiebreaks; the structural button CSS (border-radius, padding, font, cursor) is retained. Plain ` + {isOpen && ( +
+ {options.map((option, index) => ( + + ))} +
+ )} + + ); +} diff --git a/dashboard/src/components/DashboardCharts.css b/dashboard/src/components/DashboardCharts.css new file mode 100644 index 0000000..b178a03 --- /dev/null +++ b/dashboard/src/components/DashboardCharts.css @@ -0,0 +1,103 @@ +/* Dashboard analytics — all rules scoped under .dashboard-charts to avoid global collisions. */ +.dashboard-charts { + margin-bottom: 2rem; +} + +.dashboard-charts .charts-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.dashboard-charts .charts-title { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-primary); +} + +.dashboard-charts .charts-title h2 { + font-size: 1.125rem; + font-weight: 700; + margin: 0; +} + +/* Segmented pill period selector (matches the plugin/install tab control). */ +.dashboard-charts .period-toggle { + display: inline-flex; + gap: 2px; + padding: 3px; + background: var(--bg-light); + border: 1px solid var(--border); + border-radius: 10px; +} + +.dashboard-charts .period-tab { + padding: 0.35rem 0.85rem; + background: none; + border: none; + border-radius: 7px; + color: var(--text-secondary); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.dashboard-charts .period-tab.active { + background: var(--bg-white); + color: var(--text-primary); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.dashboard-charts .charts-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.dashboard-charts .chart-card { + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.25rem; + min-width: 0; /* let recharts ResponsiveContainer shrink inside the grid track */ +} + +.dashboard-charts .chart-wide { + grid-column: 1 / -1; +} + +.dashboard-charts .chart-card h3 { + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 1rem; +} + +.dashboard-charts .charts-empty { + background: var(--bg-white); + border: 1px dashed var(--border); + border-radius: 12px; + padding: 2.5rem 1rem; + text-align: center; + color: var(--text-muted); + font-size: 0.875rem; +} + +.dashboard-charts .charts-empty.small { + border: none; + padding: 2rem 1rem; +} + +@media (max-width: 768px) { + .dashboard-charts .charts-grid { + grid-template-columns: 1fr; + } + .dashboard-charts .chart-wide { + grid-column: auto; + } +} diff --git a/dashboard/src/components/DashboardCharts.tsx b/dashboard/src/components/DashboardCharts.tsx new file mode 100644 index 0000000..9a25689 --- /dev/null +++ b/dashboard/src/components/DashboardCharts.tsx @@ -0,0 +1,194 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ResponsiveContainer, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + PieChart, + Pie, + Cell, + BarChart, + Bar, +} from 'recharts'; +import { BarChart3 } from 'lucide-react'; +import { useStatsMessagesQuery } from '../hooks/queries'; +import type { StatsPeriod } from '../services/api'; +import './DashboardCharts.css'; + +const PERIODS: StatsPeriod[] = ['24h', '7d', '30d']; + +// Stable, distinct color per message type (recharts needs literal colors). Keyed by type name — +// not array index — so two types can never share a color, and a slice keeps its color even when the +// set of present types changes between requests. Covers every type mapMessageType() can emit. +const TYPE_COLORS: Record = { + text: '#25d366', + image: '#3b82f6', + contact: '#a855f7', + document: '#f59e0b', + audio: '#06b6d4', + voice: '#ec4899', + video: '#14b8a6', + sticker: '#ef4444', + location: '#84cc16', + poll: '#6366f1', + revoked: '#f43f5e', + masked: '#8b5cf6', + unknown: '#64748b', +}; + +// Deterministic fallback for any unmapped type, so its color is stable across renders. +const FALLBACK_COLORS = ['#0ea5e9', '#d946ef', '#f97316', '#10b981', '#6366f1', '#eab308']; +function colorForType(name: string): string { + if (TYPE_COLORS[name]) return TYPE_COLORS[name]; + let hash = 0; + for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0; + return FALLBACK_COLORS[Math.abs(hash) % FALLBACK_COLORS.length]; +} + +// '2026-06-24 14:00:00' (hour buckets) → '14:00'; '2026-06-24' (day buckets) → '06-24'. +function formatTick(ts: string, period: StatsPeriod): string { + return period === '24h' ? ts.slice(11, 16) : ts.slice(5); +} + +// WhatsApp ids look like '62812...@c.us' / '...@g.us' / '...@lid' — show just the local part. +function shortChat(chatId: string): string { + return chatId.split('@')[0] || chatId; +} + +export function DashboardCharts() { + const { t } = useTranslation(); + const [period, setPeriod] = useState('7d'); + const { data, isLoading, isError, error } = useStatsMessagesQuery(period); + + // Non-admin keys 403 on /stats/messages → hide the section entirely. Any OTHER error (e.g. a + // server 500) is a real fault: surface a small notice below instead of silently vanishing, which + // is what masked the #488 stats crash and made the whole chart "disappear" with no explanation. + const forbidden = (error as (Error & { status?: number }) | null)?.status === 403; + if (isError && forbidden) return null; + + const timeSeries = (data?.timeSeries ?? []).map(p => ({ ...p, label: formatTick(p.timestamp, period) })); + const byType = Object.entries(data?.byType ?? {}) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); + const topChats = (data?.topChats ?? []).slice(0, 8).map(c => ({ name: c.chatName || shortChat(c.chatId), count: c.messageCount })); + const hasData = timeSeries.length > 0 || byType.length > 0 || topChats.length > 0; + + return ( +
+
+
+ +

{t('dashboard.charts.title')}

+
+
+ {PERIODS.map(p => ( + + ))} +
+
+ + {isLoading ? ( +
{t('common.loading')}
+ ) : isError ? ( +
{t('dashboard.charts.error')}
+ ) : !hasData ? ( +
{t('dashboard.charts.empty')}
+ ) : ( +
+
+

{t('dashboard.charts.overTime')}

+ + + + + + + + + + + + + + + + + + + + + +
+ +
+

{t('dashboard.charts.byType')}

+ {byType.length === 0 ? ( +
{t('dashboard.charts.empty')}
+ ) : ( + + + + {byType.map(entry => ( + + ))} + + + + + + )} +
+ +
+

{t('dashboard.charts.topChats')}

+ {topChats.length === 0 ? ( +
{t('dashboard.charts.empty')}
+ ) : ( + + + + + + + + + + )} +
+
+ )} +
+ ); +} diff --git a/dashboard/src/components/ErrorBoundary.tsx b/dashboard/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..3bf9e7e --- /dev/null +++ b/dashboard/src/components/ErrorBoundary.tsx @@ -0,0 +1,63 @@ +import { Component, type ReactNode, type ErrorInfo } from 'react'; +import { AlertCircle, RefreshCw } from 'lucide-react'; +import i18n from '../i18n'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('[ErrorBoundary] Uncaught error:', error, errorInfo); + } + + handleReload = () => { + window.location.reload(); + }; + + render() { + if (this.state.hasError) { + return ( +
+ +

{i18n.t('errorBoundary.title')}

+

+ {i18n.t('errorBoundary.description')} +

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/dashboard/src/components/FilterBuilder.css b/dashboard/src/components/FilterBuilder.css new file mode 100644 index 0000000..ddc4185 --- /dev/null +++ b/dashboard/src/components/FilterBuilder.css @@ -0,0 +1,289 @@ +.filter-builder { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.filter-builder-head { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.filter-builder-title { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary, #64748b); +} + +.filter-builder-hint { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.filter-row { + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem; + background: var(--bg-light); + border: 1px solid var(--border); + border-radius: 8px; +} + +.filter-row select { + height: 2.25rem; + padding: 0 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-white); + color: var(--text-primary); + font-size: 0.8125rem; +} + +/* field + operator: fixed width; value flexes to fill the rest */ +.filter-row > select { + width: 100%; +} + +.filter-field { + flex: 0 0 8.5rem; +} + +.filter-operator { + flex: 0 0 7rem; +} + +.filter-value { + flex: 1 1 auto; + min-width: 0; +} + +.filter-remove { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + height: 2.25rem; + width: 2.25rem; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.filter-remove svg { + width: 16px; + height: 16px; + flex-shrink: 0; + stroke: var(--text-secondary); +} + +.filter-remove:hover { + border-color: var(--error); + color: var(--error); +} + +.filter-remove:hover svg { + stroke: var(--error); +} + +.filter-add { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.625rem; + border: 1px dashed var(--border); + border-radius: 6px; + background: transparent; + color: var(--primary); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; +} + +.filter-add:hover { + border-color: var(--primary); +} + +/* Text operator */ +.filter-text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; +} + +/* The global `.modal-body input` (full width + large padding + bg) leaks into the + case-sensitive checkbox; reset it (2-class selector beats `.modal-body input`). */ +.filter-case input { + width: auto; + margin: 0; + padding: 0; +} + +.filter-text input[type='text'] { + width: 100%; + height: 2.25rem; + padding: 0 0.625rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-white); + color: var(--text-primary); + font-size: 0.8125rem; +} + +.filter-case { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: var(--text-secondary); + cursor: pointer; +} + +/* Enum multi-select */ +.filter-enum { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.enum-tag { + font-size: 0.75rem; + padding: 0.3125rem 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-white); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.enum-tag:hover { + border-color: var(--primary); + color: var(--primary); +} + +.enum-tag.selected { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +/* Chips input with autocomplete */ +.chips-input { + position: relative; +} + +.chips-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + min-height: 2.25rem; + padding: 0.25rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-white); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.375rem; + background: rgba(37, 211, 102, 0.15); + border: 1px solid rgba(37, 211, 102, 0.25); + color: var(--primary); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; + max-width: 100%; +} + +.chip-remove { + display: inline-flex; + align-items: center; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + padding: 0; +} + +/* `.chips-row` prefix raises specificity above the global `.modal-body input`, which + would otherwise force width:100% (dropping the field onto its own line) + bg + padding. */ +.chips-row .chips-text { + flex: 1 1 6rem; + min-width: 6rem; + width: auto; + height: 26px; + padding: 2px 8px; + border: none; + outline: none; + background: var(--bg-light); + color: var(--text-primary); + font-size: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; +} + +/* keep the yes/no select compact rather than filling the value column */ +.filter-bool { + width: auto; +} + +.chips-suggestions { + position: absolute; + z-index: 20; + top: calc(100% + 2px); + left: 0; + right: 0; + max-height: 14rem; + overflow-y: auto; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); +} + +.chips-suggestion { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: 0.5rem 0.625rem; + border: none; + background: transparent; + cursor: pointer; + text-align: left; +} + +.chips-suggestion:hover { + background: var(--bg-light); +} + +.chips-suggestion-name { + font-size: 0.8125rem; + color: var(--text-primary); +} + +.chips-suggestion-id { + font-size: 0.6875rem; + color: var(--text-secondary); + font-family: 'JetBrains Mono', monospace; +} + +.chips-suggestion-add { + color: var(--primary); + font-weight: 600; + font-size: 0.8125rem; + border-top: 1px solid var(--border); +} diff --git a/dashboard/src/components/FilterBuilder.tsx b/dashboard/src/components/FilterBuilder.tsx new file mode 100644 index 0000000..51f2529 --- /dev/null +++ b/dashboard/src/components/FilterBuilder.tsx @@ -0,0 +1,292 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus, X } from 'lucide-react'; +import { + type Chat, + type WebhookFilters, + type WebhookFilterCondition, + type WebhookFilterOperator, +} from '../services/api'; +import './FilterBuilder.css'; + +type FieldKind = 'id' | 'idArray' | 'text' | 'enum' | 'boolean'; + +interface FieldDescriptor { + field: string; + kind: FieldKind; + operators: WebhookFilterOperator[]; + enumValues?: string[]; +} + +const MESSAGE_TYPES = [ + 'text', + 'image', + 'video', + 'audio', + 'voice', + 'document', + 'sticker', + 'location', + 'contact', + 'call', + 'revoked', + 'masked', + 'unknown', +]; + +// Mirrors the backend message-family field registry (src/modules/webhook/filters/filter-types.ts). +const MESSAGE_FIELDS: FieldDescriptor[] = [ + { field: 'sender', kind: 'id', operators: ['is', 'isNot'] }, + { field: 'recipient', kind: 'id', operators: ['is', 'isNot'] }, + { field: 'body', kind: 'text', operators: ['contains', 'equals'] }, + { field: 'type', kind: 'enum', operators: ['is', 'isNot'], enumValues: MESSAGE_TYPES }, + { field: 'isGroup', kind: 'boolean', operators: ['is'] }, + { field: 'fromMe', kind: 'boolean', operators: ['is'] }, + { field: 'hasMedia', kind: 'boolean', operators: ['is'] }, + { field: 'mentions', kind: 'idArray', operators: ['is', 'isNot'] }, +]; + +const descriptorFor = (field: string): FieldDescriptor => + MESSAGE_FIELDS.find(f => f.field === field) ?? MESSAGE_FIELDS[0]; + +function defaultValueFor(kind: FieldKind): WebhookFilterCondition['value'] { + if (kind === 'boolean') return true; + if (kind === 'text') return ''; + return []; +} + +/** Accepts a full JID or a phone number; normalizes bare numbers to `@c.us`. */ +function normalizeToJid(raw: string): string | null { + const value = raw.trim(); + if (!value) return null; + if (value.includes('@')) return value.toLowerCase(); + const digits = value.replace(/[^0-9]/g, ''); + return digits ? `${digits}@c.us` : null; +} + +interface ContactChipsInputProps { + value: string[]; + onChange: (next: string[]) => void; + chats: Chat[]; +} + +function ContactChipsInput({ value, onChange, chats }: ContactChipsInputProps) { + const { t } = useTranslation(); + const [text, setText] = useState(''); + const [open, setOpen] = useState(false); + + const suggestions = useMemo(() => { + const query = text.trim().toLowerCase(); + const chosen = new Set(value); + return chats + .filter(c => !chosen.has(c.id)) + .filter(c => !query || c.name.toLowerCase().includes(query) || c.id.toLowerCase().includes(query)) + .slice(0, 8); + }, [text, chats, value]); + + const labelFor = (jid: string) => chats.find(c => c.id === jid)?.name ?? jid; + const add = (jid: string) => { + if (jid && !value.includes(jid)) onChange([...value, jid]); + setText(''); + }; + const addTyped = () => { + const jid = normalizeToJid(text); + if (jid) add(jid); + }; + + return ( +
+
+ {value.map(jid => ( + + {labelFor(jid)} + + + ))} + setText(e.target.value)} + onFocus={() => setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 120)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + addTyped(); + } else if (e.key === 'Backspace' && !text && value.length) { + onChange(value.slice(0, -1)); + } + }} + /> +
+ {open && (suggestions.length > 0 || text.trim()) && ( +
+ {suggestions.map(c => ( + + ))} + {text.trim() && normalizeToJid(text) && ( + + )} +
+ )} +
+ ); +} + +interface FilterBuilderProps { + filters: WebhookFilters | null | undefined; + onChange: (filters: WebhookFilters | null) => void; + chats: Chat[]; +} + +export function FilterBuilder({ filters, onChange, chats }: FilterBuilderProps) { + const { t } = useTranslation(); + const conditions = filters?.conditions ?? []; + + const emit = (next: WebhookFilterCondition[]) => onChange(next.length ? { conditions: next } : null); + + const updateAt = (index: number, patch: Partial) => + emit(conditions.map((c, i) => (i === index ? { ...c, ...patch } : c))); + + const addCondition = () => { + const def = MESSAGE_FIELDS[0]; + emit([...conditions, { field: def.field, operator: def.operators[0], value: defaultValueFor(def.kind) }]); + }; + + const changeField = (index: number, field: string) => { + const def = descriptorFor(field); + updateAt(index, { field, operator: def.operators[0], value: defaultValueFor(def.kind), caseSensitive: undefined }); + }; + + return ( +
+
+ {t('webhooks.filters.title')} + {t('webhooks.filters.hint')} +
+ + {conditions.map((condition, index) => { + const def = descriptorFor(condition.field); + return ( +
+ + + + +
+ {(def.kind === 'id' || def.kind === 'idArray') && ( + updateAt(index, { value: next })} + chats={chats} + /> + )} + + {def.kind === 'enum' && ( +
+ {def.enumValues?.map(option => { + const selected = Array.isArray(condition.value) && (condition.value as string[]).includes(option); + return ( + + ); + })} +
+ )} + + {def.kind === 'text' && ( +
+ updateAt(index, { value: e.target.value })} + /> + +
+ )} + + {def.kind === 'boolean' && ( + + )} +
+ + +
+ ); + })} + + +
+ ); +} diff --git a/dashboard/src/components/GithubIcon.tsx b/dashboard/src/components/GithubIcon.tsx new file mode 100644 index 0000000..6d7b0d5 --- /dev/null +++ b/dashboard/src/components/GithubIcon.tsx @@ -0,0 +1,23 @@ +/** + * GitHub brand mark — inlined because lucide-react v1 removed all brand icons. + * Filled path + `fill="currentColor"` so it inherits color from CSS like the + * lucide icon it replaced. Decorative (`aria-hidden`); label the parent link. + */ +interface GithubIconProps { + size?: number; +} + +export function GithubIcon({ size = 24 }: GithubIconProps) { + return ( + + ); +} diff --git a/dashboard/src/components/GlobalSearch.css b/dashboard/src/components/GlobalSearch.css new file mode 100644 index 0000000..0721824 --- /dev/null +++ b/dashboard/src/components/GlobalSearch.css @@ -0,0 +1,125 @@ +.global-search { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; +} + +.global-search-input { + flex: 1 1 auto; + min-width: 12rem; + height: 2.25rem; + padding: 0 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius, 8px); + background: var(--bg-white); + color: var(--text-primary); + font-size: 0.875rem; +} + +.global-search-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-soft); +} + +.global-search-scope { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex: 0 0 auto; + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + user-select: none; +} + +.global-search-scope input { + margin: 0; +} + +.global-search-results { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + right: 0; + z-index: 20; + max-height: 24rem; + overflow-y: auto; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius, 8px); + box-shadow: var(--shadow-lg, var(--shadow-md)); +} + +.global-search-state { + padding: 0.75rem; + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.global-search-hit { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; + padding: 0.5rem 0.75rem; + background: transparent; + border: 0; + border-bottom: 1px solid var(--border); + text-align: left; + cursor: pointer; + font: inherit; + color: var(--text-primary); +} + +.global-search-hit:hover, +.global-search-hit:focus-visible { + background: var(--bg-light); + outline: none; +} + +.global-search-hit:last-child { + border-bottom: 0; +} + +.global-search-hit-meta { + font-size: 0.75rem; + color: var(--text-muted); + word-break: break-all; +} + +.global-search-hit-snippet { + font-size: 0.8125rem; + color: var(--text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.global-search-hit-snippet mark { + background: var(--warning, #fde68a); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +.global-search-more { + display: block; + width: 100%; + padding: 0.5rem; + background: var(--bg-light); + border: 0; + border-top: 1px solid var(--border); + color: var(--text-secondary); + font-size: 0.75rem; + cursor: pointer; +} + +.global-search-more:hover { + background: var(--primary-soft); + color: var(--primary); +} diff --git a/dashboard/src/components/GlobalSearch.tsx b/dashboard/src/components/GlobalSearch.tsx new file mode 100644 index 0000000..e31a7cd --- /dev/null +++ b/dashboard/src/components/GlobalSearch.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { searchApi, type SearchHit } from '../services/api'; +import { renderHighlightedSnippet, buildSearchParams } from '../utils/search-highlight'; +import { useTranslation } from 'react-i18next'; +import './GlobalSearch.css'; + +interface GlobalSearchProps { + /** Called when the user clicks a result — the parent navigates to that chat/message. */ + onHit: (hit: SearchHit) => void; + /** When set, the scope toggle defaults to this session (optional). */ + currentSessionId?: string; +} + +const DEBOUNCE_MS = 300; +const PAGE_SIZE = 20; + +export function GlobalSearch({ onHit, currentSessionId }: GlobalSearchProps) { + const { t } = useTranslation(); + const [q, setQ] = useState(''); + const [hits, setHits] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [scopeCurrent, setScopeCurrent] = useState(false); + const [open, setOpen] = useState(false); + const timer = useRef | null>(null); + const blurTimer = useRef | null>(null); + + const run = useCallback(async (query: string, offset: number, append: boolean) => { + const params = buildSearchParams(query, scopeCurrent && currentSessionId ? { sessionId: currentSessionId } : undefined, { limit: PAGE_SIZE, offset }); + if (!params) { setHits([]); setTotal(0); setError(null); setLoading(false); return; } + setLoading(true); setError(null); + try { + const res = await searchApi.search(params); + setHits(prev => append ? [...prev, ...res.hits] : res.hits); + setTotal(res.total); + } catch (e: unknown) { + const status = (e as { status?: number }).status; + if (status === 501) setError(t('search.unavailable')); + else if (status === 503) setError(t('search.error')); + else setError(t('search.error')); + setHits([]); setTotal(0); + } finally { + setLoading(false); + } + }, [scopeCurrent, currentSessionId, t]); + + // Debounce on input change. + useEffect(() => { + if (timer.current) clearTimeout(timer.current); + if (!q.trim()) { setHits([]); setTotal(0); setError(null); return; } + timer.current = setTimeout(() => { setOpen(true); void run(q, 0, false); }, DEBOUNCE_MS); + return () => { if (timer.current) clearTimeout(timer.current); }; + }, [q, run]); + + // Clear any pending blur timeout on unmount so it can't fire setState after teardown. + useEffect(() => { + return () => { if (blurTimer.current) clearTimeout(blurTimer.current); }; + }, []); + + const loadMore = () => void run(q, hits.length, true); + + return ( +
+ setQ(e.target.value)} + onFocus={() => q.trim() && setOpen(true)} + onBlur={() => { blurTimer.current = setTimeout(() => setOpen(false), 150); }} + aria-label={t('search.placeholder')} + /> + {currentSessionId && ( + + )} + {open && q.trim() && ( +
+ {loading &&
{t('search.loading')}
} + {!loading && error &&
{error}
} + {!loading && !error && hits.length === 0 &&
{t('search.empty')}
} + {!loading && !error && hits.map((h) => ( + + ))} + {!loading && !error && hits.length < total && ( + + )} +
+ )} +
+ ); +} diff --git a/dashboard/src/components/Layout.css b/dashboard/src/components/Layout.css new file mode 100644 index 0000000..0fdd729 --- /dev/null +++ b/dashboard/src/components/Layout.css @@ -0,0 +1,626 @@ +.layout { + display: flex; + min-height: 100vh; +} + +/* ==================== Sidebar ==================== */ +.sidebar { + width: 260px; + background: var(--bg-white); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + height: 100vh; + z-index: 100; + transition: + width 0.3s ease, + transform 0.3s ease; +} + +.sidebar.collapsed { + width: 72px; +} + +.sidebar-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border); + min-height: 72px; +} + +.sidebar.collapsed .sidebar-header { + justify-content: center; + padding: 1.5rem 1rem; +} + +.sidebar-logo { + width: 28px; + height: 28px; + object-fit: contain; + flex-shrink: 0; +} + +.mobile-brand .sidebar-logo { + width: 24px; + height: 24px; +} + +.sidebar-brand { + display: flex; + flex-direction: column; + overflow: hidden; + white-space: nowrap; +} + +.brand-name { + font-size: 1.125rem; + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +.brand-subtitle { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.brand-version { + font-size: 0.625rem; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text-muted); + opacity: 0.7; + margin-top: 1px; +} + +/* Collapse Toggle Button - Circular design at edge */ +.collapse-toggle { + position: absolute; + right: -14px; + top: 36px; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--bg-white); + border: 1px solid var(--border); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); + transition: all 0.2s; + z-index: 101; + padding: 0; +} + +.collapse-toggle:hover { + background: var(--primary); + border-color: var(--primary); + color: white; + box-shadow: 0 4px 12px rgba(37, 211, 102, 0.3); +} + +.collapse-toggle svg { + width: 16px; + height: 16px; +} + +.sidebar.collapsed .collapse-toggle { + right: -14px; +} + +.sidebar-nav { + flex: 1; + padding: 1rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + overflow-y: auto; +} + +.sidebar.collapsed .sidebar-nav { + padding: 1rem 0.5rem; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1rem; + color: var(--text-secondary); + text-decoration: none; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s; + white-space: nowrap; + overflow: hidden; +} + +.sidebar.collapsed .nav-item { + justify-content: center; + padding: 0.7rem; +} + +.nav-item:hover { + background: var(--bg-light); + color: var(--text-primary); + text-decoration: none; +} + +.nav-item.active { + background: var(--primary-soft); + color: var(--primary); +} + +.nav-item.active svg { + color: var(--primary); +} + +.sidebar-footer { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + border-top: 1px solid var(--border); +} + +.sidebar.collapsed .sidebar-footer { + padding: 0.75rem 0.5rem; +} + +.theme-toggle-btn { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.9rem; + color: var(--text-secondary); + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + overflow: hidden; +} + +.appearance-button-cue { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + border-radius: 999px; + background: radial-gradient(circle at 70% 25%, rgba(255, 255, 255, 0.8), transparent 24%), var(--swatch-color); + color: white; + box-shadow: + 0 0 0 2px var(--bg-white), + 0 0 0 3px color-mix(in srgb, var(--swatch-color) 35%, transparent); +} + +.appearance-button-cue svg { + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25)); +} + +.sidebar.collapsed .theme-toggle-btn, +.sidebar.collapsed .logout-btn { + justify-content: center; + padding: 0.6rem; +} + +.theme-toggle-btn:hover { + background: var(--bg-light); + color: var(--text-primary); +} + +.language-menu, +.appearance-menu { + position: relative; +} + +.language-menu .theme-toggle-btn, +.appearance-menu .theme-toggle-btn { + width: 100%; +} + +.language-menu-list { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 0.35rem); + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 160px; + padding: 0.3rem; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.16); + z-index: 120; +} + +.language-menu-item { + display: flex; + align-items: center; + width: 100%; + min-height: 34px; + padding: 0.45rem 0.6rem; + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 2px); + font-size: 0.875rem; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: all 0.2s; +} + +.language-menu-item:hover { + background: var(--bg-light); + color: var(--text-primary); +} + +.language-menu-item.active { + background: var(--primary-soft); + color: var(--primary); +} + +.sidebar.collapsed .language-menu-list { + right: auto; + left: calc(100% + 0.5rem); + bottom: 0; +} + +.appearance-menu-list { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 0.35rem); + display: flex; + flex-direction: column; + gap: 0.85rem; + max-width: 100%; + padding: 0.85rem; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18); + z-index: 120; +} + +.appearance-menu-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.2rem 0.1rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.appearance-menu-header strong { + display: block; + color: var(--text-primary); + font-size: 0.9375rem; + line-height: 1.2; +} + +.appearance-menu-header span { + display: block; + margin-top: 0.15rem; + color: var(--text-muted); + font-size: 0.75rem; +} + +.appearance-current-swatch { + width: 34px; + height: 34px; + flex-shrink: 0; + border-radius: 999px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.65), transparent 42%), var(--swatch-color); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.35), + 0 0 0 4px color-mix(in srgb, var(--swatch-color) 13%, transparent); +} + +.appearance-section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.appearance-section-label { + font-size: 0.6875rem; + font-weight: 800; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.appearance-mode-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.35rem; +} + +.appearance-mode { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + min-height: 58px; + padding: 0.45rem; + background: var(--bg-light); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.appearance-mode:hover { + color: var(--text-primary); + border-color: var(--primary); + background: var(--bg-white); +} + +.appearance-mode.active { + background: var(--primary-soft); + border-color: var(--primary); + color: var(--primary); + box-shadow: inset 0 -2px 0 var(--primary); +} + +.palette-grid { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.palette-swatch { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + flex: 0 0 auto; + padding: 0; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-light); +} + +.palette-swatch span { + width: 20px; + height: 20px; + border-radius: 999px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.6), transparent 45%), var(--swatch-color); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); +} + +.palette-swatch:hover, +.palette-swatch.active { + border-color: var(--swatch-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch-color) 18%, transparent); +} + +.palette-swatch.active::after { + position: absolute; + right: -1px; + bottom: -1px; + width: 10px; + height: 10px; + border: 2px solid var(--bg-white); + border-radius: 999px; + background: var(--primary); + content: ''; +} + +.sidebar.collapsed .appearance-menu-list { + right: auto; + left: calc(100% + 0.5rem); + bottom: 0; +} + +.logout-btn { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.9rem; + color: var(--text-secondary); + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + overflow: hidden; +} + +.logout-btn:hover { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.3); + color: var(--error); +} + +/* ==================== Main Content ==================== */ +.main-content { + flex: 1; + margin-left: 260px; + width: calc(100% - 260px); + background: var(--bg-light); + min-height: 100vh; + overflow-x: hidden; + transition: + margin-left 0.3s ease, + width 0.3s ease; +} + +.main-content.expanded { + margin-left: 72px; + width: calc(100% - 72px); +} + +/* ==================== Mobile Styles ==================== */ +.mobile-header { + display: none; +} + +.sidebar-overlay { + display: none; +} + +@media (max-width: 767px) { + .mobile-header { + display: flex; + align-items: center; + justify-content: space-between; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 56px; + padding: 0 1rem; + background: var(--bg-white); + border-bottom: 1px solid var(--border); + z-index: 90; + } + + .mobile-menu-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: transparent !important; + border: none; + color: var(--text-primary); + cursor: pointer; + border-radius: var(--radius); + transition: background 0.2s; + padding: 0; + } + + .mobile-menu-btn svg { + color: var(--text-primary); + stroke: var(--text-primary); + } + + .mobile-menu-btn:hover { + background: var(--bg-light) !important; + } + + .mobile-brand { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .mobile-brand .brand-name { + font-size: 1rem; + } + + .sidebar.mobile { + transform: translateX(-100%); + width: 280px; + box-shadow: 2px 0 20px rgba(0, 0, 0, 0.1); + } + + .sidebar.mobile.open { + transform: translateX(0); + } + + .sidebar-overlay { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 95; + animation: fadeIn 0.2s ease; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .main-content.mobile { + margin-left: 0; + width: 100%; + padding-top: 56px; + height: 100vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + + .collapse-toggle { + display: none; + } +} + +/* ==================== RTL support ==================== */ +[dir='rtl'] .sidebar { + border-right: none; + border-left: 1px solid var(--border); +} + +[dir='rtl'] .collapse-toggle { + right: auto; + left: -14px; +} + +[dir='rtl'] .collapse-toggle svg { + transform: scaleX(-1); +} + +[dir='rtl'] .language-menu-item { + text-align: right; +} + +[dir='rtl'] .sidebar.collapsed .language-menu-list { + right: calc(100% + 0.5rem); + left: auto; +} + +[dir='rtl'] .sidebar.collapsed .appearance-menu-list { + right: calc(100% + 0.5rem); + left: auto; +} + +[dir='rtl'] .main-content { + margin-left: 0; + margin-right: 260px; + transition: + margin-right 0.3s ease, + width 0.3s ease; +} + +[dir='rtl'] .main-content.expanded { + margin-right: 72px; +} + +@media (max-width: 768px) { + [dir='rtl'] .main-content, + [dir='rtl'] .main-content.mobile { + margin-right: 0; + } +} diff --git a/dashboard/src/components/Layout.tsx b/dashboard/src/components/Layout.tsx new file mode 100644 index 0000000..2217584 --- /dev/null +++ b/dashboard/src/components/Layout.tsx @@ -0,0 +1,344 @@ +import { useState, useEffect, useRef, type CSSProperties } from 'react'; +import { NavLink, Outlet } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + LayoutDashboard, + Smartphone, + MessageSquare, + Webhook, + Key, + FileText, + ClipboardList, + LogOut, + Send, + Server, + Puzzle, + Sun, + Moon, + Monitor, + Menu, + X, + ChevronLeft, + ChevronRight, + Languages, +} from 'lucide-react'; +import { useTheme } from '../hooks/useTheme'; +import { type UserRole } from '../hooks/useRole'; +import { languageOptions, resolveSupportedLanguage, rtlLanguages, type SupportedLanguage } from '../i18n'; +import { healthApi } from '../services/api'; +import './Layout.css'; + +interface LayoutProps { + onLogout: () => void; + userRole: UserRole | null; +} + +const allNavItems = [ + { to: '/', icon: LayoutDashboard, key: 'dashboard' as const, adminOnly: false }, + { to: '/sessions', icon: Smartphone, key: 'sessions' as const, adminOnly: false }, + { to: '/chats', icon: MessageSquare, key: 'chats' as const, adminOnly: false }, + { to: '/webhooks', icon: Webhook, key: 'webhooks' as const, adminOnly: false }, + { to: '/templates', icon: ClipboardList, key: 'templates' as const, adminOnly: false }, + { to: '/api-keys', icon: Key, key: 'apiKeys' as const, adminOnly: true }, + { to: '/message-tester', icon: Send, key: 'messageTester' as const, adminOnly: false }, + // Backend /infra/* is ADMIN-only; hide the nav item from non-admins (UX + defense-in-depth). + { to: '/infrastructure', icon: Server, key: 'infrastructure' as const, adminOnly: true }, + { to: '/plugins', icon: Puzzle, key: 'plugins' as const, adminOnly: true }, + { to: '/logs', icon: FileText, key: 'logs' as const, adminOnly: false }, +]; + +const themeIcons = { light: Sun, dark: Moon, system: Monitor }; + +export function Layout({ onLogout, userRole }: LayoutProps) { + const { t, i18n } = useTranslation(); + const { theme, setTheme, palette, setPalette, paletteOptions } = useTheme(); + const ThemeIcon = themeIcons[theme]; + const themeLabel = t(`theme.${theme}`); + const activePalette = paletteOptions.find(option => option.value === palette) ?? paletteOptions[0]; + + const navItems = allNavItems.filter(item => !item.adminOnly || userRole === 'admin'); + + const [isCollapsed, setIsCollapsed] = useState(false); + const [isMobileOpen, setIsMobileOpen] = useState(false); + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + // Show the build-time version immediately, then replace it with the live running version from the + // backend so a stale-built bundle can't display the wrong number. Falls back silently on error. + const [version, setVersion] = useState(__APP_VERSION__); + const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); + const [isAppearanceMenuOpen, setIsAppearanceMenuOpen] = useState(false); + const languageMenuRef = useRef(null); + const appearanceMenuRef = useRef(null); + + useEffect(() => { + const handleResize = () => { + const mobile = window.innerWidth < 768; + setIsMobile(mobile); + if (!mobile) setIsMobileOpen(false); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + let active = true; + healthApi + .check() + .then(info => { + if (active && info?.version) setVersion(info.version); + }) + .catch(() => { + /* keep the build-time fallback */ + }); + return () => { + active = false; + }; + }, []); + + const handleNavClick = () => { + if (isMobile) setIsMobileOpen(false); + }; + + useEffect(() => { + document.body.style.overflow = isMobileOpen ? 'hidden' : ''; + return () => { + document.body.style.overflow = ''; + }; + }, [isMobileOpen]); + + useEffect(() => { + if (!isLanguageMenuOpen) return; + + const closeOnOutsideClick = (event: MouseEvent) => { + if (!languageMenuRef.current?.contains(event.target as Node)) { + setIsLanguageMenuOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsLanguageMenuOpen(false); + }; + + document.addEventListener('mousedown', closeOnOutsideClick); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('mousedown', closeOnOutsideClick); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [isLanguageMenuOpen]); + + useEffect(() => { + if (!isAppearanceMenuOpen) return; + + const closeOnOutsideClick = (event: MouseEvent) => { + if (!appearanceMenuRef.current?.contains(event.target as Node)) { + setIsAppearanceMenuOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsAppearanceMenuOpen(false); + }; + + document.addEventListener('mousedown', closeOnOutsideClick); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('mousedown', closeOnOutsideClick); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [isAppearanceMenuOpen]); + + const toggleCollapse = () => setIsCollapsed(!isCollapsed); + const toggleMobile = () => setIsMobileOpen(!isMobileOpen); + + const currentLang = resolveSupportedLanguage(i18n.resolvedLanguage || i18n.language); + const languageLabel = languageOptions.find(option => option.value === currentLang)?.compactLabel ?? 'EN'; + const changeLanguage = (language: SupportedLanguage) => { + setIsLanguageMenuOpen(false); + void i18n.changeLanguage(language); + }; + const isRtl = rtlLanguages.includes(currentLang); + + return ( +
+ {isMobile && ( +
+ +
+ OpenWA + {t('common.appName')} +
+
+
+ )} + + {isMobile && isMobileOpen &&
setIsMobileOpen(false)} />} + + + +
+ +
+
+ ); +} diff --git a/dashboard/src/components/PageHeader.css b/dashboard/src/components/PageHeader.css new file mode 100644 index 0000000..0404094 --- /dev/null +++ b/dashboard/src/components/PageHeader.css @@ -0,0 +1,87 @@ +/* ============================================= + PageHeader Component - Shared Styles + Consistent header styling for all pages + ============================================= */ + +/* Grid so the three regions can re-order responsively: on desktop the title and actions share + the top row with the subtitle beneath; on mobile the subtitle follows the title directly and + the actions drop to the bottom (description should follow the title, not the call-to-action). */ +.page-header { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + 'title actions' + 'subtitle subtitle'; + align-items: center; + column-gap: 1rem; + margin-bottom: 1.5rem; + width: 100%; +} + +.page-header__title-group { + grid-area: title; + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.page-header__title-group h1 { + margin: 0; + /* h1 styles inherited from global */ +} + +.page-header__badge { + display: inline-flex; +} + +.page-header__actions { + grid-area: actions; + justify-self: end; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.page-header__subtitle { + grid-area: subtitle; + margin: 0; + margin-top: 0.5rem; /* Consistent 8px gap from title row */ + font-size: 0.9375rem; + color: var(--text-muted); + line-height: 1.5; +} + +/* Mobile Responsive: stack as title -> subtitle -> actions */ +@media (max-width: 768px) { + .page-header { + grid-template-columns: 1fr; + grid-template-areas: + 'title' + 'subtitle' + 'actions'; + column-gap: 0; + } + + .page-header__title-group h1 { + font-size: 1.5rem; + } + + .page-header__actions { + justify-self: stretch; + margin-top: 0.25rem; + } + + .page-header__subtitle { + font-size: 0.875rem; + } +} + +@media (max-width: 480px) { + .page-header__actions > button, + .page-header__actions > .btn-primary, + .page-header__actions > .btn-secondary { + width: 100%; + justify-content: center; + } +} diff --git a/dashboard/src/components/PageHeader.tsx b/dashboard/src/components/PageHeader.tsx new file mode 100644 index 0000000..34f7553 --- /dev/null +++ b/dashboard/src/components/PageHeader.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; +import './PageHeader.css'; + +interface PageHeaderProps { + title: string; + subtitle?: string; + badge?: ReactNode; + actions?: ReactNode; +} + +/** + * Shared page header component for consistent styling across all pages. + * + * @example + * // Simple usage + * + * + * @example + * // With badge and actions + * } + * subtitle="Overview of your WhatsApp sessions" + * actions={} + * /> + */ +export function PageHeader({ title, subtitle, badge, actions }: PageHeaderProps) { + return ( +
+
+

{title}

+ {badge && {badge}} +
+ {actions &&
{actions}
} + {subtitle &&

{subtitle}

} +
+ ); +} diff --git a/dashboard/src/components/PluginInstances.css b/dashboard/src/components/PluginInstances.css new file mode 100644 index 0000000..555770f --- /dev/null +++ b/dashboard/src/components/PluginInstances.css @@ -0,0 +1,116 @@ +.plugin-instances { + display: flex; + flex-direction: column; + gap: 1rem; +} +.plugin-instances .pi-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} +.plugin-instances .pi-desc { + color: var(--text-muted); + margin: 0; + flex: 1; +} +.plugin-instances .pi-loading { + display: flex; + justify-content: center; + padding: 2rem; +} +.plugin-instances .pi-empty { + text-align: center; + color: var(--text-muted); + padding: 2rem 1rem; +} +.plugin-instances .pi-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.plugin-instances .pi-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; +} +.plugin-instances .pi-main { + display: flex; + flex-direction: column; + min-width: 0; +} +.plugin-instances .pi-id { + font-weight: 600; +} +.plugin-instances .pi-scope { + font-size: 0.8rem; + color: var(--text-muted); +} +.plugin-instances .pi-url { + flex: 1; + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} +.plugin-instances .pi-url code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.plugin-instances .pi-badge { + font-size: 0.75rem; + padding: 0.15rem 0.5rem; + border-radius: 999px; + white-space: nowrap; +} +.plugin-instances .pi-badge.on { + background: var(--success-bg, #dcfce7); + color: var(--success, #16a34a); +} +.plugin-instances .pi-badge.off { + background: var(--bg-secondary); + color: var(--text-muted); +} +.plugin-instances .pi-actions { + display: flex; + gap: 0.25rem; + margin-left: auto; +} +.plugin-instances .pi-hint { + font-size: 0.8rem; + color: var(--text-muted); + margin: 0.25rem 0 0.75rem; +} +.plugin-instances .pi-secret { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; +} +.plugin-instances .pi-secret code { + flex: 1; + padding: 0.75rem; + background: var(--bg-secondary); + border-radius: 6px; + word-break: break-all; +} +.plugin-instances .pi-error { + color: var(--danger, #e5484d); + font-size: 0.85rem; + margin-top: 0.5rem; +} +.plugin-instances .pi-confirm-icon { + display: flex; + justify-content: center; + color: var(--danger, #e5484d); + margin-bottom: 0.75rem; +} +.plugin-instances textarea { + width: 100%; + min-height: 96px; + font-family: monospace; +} diff --git a/dashboard/src/components/PluginInstances.tsx b/dashboard/src/components/PluginInstances.tsx new file mode 100644 index 0000000..1dd66d5 --- /dev/null +++ b/dashboard/src/components/PluginInstances.tsx @@ -0,0 +1,363 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus, Copy, Check, RefreshCw, Pencil, Trash2, X, Loader2, Power, AlertTriangle } from 'lucide-react'; +import type { InstanceView, MintedInstance } from '../services/api'; +import { + usePluginInstancesQuery, + useCreateInstanceMutation, + useRegenerateInstanceSecretMutation, + useUpdateInstanceMutation, + useDeleteInstanceMutation, +} from '../hooks/queries'; +import { isValidInstanceId, parseInstanceConfig } from '../utils/instanceForm'; +import { copyToClipboard } from '../utils/clipboard'; +import { useToast } from './Toast'; +import './PluginInstances.css'; + +const emptyForm = { instanceId: '', sessionScope: '', verifyToken: '', config: '' }; + +export function PluginInstances({ pluginId }: { pluginId: string }) { + const { t } = useTranslation(); + const toast = useToast(); + const { data: instances = [], isLoading, isError } = usePluginInstancesQuery(pluginId, true); + const createM = useCreateInstanceMutation(pluginId); + const regenM = useRegenerateInstanceSecretMutation(pluginId); + const updateM = useUpdateInstanceMutation(pluginId); + const deleteM = useDeleteInstanceMutation(pluginId); + + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState(emptyForm); + const [formError, setFormError] = useState(null); + const [minted, setMinted] = useState(null); // secret-shown-once view + const [mintedKind, setMintedKind] = useState<'created' | 'regenerated'>('created'); + const [editing, setEditing] = useState(null); + const [editForm, setEditForm] = useState({ sessionScope: '', config: '' }); + const [editError, setEditError] = useState(null); + const [confirm, setConfirm] = useState<{ type: 'delete' | 'regenerate'; inst: InstanceView } | null>(null); + const [copied, setCopied] = useState(null); + + const copy = async (text: string, id: string) => { + if (await copyToClipboard(text)) { + setCopied(id); + setTimeout(() => setCopied(null), 2000); + } + }; + + const openCreate = () => { + setForm(emptyForm); + setFormError(null); + setShowForm(true); + }; + + const submitCreate = async () => { + if (!isValidInstanceId(form.instanceId)) { + setFormError(t('plugins.instances.errors.invalidId')); + return; + } + const parsed = parseInstanceConfig(form.config); + if (!parsed.ok) { + setFormError(t('plugins.instances.errors.invalidJson')); + return; + } + try { + const created = await createM.mutateAsync({ + instanceId: form.instanceId, + sessionScope: form.sessionScope.trim() || undefined, + verifyToken: form.verifyToken.trim() || undefined, + config: parsed.value, + }); + setShowForm(false); + setForm(emptyForm); + setMintedKind('created'); + setMinted(created); + toast.success(t('plugins.instances.toasts.created'), created.instanceId); + } catch (err) { + const e = err as Error & { status?: number }; + setFormError(e.status === 409 ? t('plugins.instances.errors.duplicateId') : e.message); + } + }; + + const toggleEnabled = async (inst: InstanceView) => { + try { + await updateM.mutateAsync({ instanceId: inst.instanceId, body: { enabled: !inst.enabled } }); + toast.success(t('plugins.instances.toasts.updated'), inst.instanceId); + } catch (err) { + toast.error(t('plugins.instances.toasts.actionFailed'), (err as Error).message); + } + }; + + const openEdit = (inst: InstanceView) => { + setEditing(inst); + setEditForm({ + sessionScope: inst.sessionScope ?? '', + config: inst.config ? JSON.stringify(inst.config, null, 2) : '', + }); + setEditError(null); + }; + + const submitEdit = async () => { + if (!editing) return; + const parsed = parseInstanceConfig(editForm.config); + if (!parsed.ok) { + setEditError(t('plugins.instances.errors.invalidJson')); + return; + } + try { + await updateM.mutateAsync({ + instanceId: editing.instanceId, + // Blank → omit (leave scope unchanged); mirrors create. Sending '' would corrupt an + // all-sessions (null) instance into a literal empty scope the backend never clears. + body: { sessionScope: editForm.sessionScope.trim() || undefined, config: parsed.value ?? {} }, + }); + setEditing(null); + toast.success(t('plugins.instances.toasts.updated'), editing.instanceId); + } catch (err) { + setEditError((err as Error).message); + } + }; + + const runConfirm = async () => { + if (!confirm) return; + const { type, inst } = confirm; + setConfirm(null); + try { + if (type === 'delete') { + await deleteM.mutateAsync(inst.instanceId); + toast.success(t('plugins.instances.toasts.deleted'), inst.instanceId); + } else { + const res = await regenM.mutateAsync(inst.instanceId); + setMintedKind('regenerated'); + setMinted(res); + toast.success(t('plugins.instances.toasts.secretRegenerated'), inst.instanceId); + } + } catch (err) { + toast.error(t('plugins.instances.toasts.actionFailed'), (err as Error).message); + } + }; + + return ( +
+
+

{t('plugins.instances.description')}

+ +
+ + {isLoading ? ( +
+ +
+ ) : isError ? ( +

{t('plugins.instances.loadError')}

+ ) : instances.length === 0 ? ( +

{t('plugins.instances.empty')}

+ ) : ( +
+ {instances.map(inst => ( +
+
+ {inst.instanceId} + {inst.sessionScope || t('plugins.instances.allSessions')} +
+ {inst.ingressUrls[0] && ( +
+ {inst.ingressUrls[0].url} + +
+ )} + + {inst.enabled ? t('plugins.instances.enabled') : t('plugins.instances.disabled')} + +
+ + + + +
+
+ ))} +
+ )} + + {/* Create modal — form, or the secret-shown-once view after mint */} + {showForm && ( +
setShowForm(false)}> +
e.stopPropagation()}> +
+

{t('plugins.instances.create')}

+ +
+
+ + setForm({ ...form, instanceId: e.target.value })} + /> +

{t('plugins.instances.form.instanceIdHint')}

+ + setForm({ ...form, sessionScope: e.target.value })} + /> + + setForm({ ...form, verifyToken: e.target.value })} + /> + +