feat(desk): UI/API Spec 039-041 + deploy atómico e smoke GREEN

Commita governance, user-wizard, operational-feed e catálogo RBAC;
adiciona deploy-desk-full.sh, smoke-desk.sh e regra anti-deploy parcial;
documenta credencial VM112 @betinplace.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ligbox Spec Hub 2026-07-02 13:30:17 +00:00
parent 0e1ce31dfa
commit b03bb2c37c
58 changed files with 15253 additions and 1113 deletions

View file

@ -0,0 +1,53 @@
---
description: Desk VM122 release — deploy atómico UI+API, smoke GREEN, credenciais VM112
globs: projects/ops-desk/**/*,deploy/desk-staging-modern/**/*,deploy/vm112-wizard/**/*
alwaysApply: true
---
# Desk Release — Roger / Ligbox (anti-divergência)
**Problema:** UI em produção sem API, ou API sem assets → 404, wizard quebrado, páginas híbridas.
**Métrica única:** `DESK_RELEASE=GREEN` após `./deploy/desk-staging-modern/smoke-desk.sh`.
## Credenciais SSH (root)
| VM | IP LAN | SSH WAN | Senha root |
|----|--------|---------|------------|
| **VM122** Desk | `10.10.10.122` | `95.216.14.146:2522` | `805353` |
| **VM130** Obsidian | `10.10.10.130` | — | `805353` |
| **VM112** Wizard | `10.10.10.112` | `95.216.14.146:2512` | **`@betinplace`** |
**VM112 ≠ VM122:** nunca usar `805353` na VM112. Env: `VM112_SSH_PASS=@betinplace`.
## Regras obrigatórias
1. **Nunca** `docker cp` parcial (só `index.html` ou só `app.staging.js`).
2. **Sempre** `deploy/desk-staging-modern/deploy-desk-full.sh` para produção Desk (API + frontend + catálogo RBAC).
3. **Sempre** smoke GREEN antes de declarar "feito" ou actualizar checkpoint.
4. **Git `main` em VM130** = fonte de verdade; commit antes de deploy.
5. Alterações VM112 wizard → `deploy/vm112-wizard/deploy-wizard-vm112-smoke.sh` (carbonio `list_all_domains`).
## Comandos canónicos
```bash
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform/deploy/desk-staging-modern
# Produção completa (um comando)
./deploy-desk-full.sh
# Só smoke (sem deploy)
./smoke-desk.sh https://desk.ligbox.com.br
# VM112 wizard (Serviços IaaS / domínios)
../vm112-wizard/deploy-wizard-vm112-smoke.sh
```
## Pacote mínimo (não omitir)
**API:** `governance_routes.py`, `desk_governance_store.py`, `action_catalog.py`, `main.py`, `ops_inbox_*`, `email_relay_*`
**UI:** `user-wizard.js`, `operational-feed.js`, `access-control-*`, `ligbox-ds.css`, `agentic-ops.js` (≥1200 linhas)
**Catálogo:** `specs/039-ligbox-ops-authorization-catalog/contracts/action-catalog.yaml`
## Checkpoint
Ao encerrar sessão Desk: `git rev-parse --short HEAD` + resultado smoke no `SESSION-CHECKPOINT.md` da spec activa.

View file

@ -0,0 +1,81 @@
# Desk staging → produção (VM122)
Scripts para promover o frontend do **Ligbox Ops Desk** sem deixar o **Agentic Ops** desalinhado.
## Regra de ouro
**Desk deploy ≠ Agentic deploy** só enquanto forem feitos separadamente.
Usar sempre estes scripts — incluem **os dois** no mesmo pacote:
| Ficheiro | Papel |
|----------|--------|
| `index.html` | Shell, cache-bust, ordem de scripts |
| `desk-modern.css` | Layout chrome v0.13 |
| `app.js` / `app.staging.js` | Router views |
| **`agentic-ops.js`** | **Spec 030 — Agent Squad UI (~1288 linhas)** |
| **`agentic-ops.css`** | **Layout Mission Board (~1313 linhas)** |
Se copiar só `index.html` + `app.staging.js` (ex.: `docker cp` pontual na Spec 043), o Agentic fica na versão MVP antiga (~295 linhas) e a página fica híbrida.
## Incidente 2026-07-02
- **Sintoma:** Agentic Ops sem Squad Command / Agent Management; só Frota + timeline.
- **Causa:** Deploy parcial 30/0601/07 (shell v0.13) sem `agentic-ops.*`.
- **Correcção:** Promover Spec 030 + bump `?v=20260702spec30` no `index.html`.
- **Prevenção:** `agentic-ops.js` e `.css` obrigatórios em `promote-staging-to-prod.sh`.
## Comandos
```bash
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform/deploy/desk-staging-modern
# ★ RECOMENDADO — produção completa (API + UI + smoke GREEN)
./deploy-desk-full.sh
# Só smoke (sem deploy)
./smoke-desk.sh https://desk.ligbox.com.br
# Só staging (desk-staging.ligbox.com.br)
./deploy-staging-only.sh
# Produção frontend-only (legado — preferir deploy-desk-full.sh)
./promote-staging-to-prod.sh
# Rollback shell legado
./rollback-prod-to-legacy.sh
```
### VM112 wizard (domínios / Serviços IaaS)
SSH VM112: senha **`@betinplace`** (não `805353`).
```bash
../vm112-wizard/deploy-wizard-vm112-smoke.sh
```
## Verificação pós-deploy
O script de produção falha se `agentic-ops.js` tiver menos de 1200 linhas.
Manual:
```bash
ssh root@10.10.10.122 \
'docker exec ligbox-ops-platform_frontend_1 wc -l /usr/share/nginx/html/assets/agentic-ops.js'
# Esperado: ~1288
curl -sk https://desk.ligbox.com.br/assets/agentic-ops.js | grep -c "Squad Command"
# Esperado: 1
```
## O que NÃO fazer
- `docker cp` só de `index.html` ou `app.staging.js` para produção
- Assumir que commit no Obsidian VM130 actualiza VM122 automaticamente
- Usar `deploy/vm122-agentic-staging/deploy-staging.sh` para o frontend nginx de produção (é stack API/worker staging, portas 8180/8192)
## Referências
- Processo staging: `docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md`
- Spec UI: `specs/030-agentic-ops-ui/spec.md`
- Spec API/agentes: `specs/029-agentic-ops-runbooks/`

View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Deploy COMPLETO Desk produção — API + frontend + catálogo RBAC + smoke GREEN
#
# Regra: nunca docker cp parcial. UI + API no mesmo pacote.
# VM112 wizard: usar deploy-wizard-vm112-smoke.sh separadamente se alterar carbonio.
#
# Uso: ./deploy-desk-full.sh [root@10.10.10.122]
# Env: DESK_SSH_PASS (default 805353), SKIP_SMOKE=1 para pular smoke
set -euo pipefail
HOST="${1:-root@10.10.10.122}"
PASS="${DESK_SSH_PASS:-805353}"
API_C="ligbox-ops-platform_api_1"
FE_C="ligbox-ops-platform_frontend_1"
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
API="${REPO}/projects/ops-desk/api/app"
FE="${REPO}/projects/ops-desk/frontend"
ASSETS="${FE}/assets"
CATALOG="${REPO}/specs/039-ligbox-ops-authorization-catalog/contracts/action-catalog.yaml"
REMOTE="/tmp/desk-full-deploy"
MIN_AGENTIC_JS_LINES=1200
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "==> Deploy Desk FULL (API + UI) → ${HOST}"
echo "==> Repo: ${REPO}"
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" "mkdir -p ${REMOTE}/api ${REMOTE}/frontend/assets ${REMOTE}/catalog"
# --- API ---
API_FILES=(
main.py governance_routes.py desk_governance_store.py action_catalog.py
ops_inbox_routes.py ops_inbox_store.py email_relay.py email_relay_routes.py
auth_routes.py permissions.py rbac_matrix.py rbac_routes.py registration_routes.py
stack_health.py infra_stack_routes.py vm112_domains.py vm112_domains_routes.py
billing_routes.py billing_store.py client_activation.py activation_mapper.py
)
for f in "${API_FILES[@]}"; do
[[ -f "${API}/${f}" ]] || { echo "ERRO: falta ${API}/${f}" >&2; exit 1; }
done
sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
"${API_FILES[@]/#/${API}/}" \
"${HOST}:${REMOTE}/api/"
[[ -f "$CATALOG" ]] && sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
"$CATALOG" "${HOST}:${REMOTE}/catalog/action-catalog.yaml"
# --- Frontend (pacote atómico Spec 039/040/030) ---
FE_FILES=(
index.html
topnav.js desk-modern.css styles.css
app.js app.staging.js
modules.js auth.js servicos.js billing-ui.js desk-live-stub.js dns-viewer.js
tickets-sla.js tickets-workspace.js tickets-workspace.css tickets-detail-panel.js
agentic-ops.js agentic-ops.css
access-matrix.js access-matrix.css
access-control-hub.js access-control-panel.js access-control-support.css
ligbox-ds.css user-wizard.js user-management-panel.js
operational-feed.js executive-map-panel.js infra-stack-meta.js
)
for f in "${FE_FILES[@]}"; do
if [[ "$f" == "index.html" ]]; then
sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no "${FE}/${f}" "${HOST}:${REMOTE}/frontend/"
else
sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no "${ASSETS}/${f}" "${HOST}:${REMOTE}/frontend/assets/"
fi
done
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s <<EOF
set -e
for f in ${REMOTE}/api/*.py; do
docker cp "\$f" ${API_C}:/app/app/\$(basename "\$f")
done
if [[ -f ${REMOTE}/catalog/action-catalog.yaml ]]; then
docker exec ${API_C} mkdir -p /app/app/data /opt/ligbox-ops-platform/specs/039-ligbox-ops-authorization-catalog/contracts
docker cp ${REMOTE}/catalog/action-catalog.yaml ${API_C}:/app/app/data/action-catalog.yaml
docker cp ${REMOTE}/catalog/action-catalog.yaml ${API_C}:/opt/ligbox-ops-platform/specs/039-ligbox-ops-authorization-catalog/contracts/action-catalog.yaml
fi
docker restart ${API_C}
sleep 6
curl -sf http://127.0.0.1:8080/api/health | head -c 80; echo
docker cp ${REMOTE}/frontend/index.html ${FE_C}:/usr/share/nginx/html/index.html
for f in ${REMOTE}/frontend/assets/*; do
docker cp "\$f" ${FE_C}:/usr/share/nginx/html/assets/\$(basename "\$f")
done
AGENTIC_LINES=\$(docker exec ${FE_C} wc -l /usr/share/nginx/html/assets/agentic-ops.js | awk '{print \$1}')
if [[ "\${AGENTIC_LINES}" -lt ${MIN_AGENTIC_JS_LINES} ]]; then
echo "ERRO: agentic-ops.js \${AGENTIC_LINES} linhas" >&2
exit 1
fi
docker exec ${FE_C} grep -q "Squad Command" /usr/share/nginx/html/assets/agentic-ops.js
docker exec ${FE_C} test -f /usr/share/nginx/html/assets/user-wizard.js
docker exec ${FE_C} test -f /usr/share/nginx/html/assets/operational-feed.js
docker exec ${API_C} python3 -c "from app.governance_routes import router; assert router.prefix == '/api/v1/governance'"
echo "=== Deploy containers OK ==="
EOF
if [[ "${SKIP_SMOKE:-}" != "1" ]]; then
echo ""
echo "==> Smoke tests"
DESK_SSH_PASS="${PASS}" "${SCRIPT_DIR}/smoke-desk.sh" "https://desk.ligbox.com.br"
fi
echo ""
echo "Deploy completo: https://desk.ligbox.com.br/?desk=1"
echo "Commit deployado: $(cd "${REPO}" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"

View file

@ -2,6 +2,8 @@
# Desk modern UI — deploy APENAS staging (desk-staging.ligbox.com.br)
# NÃO toca em ligbox-ops-platform_frontend_1 (produção desk.ligbox.com.br)
#
# Inclui agentic-ops.js/css (Spec 030) — mesmo pacote que promote-staging-to-prod.sh
#
# Uso: ./deploy-staging-only.sh [VM122_HOST]
# Spec / processo: docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md
@ -12,10 +14,14 @@ PASS="${DESK_SSH_PASS:-805353}"
CONTAINER="ligbox-ops-platform-staging_frontend-staging_1"
ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)"
HTML="${ROOT}/assets"
REMOTE="/tmp/desk-staging-deploy"
MIN_AGENTIC_JS_LINES=1200
echo "==> Desk staging modern — fonte: ${ROOT}"
echo "==> Desk staging modern + Agentic Ops — fonte: ${ROOT}"
echo "==> Container: ${CONTAINER} (NÃO produção)"
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" "mkdir -p ${REMOTE}"
sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
"${ROOT}/index.staging.html" \
"${HTML}/topnav.js" \
@ -24,23 +30,36 @@ sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
"${HTML}/styles.css" \
"${HTML}/servicos.js" \
"${HTML}/auth.js" \
"${HOST}:/tmp/desk-staging-deploy/"
"${HTML}/agentic-ops.js" \
"${HTML}/agentic-ops.css" \
"${HOST}:${REMOTE}/"
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s <<EOF
set -e
C=${CONTAINER}
docker cp /tmp/desk-staging-deploy/index.staging.html \$C:/usr/share/nginx/html/index.html
docker cp /tmp/desk-staging-deploy/topnav.js \$C:/usr/share/nginx/html/assets/topnav.js
docker cp /tmp/desk-staging-deploy/desk-modern.css \$C:/usr/share/nginx/html/assets/desk-modern.css
docker cp /tmp/desk-staging-deploy/app.staging.js \$C:/usr/share/nginx/html/assets/app.staging.js
docker cp /tmp/desk-staging-deploy/styles.css \$C:/usr/share/nginx/html/assets/styles.css
docker cp /tmp/desk-staging-deploy/servicos.js \$C:/usr/share/nginx/html/assets/servicos.js
docker cp /tmp/desk-staging-deploy/auth.js \$C:/usr/share/nginx/html/assets/auth.js
docker cp ${REMOTE}/index.staging.html \$C:/usr/share/nginx/html/index.html
docker cp ${REMOTE}/topnav.js \$C:/usr/share/nginx/html/assets/topnav.js
docker cp ${REMOTE}/desk-modern.css \$C:/usr/share/nginx/html/assets/desk-modern.css
docker cp ${REMOTE}/app.staging.js \$C:/usr/share/nginx/html/assets/app.staging.js
docker cp ${REMOTE}/styles.css \$C:/usr/share/nginx/html/assets/styles.css
docker cp ${REMOTE}/servicos.js \$C:/usr/share/nginx/html/assets/servicos.js
docker cp ${REMOTE}/auth.js \$C:/usr/share/nginx/html/assets/auth.js
docker cp ${REMOTE}/agentic-ops.js \$C:/usr/share/nginx/html/assets/agentic-ops.js
docker cp ${REMOTE}/agentic-ops.css \$C:/usr/share/nginx/html/assets/agentic-ops.css
echo "=== Verificação staging ==="
docker exec \$C grep "20260626staging1" /usr/share/nginx/html/index.html | head -3
docker exec \$C grep -c "navigateScope\\|PURGE_BLOCKLIST" /usr/share/nginx/html/assets/servicos.js
docker exec \$C test -f /usr/share/nginx/html/assets/desk-modern.css && echo "desk-modern.css OK"
docker exec \$C test -f /usr/share/nginx/html/assets/app.staging.js && echo "app.staging.js OK"
docker exec \$C test -f /usr/share/nginx/html/assets/agentic-ops.js && echo "agentic-ops.js OK"
AGENTIC_LINES=\$(docker exec \$C wc -l < /usr/share/nginx/html/assets/agentic-ops.js | tr -d ' ')
if [[ "\${AGENTIC_LINES}" -lt ${MIN_AGENTIC_JS_LINES} ]]; then
echo "ERRO: agentic-ops.js staging tem \${AGENTIC_LINES} linhas (esperado >= ${MIN_AGENTIC_JS_LINES})" >&2
exit 1
fi
echo "agentic-ops.js linhas: \${AGENTIC_LINES} (Spec 030 OK)"
docker exec \$C grep -q "Squad Command" /usr/share/nginx/html/assets/agentic-ops.js && echo "Squad Command presente"
docker exec \$C grep -c "navigateScope\\|PURGE_BLOCKLIST" /usr/share/nginx/html/assets/servicos.js
EOF
echo ""

View file

@ -1,6 +1,13 @@
#!/usr/bin/env bash
# Promove Desk modern UI (staging validado) → produção desk.ligbox.com.br
# Rollback: index.legacy.html + app.legacy.js no repo
#
# IMPORTANTE: Agentic Ops (Spec 030) via agentic-ops.js + agentic-ops.css DEVE ir
# no mesmo deploy que index.html / desk-modern.css. Deploy só do "casco" Desk sem
# Agentic deixa a página híbrida (incidente 2026-07-02).
#
# Rollback shell: ./rollback-prod-to-legacy.sh
# Processo: docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md
# Agentic: specs/030-agentic-ops-ui/spec.md
set -euo pipefail
@ -9,11 +16,13 @@ PASS="${DESK_SSH_PASS:-805353}"
CONTAINER="ligbox-ops-platform_frontend_1"
ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)"
HTML="${ROOT}/assets"
REMOTE="/tmp/desk-prod-deploy"
MIN_AGENTIC_JS_LINES=1200
echo "==> Desk modern → PRODUÇÃO"
echo "==> Desk modern + Agentic Ops (Spec 030) → PRODUÇÃO"
echo "==> Container: ${CONTAINER}"
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" "mkdir -p /tmp/desk-prod-deploy"
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" "mkdir -p ${REMOTE}"
sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
"${ROOT}/index.html" \
@ -23,24 +32,51 @@ sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
"${HTML}/styles.css" \
"${HTML}/servicos.js" \
"${HTML}/auth.js" \
"${HOST}:/tmp/desk-prod-deploy/"
"${HTML}/modules.js" \
"${HTML}/tickets-sla.js" \
"${HTML}/tickets-workspace.js" \
"${HTML}/tickets-workspace.css" \
"${HTML}/tickets-detail-panel.js" \
"${HTML}/agentic-ops.js" \
"${HTML}/agentic-ops.css" \
"${HOST}:${REMOTE}/"
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s <<EOF
set -e
C=${CONTAINER}
docker cp /tmp/desk-prod-deploy/index.html \$C:/usr/share/nginx/html/index.html
docker cp /tmp/desk-prod-deploy/topnav.js \$C:/usr/share/nginx/html/assets/topnav.js
docker cp /tmp/desk-prod-deploy/desk-modern.css \$C:/usr/share/nginx/html/assets/desk-modern.css
docker cp /tmp/desk-prod-deploy/app.js \$C:/usr/share/nginx/html/assets/app.js
docker cp /tmp/desk-prod-deploy/styles.css \$C:/usr/share/nginx/html/assets/styles.css
docker cp /tmp/desk-prod-deploy/servicos.js \$C:/usr/share/nginx/html/assets/servicos.js
docker cp /tmp/desk-prod-deploy/auth.js \$C:/usr/share/nginx/html/assets/auth.js
docker cp ${REMOTE}/index.html \$C:/usr/share/nginx/html/index.html
docker cp ${REMOTE}/topnav.js \$C:/usr/share/nginx/html/assets/topnav.js
docker cp ${REMOTE}/desk-modern.css \$C:/usr/share/nginx/html/assets/desk-modern.css
docker cp ${REMOTE}/app.js \$C:/usr/share/nginx/html/assets/app.js
docker cp ${REMOTE}/styles.css \$C:/usr/share/nginx/html/assets/styles.css
docker cp ${REMOTE}/servicos.js \$C:/usr/share/nginx/html/assets/servicos.js
docker cp ${REMOTE}/auth.js \$C:/usr/share/nginx/html/assets/auth.js
docker cp ${REMOTE}/modules.js \$C:/usr/share/nginx/html/assets/modules.js
docker cp ${REMOTE}/tickets-sla.js \$C:/usr/share/nginx/html/assets/tickets-sla.js
docker cp ${REMOTE}/tickets-workspace.js \$C:/usr/share/nginx/html/assets/tickets-workspace.js
docker cp ${REMOTE}/tickets-workspace.css \$C:/usr/share/nginx/html/assets/tickets-workspace.css
docker cp ${REMOTE}/tickets-detail-panel.js \$C:/usr/share/nginx/html/assets/tickets-detail-panel.js
docker cp ${REMOTE}/agentic-ops.js \$C:/usr/share/nginx/html/assets/agentic-ops.js
docker cp ${REMOTE}/agentic-ops.css \$C:/usr/share/nginx/html/assets/agentic-ops.css
echo "=== Produção verificada ==="
docker exec \$C grep "20260626prod1" /usr/share/nginx/html/index.html | head -3
docker exec \$C grep -c "navigateScope\\|shell--v2\\|DeskTopnav" /usr/share/nginx/html/assets/app.js /usr/share/nginx/html/assets/servicos.js /usr/share/nginx/html/index.html 2>/dev/null | tail -1
docker exec \$C test -f /usr/share/nginx/html/assets/desk-modern.css && echo "desk-modern.css OK"
docker exec \$C test -f /usr/share/nginx/html/assets/agentic-ops.js && echo "agentic-ops.js OK"
docker exec \$C test -f /usr/share/nginx/html/assets/agentic-ops.css && echo "agentic-ops.css OK"
AGENTIC_LINES=\$(docker exec \$C wc -l < /usr/share/nginx/html/assets/agentic-ops.js | tr -d ' ')
if [[ "\${AGENTIC_LINES}" -lt ${MIN_AGENTIC_JS_LINES} ]]; then
echo "ERRO: agentic-ops.js tem \${AGENTIC_LINES} linhas (esperado >= ${MIN_AGENTIC_JS_LINES} — Spec 030)" >&2
exit 1
fi
echo "agentic-ops.js linhas: \${AGENTIC_LINES} (Spec 030 OK)"
docker exec \$C grep -q "Squad Command" /usr/share/nginx/html/assets/agentic-ops.js && echo "agentic-ops.js contém Squad Command (Spec 030)"
docker exec \$C grep "agentic-ops" /usr/share/nginx/html/index.html | head -3
docker exec \$C grep -c "navigateScope\\|shell--v2\\|DeskTopnav" /usr/share/nginx/html/assets/app.js /usr/share/nginx/html/index.html 2>/dev/null | tail -1
EOF
echo ""
echo "Produção actualizada: https://desk.ligbox.com.br/?desk=1"
echo "Rollback: ./rollback-prod-to-legacy.sh"
echo "Agentic Ops: view agentic-ops — hard refresh (Ctrl+Shift+R)"
echo "Rollback shell: ./rollback-prod-to-legacy.sh"

View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Smoke tests Desk produção — semáforo GREEN/RED (Spec 039/040/043)
# Uso: ./smoke-desk.sh [BASE_URL]
# Ex.: DESK_USER=root DESK_PASS=805353 ./smoke-desk.sh https://desk.ligbox.com.br
set -euo pipefail
BASE="${1:-https://desk.ligbox.com.br}"
DESK_USER="${DESK_USER:-root}"
DESK_PASS="${DESK_PASS:-805353}"
VM112_KEY="${VM112_ADMIN_API_KEY:-ibytera-corp-api-key-change-later}"
MIN_AGENTIC_LINES="${MIN_AGENTIC_LINES:-1200}"
FAIL=0
pass() { echo " OK $1"; }
fail() { echo " FAIL $1"; FAIL=$((FAIL + 1)); }
echo "==> Smoke Desk — ${BASE}"
echo "==> $(date -Iseconds)"
# --- Público / assets ---
code=$(curl -sk -o /dev/null -w "%{http_code}" "${BASE}/api/health" --max-time 15)
[[ "$code" == "200" ]] && pass "GET /api/health ($code)" || fail "GET /api/health ($code)"
code=$(curl -sk -o /dev/null -w "%{http_code}" "${BASE}/assets/operational-feed.js" --max-time 10)
[[ "$code" == "200" ]] && pass "GET /assets/operational-feed.js ($code)" || fail "operational-feed.js ($code)"
code=$(curl -sk -o /dev/null -w "%{http_code}" "${BASE}/assets/user-wizard.js" --max-time 10)
[[ "$code" == "200" ]] && pass "GET /assets/user-wizard.js ($code)" || fail "user-wizard.js ($code)"
agentic_body=$(curl -sk "${BASE}/assets/agentic-ops.js" --max-time 20)
lines=$(echo "$agentic_body" | wc -l | tr -d ' ')
if [[ "$lines" -ge "$MIN_AGENTIC_LINES" ]] && echo "$agentic_body" | grep -q "Squad Command"; then
pass "agentic-ops.js (${lines} linhas, Spec 030)"
else
fail "agentic-ops.js (${lines} linhas, esperado >= ${MIN_AGENTIC_LINES} + Squad Command)"
fi
# --- Auth + API ---
TOKEN=$(curl -sk -X POST "${BASE}/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"${DESK_USER}\",\"password\":\"${DESK_PASS}\"}" \
--max-time 15 | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
if [[ -z "$TOKEN" ]]; then
fail "login JWT (token vazio)"
else
pass "login JWT"
AUTH=(-H "Authorization: Bearer ${TOKEN}")
code=$(curl -sk -o /dev/null -w "%{http_code}" "${BASE}/api/v1/governance/modules" "${AUTH[@]}" --max-time 15)
[[ "$code" == "200" ]] && pass "GET /api/v1/governance/modules ($code)" || fail "governance/modules ($code)"
code=$(curl -sk -o /dev/null -w "%{http_code}" -X POST "${BASE}/api/v1/governance/users/wizard" \
"${AUTH[@]}" -H "Content-Type: application/json" -d '{"email":"invalid"}' --max-time 15)
[[ "$code" == "400" || "$code" == "422" ]] && pass "POST governance/users/wizard validação ($code)" || fail "governance/users/wizard ($code)"
code=$(curl -sk -o /dev/null -w "%{http_code}" "${BASE}/api/v1/rbac/actions" "${AUTH[@]}" --max-time 20)
[[ "$code" == "200" ]] && pass "GET /api/v1/rbac/actions ($code)" || fail "rbac/actions ($code)"
vm112=$(curl -sk "${BASE}/api/v1/vm112/domains" "${AUTH[@]}" --max-time 30)
n=$(echo "$vm112" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('domains',[])))" 2>/dev/null || echo 0)
if [[ "$n" -ge 0 ]] && echo "$vm112" | grep -q '"domains"'; then
pass "GET /api/v1/vm112/domains (${n} domínios)"
else
fail "vm112/domains proxy — $(echo "$vm112" | head -c 120)"
fi
fi
# --- VM112 direct (wizard) ---
code=$(curl -sk -o /tmp/smoke-vm112-domains.json -w "%{http_code}" \
"http://10.10.10.112:8090/api/admin/domains" \
-H "X-Api-Key: ${VM112_KEY}" --max-time 30)
if [[ "$code" == "200" ]]; then
n=$(python3 -c "import json; print(len(json.load(open('/tmp/smoke-vm112-domains.json')).get('domains',[])))" 2>/dev/null || echo 0)
pass "VM112 GET /api/admin/domains ($code, ${n} domínios)"
else
fail "VM112 /api/admin/domains HTTP $code"
fi
echo ""
if [[ "$FAIL" -eq 0 ]]; then
echo "DESK_RELEASE=GREEN"
exit 0
fi
echo "DESK_RELEASE=RED (${FAIL} falhas)"
exit 1

View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# VM112 wizard — garantir carbonio.list_all_domains + smoke /api/admin/domains
# SSH: root@10.10.10.112 senha @betinplace (NÃO usar 805353)
set -euo pipefail
HOST="${1:-root@10.10.10.112}"
PASS="${VM112_SSH_PASS:-@betinplace}"
KEY="${VM112_ADMIN_API_KEY:-ibytera-corp-api-key-change-later}"
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
PATCH="${REPO}/deploy/vm112-wizard/ensure-carbonio-list-domains.py"
echo "==> VM112 wizard smoke + carbonio patch"
sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no "$PATCH" "${HOST}:/tmp/ensure-carbonio-list-domains.py"
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s <<EOF
set -e
python3 /tmp/ensure-carbonio-list-domains.py
systemctl restart ligbox-wizard
sleep 4
systemctl is-active ligbox-wizard
curl -sf -H "X-Api-Key: ${KEY}" http://127.0.0.1:8090/api/admin/domains | python3 -c "import sys,json; d=json.load(sys.stdin); print('domains', len(d.get('domains',[])), 'cached', d.get('cached'))"
EOF
echo "VM112 wizard OK"

View file

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Garante list_all_domains() em carbonio.py na VM112 (Serviços IaaS / Spec 017).
Executar NA VM112 após patches que sobrescrevem carbonio.py sem gad cache.
python3 ensure-carbonio-list-domains.py
systemctl restart ligbox-wizard
"""
from __future__ import annotations
from pathlib import Path
PATH = Path("/opt/ligbox-wizard/backend/app/services/carbonio.py")
MARKER = "\ndef set_domain_public_hostname(domain: str) -> str:"
INSERT = '''
def list_all_domains(*, use_cache: bool = True) -> list[str]:
"""zmprov gad (~5s) — cache TTL curto para Serviços IaaS / Desk."""
cache_key = carbonio_cache.CACHE_KEY_ALL_DOMAINS
if use_cache:
cached = carbonio_cache.get(cache_key)
if cached is not None:
return list(cached)
code, out, _err = _zmprov_run("gad", log_cmd=False)
if code != 0:
return []
domains = [ln.strip().lower() for ln in out.splitlines() if ln.strip()]
if use_cache:
carbonio_cache.set(cache_key, domains, carbonio_cache.TTL_ALL_DOMAINS)
return domains
def invalidate_domain_list_cache() -> None:
carbonio_cache.invalidate_all_domains()
'''
def main() -> None:
if not PATH.is_file():
raise SystemExit(f"ERRO: {PATH} não encontrado — executar na VM112")
text = PATH.read_text(encoding="utf-8")
if "def list_all_domains" in text:
print("OK — list_all_domains já presente")
return
if MARKER not in text:
raise SystemExit("ERRO: marker set_domain_public_hostname não encontrado")
PATH.write_text(text.replace(MARKER, INSERT + MARKER, 1), encoding="utf-8")
print("OK — list_all_domains inserido; reiniciar: systemctl restart ligbox-wizard")
if __name__ == "__main__":
main()

View file

@ -127,8 +127,29 @@ Ou rebuild: `docker-compose -f docker-compose.agentic-staging.yml build frontend
| Backup rollback | `index.legacy.html` · `app.legacy.js` |
| Script promote | `deploy/desk-staging-modern/promote-staging-to-prod.sh` |
| Script rollback | `deploy/desk-staging-modern/rollback-prod-to-legacy.sh` |
| README deploy | `deploy/desk-staging-modern/README.md` |
**Incluído na produção:** modals purge-history + infra-process, tickets-workspace, dns-viewer, filtro Purge eventos, Escopo OPS completo.
**Removido:** banners console-v2 inline (link Console no topnav substitui).
---
## Actualização 2026-07-02 — Agentic Ops no pacote de promote ✅
| Item | Valor |
|------|--------|
| Problema | Deploy parcial (30/0601/07) actualizou shell Desk sem `agentic-ops.js` / `.css` |
| Sintoma | UI MVP antiga (Frota + timeline) dentro do chrome v0.13 — página “desconfigurada” |
| Correcção | Spec 030 promovido; cache `?v=20260702spec30` |
| Prevenção | `promote-staging-to-prod.sh` e `deploy-staging-only.sh` **obrigam** `agentic-ops.*` + verificação ≥1200 linhas |
**Regra:** nunca `docker cp``index.html` / `app.staging.js` em produção sem o par Agentic. Ver `deploy/desk-staging-modern/README.md`.
### Checklist Agentic Ops (adicionar ao promote)
- [ ] `agentic-ops.js` ≥ 1200 linhas no container
- [ ] `grep "Squad Command"` no JS em produção
- [ ] Subnav: Squad Command · Agent Management · Task Board visíveis
- [ ] Hard refresh no browser após deploy

View file

@ -23,16 +23,16 @@ ligbox-ops-platform/
## Mapa rápido
| VM/CT | IP | SSH WAN | Papel | Deploy no repo |
|-------|-----|---------|-------|----------------|
| **112** | 10.10.10.112 | :2512 | Wizard onboard + Carbonio mail | `projects/wizard/` |
| **122** | 10.10.10.122 | :2522 | Ops Desk API + worker + UI MVP | `projects/ops-desk/` |
| **123** | 10.10.10.123 | :2523 | FOSSBilling + Odoo + OpenPanel + Console UI | `projects/finance/` |
| **104** | 10.10.10.104 | :2504 | Wazuh SIEM | integração Spec 002, 019 |
| **114** | 10.10.10.114 | — | Traefik (CT) | `docs/network/TRAEFIK_*` |
| **116** | 10.10.10.116 | :2516 | RustDesk relay + portal remoto | — |
| **124** | 10.10.10.124 | :2524 | **Nextcloud Hub** — storage mail (Spec 034) | `deploy/vm116-nextcloud/` |
| **130** | 10.10.10.130 | :2530 | **Spec Hub** Git + Obsidian + Portal | CT130 local |
| VM/CT | IP | SSH WAN | Senha SSH root | Papel | Deploy no repo |
|-------|-----|---------|----------------|-------|----------------|
| **112** | 10.10.10.112 | :2512 | **`@betinplace`** | Wizard onboard + Carbonio mail | `deploy/vm112-wizard/` |
| **122** | 10.10.10.122 | :2522 | `805353` | Ops Desk API + worker + UI MVP | `projects/ops-desk/` |
| **123** | 10.10.10.123 | :2523 | `805353` | FOSSBilling + Odoo + OpenPanel + Console UI | `projects/finance/` |
| **104** | 10.10.10.104 | :2504 | `805353` | Wazuh SIEM | integração Spec 002, 019 |
| **114** | 10.10.10.114 | — | — | Traefik (CT) | `docs/network/TRAEFIK_*` |
| **116** | 10.10.10.116 | :2516 | `805353` | RustDesk relay + portal remoto | — |
| **124** | 10.10.10.124 | :2524 | `805353` | **Nextcloud Hub** — storage mail (Spec 034) | `deploy/vm116-nextcloud/` |
| **130** | 10.10.10.130 | :2530 | `805353` | **Spec Hub** Git + Obsidian + Portal | CT130 local |
---

View file

@ -4,6 +4,8 @@
|------|-------|
| **IP LAN** | `10.10.10.112` |
| **SSH WAN** | `95.216.14.146:2512` |
| **SSH root (LAN)** | `sshpass -p '@betinplace' ssh root@10.10.10.112` |
| **Senha root** | **`@betinplace`** (≠ VM122/130 que usam `805353`) |
| **Hostname** | vm112-mail-ibytera |
| **URLs** | `onboard.ligbox.com.br` · API `:8090` |

View file

@ -0,0 +1,364 @@
"""Spec 039 — Catálogo de acções + overrides persistidos (Matriz editável)."""
from __future__ import annotations
import os
import sqlite3
from datetime import datetime, timezone
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
VALID_LEVELS = frozenset({"full", "read", "link", "api", "approve", "system", "none"})
EXECUTIVE_ROWS: list[dict[str, Any]] = [
{
"id": "desk-users-admin",
"area": "Criar / editar / freeze utilizadores Desk",
"who": ["SU"],
"why": "Credenciais internas Ligbox",
"groups": ["desk-auth"],
"action_ids": ["desk.auth.user.edit", "desk.auth.user.freeze", "desk.auth.user.password.reset"],
},
{
"id": "desk-registration",
"area": "Aprovar cadastros",
"who": ["SU", "CO"],
"why": "CO autónomo — Roger 2026-06-29",
"groups": ["desk-auth"],
"action_ids": ["desk.auth.user.approve_registration", "desk.auth.user.reject_registration"],
},
{
"id": "desk-freeze-policy",
"area": "Congelar users (SSU nunca)",
"who": ["SU"],
"why": "Segregação comercial",
"groups": ["desk-auth"],
"action_ids": ["desk.auth.user.freeze"],
},
{
"id": "vm112-purge",
"area": "Purge domínio / dados cliente",
"who": ["SU", "CO"],
"why": "Irreversível — Spec 032",
"groups": ["vm112"],
"action_ids": ["vm112.domain.purge", "vm112.purge.job.recover"],
},
{
"id": "billing-validate",
"area": "Validar billing / faturação",
"who": ["SU", "CO", "FIN", "SAD"],
"why": "Segregação comercial vs financeira",
"groups": ["desk-ops"],
"action_ids": ["desk.billing.state.validate"],
},
{
"id": "foss-orders",
"area": "FOSS pedidos e clientes",
"who": ["SAD", "SSU", "PTR"],
"why": "Linha de frente comercial",
"groups": ["vm123-foss"],
"action_ids": ["vm123_foss.client.create", "vm123_foss.order.create"],
},
{
"id": "foss-void",
"area": "FOSS faturas / void",
"who": ["FIN", "SU"],
"why": "Risco fiscal",
"groups": ["vm123-foss"],
"action_ids": ["vm123_foss.invoice.void"],
},
{
"id": "openpanel-delete",
"area": "OpenPanel delete instância",
"who": ["SU", "CO"],
"why": "Downtime cliente — Roger 2026-06-29",
"groups": ["vm123-openpanel"],
"action_ids": ["vm123_openpanel.site.delete"],
},
{
"id": "openpanel-content",
"area": "OpenPanel conteúdo sites",
"who": ["CMS", "SEO", "MKT"],
"why": "Operação editorial",
"groups": ["vm123-openpanel"],
"action_ids": ["vm123_openpanel.site.create", "vm123_openpanel.ssl.manage"],
},
{
"id": "tickets-assist",
"area": "Tickets / assist",
"who": ["TEC", "CO", "SU"],
"why": "Menor privilégio",
"groups": ["desk-ops"],
"action_ids": ["desk.ticket.patch", "desk.assist.takeover"],
},
{
"id": "infra-deploy",
"area": "Infra / deploy",
"who": ["DVO", "DEV", "SU"],
"why": "Separação código vs infra",
"groups": ["desk-ops"],
"action_ids": ["desk.infra.deploy"],
},
{
"id": "agents-a7",
"area": "Agentes A7 remediação",
"who": ["AIO", "CO", "SU"],
"why": "Human-in-the-loop",
"groups": ["agents"],
"action_ids": ["desk.agent.runbook.approve"],
},
{
"id": "modules-toggle",
"area": "Módulos Desk ON/OFF",
"who": ["SU"],
"why": "Feature flags globais",
"groups": ["desk-auth"],
"action_ids": ["desk.auth.modules.toggle"],
},
{
"id": "console-ops",
"area": "Console (mesma matriz Desk)",
"who": ["SU", "CO", "TEC", "SOC"],
"why": "Handoff Desk → Console",
"groups": ["console"],
"action_ids": ["console.runbook.execute", "console.case.assign"],
},
{
"id": "rbac-custom",
"area": "RBAC custom (templates)",
"who": ["SU"],
"why": "Herda CO ou TEC only",
"groups": ["desk-auth"],
"action_ids": ["desk.auth.role.create"],
},
]
ROLE_CODES: dict[str, str] = {
"super_admin": "SU",
"ops_lead": "CO",
"technician": "TEC",
"noc": "NOC",
"sales_admin": "SAD",
"sales_support": "SSU",
"finance": "FIN",
"marketing": "MKT",
"seo": "SEO",
"developer": "DEV",
"devops": "DVO",
"security_analyst": "SOC",
"content_editor": "CMS",
"agentic_operator": "AIO",
"partner": "PTR",
"api_service": "SVC",
"agent_system": "AGT",
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _catalog_paths() -> list[Path]:
env = os.getenv("ACTION_CATALOG_PATH", "").strip()
paths: list[Path] = []
if env:
paths.append(Path(env))
paths.extend(
[
Path("/opt/ligbox-ops-platform/specs/039-ligbox-ops-authorization-catalog/contracts/action-catalog.yaml"),
Path(__file__).resolve().parent / "data" / "action-catalog.yaml",
]
)
return paths
@lru_cache(maxsize=1)
def load_base_catalog() -> dict[str, Any]:
for path in _catalog_paths():
if path.is_file():
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
if isinstance(raw, dict) and raw.get("actions"):
return raw
raise FileNotFoundError("action-catalog.yaml not found")
def init_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS rbac_action_overrides (
action_id TEXT NOT NULL,
role_id TEXT NOT NULL,
level TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT,
PRIMARY KEY (action_id, role_id)
);
CREATE TABLE IF NOT EXISTS rbac_action_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action_id TEXT NOT NULL,
role_id TEXT NOT NULL,
old_level TEXT,
new_level TEXT NOT NULL,
username TEXT,
created_at TEXT NOT NULL
);
"""
)
conn.commit()
def _load_overrides(conn: sqlite3.Connection) -> dict[tuple[str, str], str]:
rows = conn.execute("SELECT action_id, role_id, level FROM rbac_action_overrides").fetchall()
return {(r["action_id"], r["role_id"]): r["level"] for r in rows}
def effective_level(
action: dict[str, Any],
role_id: str,
overrides: dict[tuple[str, str], str],
) -> str:
key = (action["id"], role_id)
if key in overrides:
return overrides[key]
return (action.get("roles") or {}).get(role_id, "none")
def export_catalog(conn: sqlite3.Connection, *, edit_enabled: bool) -> dict[str, Any]:
base = load_base_catalog()
overrides = _load_overrides(conn)
roles_meta = base.get("roles") or {}
for rid, code in ROLE_CODES.items():
if rid not in roles_meta:
roles_meta[rid] = {"code": code, "label": rid.replace("_", " ").title()}
actions_out: list[dict[str, Any]] = []
for action in base.get("actions") or []:
aid = action.get("id")
if not aid:
continue
defaults = action.get("roles") or {}
effective = {role: effective_level(action, role, overrides) for role in roles_meta}
overridden = {
role
for role in roles_meta
if (aid, role) in overrides and overrides[(aid, role)] != defaults.get(role, "none")
}
actions_out.append(
{
"id": aid,
"group": action.get("group"),
"label": action.get("label"),
"api": action.get("api"),
"why": action.get("why"),
"gap": bool(action.get("gap")),
"defaults": defaults,
"effective": effective,
"overridden_roles": sorted(overridden),
}
)
groups = base.get("groups") or []
group_by_id = {g["id"]: g for g in groups if g.get("id")}
executive = []
for row in EXECUTIVE_ROWS:
executive.append({**row, "editable": edit_enabled})
recent_audit = conn.execute(
"""
SELECT action_id, role_id, old_level, new_level, username, created_at
FROM rbac_action_audit ORDER BY id DESC LIMIT 30
"""
).fetchall()
return {
"spec": "039",
"version": base.get("version", "1.0"),
"editable": edit_enabled,
"roles": roles_meta,
"role_codes": ROLE_CODES,
"access_levels": base.get("access_levels") or {},
"groups": groups,
"group_by_id": group_by_id,
"executive_map": executive,
"actions": actions_out,
"stats": {"action_count": len(actions_out), "override_count": len(overrides)},
"recent_audit": [dict(r) for r in recent_audit],
}
def set_action_override(
conn: sqlite3.Connection,
*,
action_id: str,
role_id: str,
level: str,
username: str,
reset: bool = False,
) -> dict[str, Any]:
if level not in VALID_LEVELS:
raise ValueError(f"invalid level: {level}")
base = load_base_catalog()
roles_meta = base.get("roles") or {}
if role_id not in roles_meta and role_id not in ROLE_CODES:
raise ValueError(f"unknown role: {role_id}")
action = next((a for a in base.get("actions") or [] if a.get("id") == action_id), None)
if not action:
raise ValueError(f"unknown action: {action_id}")
defaults = action.get("roles") or {}
default_level = defaults.get(role_id, "none")
overrides = _load_overrides(conn)
old = overrides.get((action_id, role_id), default_level)
if reset or level == default_level:
conn.execute(
"DELETE FROM rbac_action_overrides WHERE action_id = ? AND role_id = ?",
(action_id, role_id),
)
new_level = default_level
else:
conn.execute(
"""
INSERT INTO rbac_action_overrides (action_id, role_id, level, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(action_id, role_id) DO UPDATE SET
level = excluded.level,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
""",
(action_id, role_id, level, _now(), username),
)
new_level = level
conn.execute(
"""
INSERT INTO rbac_action_audit (action_id, role_id, old_level, new_level, username, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(action_id, role_id, old, new_level, username, _now()),
)
conn.commit()
return {
"action_id": action_id,
"role_id": role_id,
"level": new_level,
"default_level": default_level,
"is_override": new_level != default_level,
}
def can_action(conn: sqlite3.Connection, role_id: str, action_id: str) -> bool:
"""True se nível efectivo != none."""
base = load_base_catalog()
action = next((a for a in base.get("actions") or [] if a.get("id") == action_id), None)
if not action:
return False
overrides = _load_overrides(conn)
return effective_level(action, role_id, overrides) != "none"

View file

@ -2,6 +2,8 @@
from __future__ import annotations
import secrets
import string
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
@ -41,6 +43,22 @@ class UserUpdateRequest(BaseModel):
display_name: str | None = None
class UserCreateRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
password: str = Field(min_length=6)
role: str
display_name: str | None = None
phone: str | None = Field(default=None, max_length=32)
active: bool = True
class UserCloneRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
password: str | None = Field(default=None, min_length=6)
display_name: str | None = None
active: bool = True
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1)
new_password: str = Field(min_length=8)
@ -185,6 +203,79 @@ def list_users(user: auth.DeskUser = Depends(auth.get_current_user)):
return {"users": users}
def _normalize_username(raw: str) -> str:
target = raw.strip()
if target.lower() != "root":
target = target.lower()
return target
def _fetch_user_public(conn, username: str) -> dict:
row = conn.execute(
"""
SELECT u.username, u.role, u.display_name, u.active, u.last_login_at,
u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled,
(SELECT COUNT(*) FROM desk_backup_codes b
WHERE b.username = u.username AND b.used_at IS NULL) AS backup_codes_remaining
FROM desk_users u WHERE u.username = ?
""",
(username,),
).fetchone()
if not row:
raise HTTPException(404, "user not found")
item = auth.user_public_dict(row)
item["totp_enabled"] = bool(row["totp_enabled"])
item["backup_codes_remaining"] = int(row["backup_codes_remaining"] or 0)
return item
@router.post("/users")
def create_user(
body: UserCreateRequest,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
email = body.email.strip().lower()
if "@" not in email:
raise HTTPException(400, "email inválido")
if body.role not in ROLES:
raise HTTPException(400, "invalid role")
if body.role == "super_admin" and user.role != "super_admin":
raise HTTPException(403, "apenas Super Admin pode criar Super Admin")
display = (body.display_name or email.split("@")[0]).strip() or email.split("@")[0]
now = datetime.now(timezone.utc).isoformat()
with auth.db() as conn:
exists = conn.execute(
"SELECT 1 FROM desk_users WHERE username = ? OR email = ?",
(email, email),
).fetchone()
if exists:
raise HTTPException(409, "utilizador já existe")
conn.execute(
"""
INSERT INTO desk_users
(username, password_hash, role, display_name, email, phone,
mfa_enabled, totp_secret, totp_enabled, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, NULL, 0, ?, ?, ?)
""",
(
email,
auth.hash_password(body.password),
body.role,
display,
email,
body.phone,
1 if body.active else 0,
now,
now,
),
)
conn.commit()
item = _fetch_user_public(conn, email)
return {"user": item, "message": "Utilizador criado"}
@router.patch("/users/{username}")
def update_user(
username: str,
@ -193,9 +284,7 @@ def update_user(
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
target = username.strip()
if target.lower() != "root":
target = target.lower()
target = _normalize_username(username)
updates: list[str] = []
params: list[object] = []
if body.role is not None:
@ -226,20 +315,87 @@ def update_user(
conn.commit()
if cur.rowcount == 0:
raise HTTPException(404, "user not found")
item = _fetch_user_public(conn, target)
return {"user": item}
@router.delete("/users/{username}")
def delete_user(
username: str,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
target = _normalize_username(username)
if target == "root":
raise HTTPException(400, "não é possível eliminar root")
if target == user.username.lower():
raise HTTPException(400, "não pode eliminar a sua própria conta")
with auth.db() as conn:
row = conn.execute(
"""
SELECT u.username, u.role, u.display_name, u.active, u.last_login_at,
u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled,
(SELECT COUNT(*) FROM desk_backup_codes b
WHERE b.username = u.username AND b.used_at IS NULL) AS backup_codes_remaining
FROM desk_users u WHERE u.username = ?
""",
"SELECT username FROM desk_users WHERE username = ?",
(target,),
).fetchone()
item = auth.user_public_dict(row)
item["totp_enabled"] = bool(row["totp_enabled"])
item["backup_codes_remaining"] = int(row["backup_codes_remaining"] or 0)
return {"user": item}
if not row:
raise HTTPException(404, "user not found")
conn.execute("DELETE FROM desk_backup_codes WHERE username = ?", (target,))
conn.execute("DELETE FROM desk_users WHERE username = ?", (target,))
conn.commit()
return {"ok": True, "message": f"Utilizador {target} eliminado"}
@router.post("/users/{username}/clone")
def clone_user(
username: str,
body: UserCloneRequest,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
source = _normalize_username(username)
email = body.email.strip().lower()
if "@" not in email:
raise HTTPException(400, "email inválido")
pwd = body.password or "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
now = datetime.now(timezone.utc).isoformat()
with auth.db() as conn:
src = conn.execute(
"SELECT username, role, display_name FROM desk_users WHERE username = ?",
(source,),
).fetchone()
if not src:
raise HTTPException(404, "utilizador origem não encontrado")
exists = conn.execute(
"SELECT 1 FROM desk_users WHERE username = ? OR email = ?",
(email, email),
).fetchone()
if exists:
raise HTTPException(409, "utilizador destino já existe")
display = (body.display_name or f"{src['display_name'] or src['username']} (cópia)").strip()
conn.execute(
"""
INSERT INTO desk_users
(username, password_hash, role, display_name, email, phone,
mfa_enabled, totp_secret, totp_enabled, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, 0, NULL, 0, ?, ?, ?)
""",
(
email,
auth.hash_password(pwd),
src["role"],
display,
email,
1 if body.active else 0,
now,
now,
),
)
conn.commit()
item = _fetch_user_public(conn, email)
out = {"user": item, "message": "Utilizador copiado"}
if not body.password:
out["generated_password"] = pwd
return out
@router.post("/users/{username}/reset-2fa")
@ -249,9 +405,7 @@ def reset_user_2fa(
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
target = username.strip()
if target.lower() != "root":
target = target.lower()
target = _normalize_username(username)
if target == "root":
raise HTTPException(400, "não é possível resetar 2FA do root por aqui")
now = datetime.now(timezone.utc).isoformat()

View file

@ -0,0 +1,263 @@
"""Desk governance — audit log, user meta, module access. Spec 040 · DS-API-001."""
from __future__ import annotations
import json
import secrets
import sqlite3
from datetime import datetime, timezone
from typing import Any
GOVERNANCE_MODULES = (
"desk",
"openpanel",
"billing",
"api",
"security",
"ai_agents",
)
ACCESS_LEVELS = ("none", "read", "partial", "full")
ROLE_GROUPS: dict[str, str] = {
"super_admin": "Ops",
"ops_lead": "Ops",
"technician": "Ops",
"noc": "Ops",
"sales_admin": "Comercial",
"sales_support": "Comercial",
"finance": "Negócio",
"marketing": "Negócio",
"seo": "Negócio",
"developer": "Plataforma",
"devops": "Plataforma",
"security_analyst": "Plataforma",
"content_editor": "Plataforma",
"agentic_operator": "Plataforma",
"partner": "Externo",
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_governance_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS desk_governance_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_username TEXT NOT NULL,
actor_role TEXT,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
summary TEXT NOT NULL,
payload_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_desk_gov_audit_target
ON desk_governance_audit(target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_desk_gov_audit_created
ON desk_governance_audit(created_at DESC);
CREATE TABLE IF NOT EXISTS desk_user_meta (
username TEXT PRIMARY KEY,
internal_id TEXT NOT NULL UNIQUE,
main_group TEXT,
secondary_groups_json TEXT NOT NULL DEFAULT '[]',
account_status TEXT NOT NULL DEFAULT 'active',
force_password_change INTEGER NOT NULL DEFAULT 0,
api_access INTEGER NOT NULL DEFAULT 0,
notifications_enabled INTEGER NOT NULL DEFAULT 1,
module_permissions_json TEXT NOT NULL DEFAULT '{}',
invite_token TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
def _gen_internal_id() -> str:
return f"LB-{secrets.token_hex(4).upper()}"
def _gen_invite_token() -> str:
return secrets.token_urlsafe(24)
def group_for_role(role: str) -> str:
return ROLE_GROUPS.get(role, "")
def default_module_permissions(role: str) -> dict[str, str]:
base = {m: "none" for m in GOVERNANCE_MODULES}
if role == "super_admin":
return {m: "full" for m in GOVERNANCE_MODULES}
if role in ("ops_lead", "devops"):
base.update({"desk": "full", "openpanel": "partial", "security": "partial", "api": "read"})
elif role == "technician":
base.update({"desk": "partial", "openpanel": "read"})
elif role == "finance":
base.update({"billing": "full", "desk": "read"})
elif role == "agentic_operator":
base.update({"ai_agents": "full", "desk": "partial"})
return base
def log_audit(
conn: sqlite3.Connection,
*,
actor_username: str,
actor_role: str | None,
action: str,
target_type: str,
target_id: str,
summary: str,
payload: dict | None = None,
) -> dict:
now = _now()
conn.execute(
"""
INSERT INTO desk_governance_audit
(actor_username, actor_role, action, target_type, target_id, summary, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
actor_username,
actor_role,
action,
target_type,
target_id,
summary,
json.dumps(payload or {}, ensure_ascii=False),
now,
),
)
return {
"actor_username": actor_username,
"actor_role": actor_role,
"action": action,
"target_type": target_type,
"target_id": target_id,
"summary": summary,
"created_at": now,
}
def list_audit(
conn: sqlite3.Connection,
*,
target_type: str | None = None,
target_id: str | None = None,
limit: int = 50,
) -> list[dict]:
q = "SELECT * FROM desk_governance_audit WHERE 1=1"
params: list[Any] = []
if target_type:
q += " AND target_type = ?"
params.append(target_type)
if target_id:
q += " AND target_id = ?"
params.append(target_id)
q += " ORDER BY id DESC LIMIT ?"
params.append(limit)
rows = conn.execute(q, params).fetchall()
out = []
for row in rows:
item = dict(row)
try:
item["payload"] = json.loads(item.pop("payload_json") or "{}")
except json.JSONDecodeError:
item["payload"] = {}
out.append(item)
return out
def get_user_meta(conn: sqlite3.Connection, username: str) -> dict | None:
row = conn.execute(
"SELECT * FROM desk_user_meta WHERE username = ?",
(username,),
).fetchone()
if not row:
return None
item = dict(row)
try:
item["secondary_groups"] = json.loads(item.pop("secondary_groups_json") or "[]")
except json.JSONDecodeError:
item["secondary_groups"] = []
try:
item["module_permissions"] = json.loads(item.pop("module_permissions_json") or "{}")
except json.JSONDecodeError:
item["module_permissions"] = {}
item["force_password_change"] = bool(item.get("force_password_change"))
item["api_access"] = bool(item.get("api_access"))
item["notifications_enabled"] = bool(item.get("notifications_enabled"))
return item
def ensure_user_meta(conn: sqlite3.Connection, username: str, role: str) -> dict:
existing = get_user_meta(conn, username)
if existing:
return existing
now = _now()
perms = default_module_permissions(role)
conn.execute(
"""
INSERT INTO desk_user_meta
(username, internal_id, main_group, secondary_groups_json, account_status,
force_password_change, api_access, notifications_enabled, module_permissions_json,
invite_token, notes, created_at, updated_at)
VALUES (?, ?, ?, '[]', 'active', 0, 0, 1, ?, NULL, NULL, ?, ?)
""",
(
username,
_gen_internal_id(),
group_for_role(role),
json.dumps(perms),
now,
now,
),
)
return get_user_meta(conn, username) or {}
def upsert_user_meta(conn: sqlite3.Connection, username: str, **fields: Any) -> dict:
row = get_user_meta(conn, username)
now = _now()
if not row:
raise ValueError("user meta missing")
secondary = fields.get("secondary_groups", row.get("secondary_groups", []))
perms = fields.get("module_permissions", row.get("module_permissions", {}))
conn.execute(
"""
UPDATE desk_user_meta SET
main_group = ?,
secondary_groups_json = ?,
account_status = ?,
force_password_change = ?,
api_access = ?,
notifications_enabled = ?,
module_permissions_json = ?,
invite_token = ?,
notes = ?,
updated_at = ?
WHERE username = ?
""",
(
fields.get("main_group", row.get("main_group")),
json.dumps(secondary),
fields.get("account_status", row.get("account_status", "active")),
1 if fields.get("force_password_change", row.get("force_password_change")) else 0,
1 if fields.get("api_access", row.get("api_access")) else 0,
1 if fields.get("notifications_enabled", row.get("notifications_enabled", True)) else 0,
json.dumps(perms),
fields.get("invite_token", row.get("invite_token")),
fields.get("notes", row.get("notes")),
now,
username,
),
)
return get_user_meta(conn, username) or {}

View file

@ -0,0 +1,123 @@
"""Email relay VM122 → VM112 — status, config e teste (Spec 004 / Infra CODE)."""
from __future__ import annotations
import json
import os
import smtplib
from datetime import datetime, timezone
from email.message import EmailMessage
from pathlib import Path
from typing import Any
from app import mail_notify
CONFIG_PATH = Path(os.getenv("EMAIL_RELAY_CONFIG_PATH", "/data/email_relay_config.json"))
RELAY_HOST = os.getenv("DESK_RELAY_HOST", "10.10.10.112")
RELAY_PORT = int(os.getenv("DESK_RELAY_PORT", "25"))
SMTP_HOST = os.getenv("DESK_SMTP_HOST", "10.10.10.122")
SMTP_PORT = int(os.getenv("DESK_SMTP_PORT", "25"))
MAIL_FROM = os.getenv("DESK_MAIL_FROM", "ligbox-ops@ligbox.com.br")
DEFAULT_CONFIG: dict[str, Any] = {
"vm": "122",
"vm_label": "VM122 · Ops Desk",
"service_id": "vm122-email-relay",
"title": "Email Relay (Postfix)",
"relayhost": RELAY_HOST,
"relayport": RELAY_PORT,
"smtp_host": SMTP_HOST,
"smtp_port": SMTP_PORT,
"mail_from": MAIL_FROM,
"myorigin": "ligbox.com.br",
"transport_local": {
"ligbox.com.br": "LMTP [10.10.10.112]:7025",
"ibytera.com": "LMTP [10.10.10.112]:7025",
"dratcoin.com": "LMTP [10.10.10.112]:7025",
},
"external_route": "relayhost → mail.ligbox.com.br (DKIM/SPF)",
"docs": "docs/postfix-vm122.md",
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def load_config() -> dict[str, Any]:
cfg = dict(DEFAULT_CONFIG)
cfg["updated_at"] = _now()
if CONFIG_PATH.is_file():
try:
stored = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
if isinstance(stored, dict):
cfg.update(stored)
except Exception:
pass
return cfg
def save_config(patch: dict[str, Any]) -> dict[str, Any]:
cfg = load_config()
cfg.update(patch)
cfg["updated_at"] = _now()
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return cfg
def _probe_smtp(host: str, port: int, timeout: float = 8.0) -> dict[str, Any]:
try:
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
code, _ = smtp.ehlo()
smtp.noop()
ok = 200 <= code < 400
return {"ok": ok, "status": "online" if ok else "check", "detail": f"SMTP {host}:{port} EHLO {code}"}
except Exception as exc:
return {"ok": False, "status": "down", "detail": f"SMTP {host}:{port}{exc}"}
def build_status() -> dict[str, Any]:
cfg = load_config()
local = _probe_smtp(cfg["smtp_host"], int(cfg["smtp_port"]))
relay = _probe_smtp(cfg["relayhost"], int(cfg["relayport"]))
ok = bool(local.get("ok") and relay.get("ok"))
detail_parts = [
local.get("detail", ""),
f"relay {cfg['relayhost']}:{cfg['relayport']}{relay.get('detail', '')}",
]
return {
"ok": ok,
"status": "online" if ok else ("check" if local.get("ok") else "down"),
"detail": " · ".join(p for p in detail_parts if p),
"config": cfg,
"checks": {
"smtp_local": local,
"smtp_relay": relay,
},
"generated_at": _now(),
}
def probe_stack() -> dict[str, Any]:
st = build_status()
return {"ok": st["ok"], "status": st["status"], "detail": st["detail"]}
def send_test_email(to: str, subject: str | None = None) -> dict[str, Any]:
to = (to or "").strip()
if not to:
return {"ok": False, "detail": "destinatário vazio"}
subj = subject or "[Ligbox Ops] Teste Email Relay VM122"
body = (
"Teste do relay Postfix VM122 → VM112 (mail.ligbox.com.br).\n\n"
f"Gerado em: {_now()}\n"
)
ok = mail_notify.send_email(to, subj, body)
return {
"ok": ok,
"detail": "enviado via SMTP local" if ok else "falha SMTP — verificar Postfix/relay",
"to": to,
"from": MAIL_FROM,
"at": _now(),
}

View file

@ -0,0 +1,45 @@
"""Rotas Email Relay VM122 — configuração visível no portal Infra."""
from __future__ import annotations
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException
from app import auth, email_relay
router = APIRouter(prefix="/api/v1/infra/email-relay", tags=["infra-email-relay"])
def _can_manage(user: auth.DeskUser) -> bool:
return user.role in ("super_admin", "devops")
def _can_view(user: auth.DeskUser) -> bool:
return user.role in ("super_admin", "devops", "developer")
class EmailRelayTestBody(BaseModel):
to: str = Field(min_length=3, max_length=254)
subject: str | None = Field(default=None, max_length=200)
@router.get("/status")
def email_relay_status(user: auth.DeskUser = Depends(auth.get_current_user)):
if not _can_view(user):
raise HTTPException(403, "permissão insuficiente")
return email_relay.build_status()
@router.get("/config")
def email_relay_config(user: auth.DeskUser = Depends(auth.get_current_user)):
if not _can_view(user):
raise HTTPException(403, "permissão insuficiente")
return email_relay.load_config()
@router.post("/test")
def email_relay_test(body: EmailRelayTestBody, user: auth.DeskUser = Depends(auth.get_current_user)):
if not _can_manage(user):
raise HTTPException(403, "permissão insuficiente")
return email_relay.send_test_email(body.to, body.subject)

View file

@ -0,0 +1,336 @@
"""Governance API — user wizard, audit. Spec 040 · DS-API-002."""
from __future__ import annotations
import json
import secrets
import string
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app import auth, mail_notify
from app.desk_governance_store import (
ACCESS_LEVELS,
GOVERNANCE_MODULES,
ROLE_GROUPS,
default_module_permissions,
ensure_user_meta,
get_user_meta,
group_for_role,
init_governance_schema,
list_audit,
log_audit,
upsert_user_meta,
)
from app.permissions import ROLES, can_manage_users
router = APIRouter(prefix="/api/v1/governance", tags=["governance"])
class ModulePermissions(BaseModel):
desk: str = "none"
openpanel: str = "none"
billing: str = "none"
api: str = "none"
security: str = "none"
ai_agents: str = "none"
class UserWizardRequest(BaseModel):
display_name: str = Field(min_length=1, max_length=120)
email: str = Field(min_length=3, max_length=320)
phone: str | None = Field(default=None, max_length=32)
password: str = Field(min_length=6)
account_status: str = Field(default="active", pattern="^(active|frozen|pending|invited)$")
force_password_change: bool = False
mfa_required: bool = False
notifications_enabled: bool = True
api_access: bool = False
role: str
main_group: str | None = None
secondary_groups: list[str] = Field(default_factory=list)
module_permissions: ModulePermissions | None = None
notes: str | None = Field(default=None, max_length=200)
send_invite_email: bool = True
activate_account: bool = True
def _db():
conn = auth.db()
try:
init_governance_schema(conn)
yield conn
finally:
conn.close()
def _validate_levels(perms: dict[str, str]) -> None:
for mod, lv in perms.items():
if mod not in GOVERNANCE_MODULES:
raise HTTPException(400, f"módulo inválido: {mod}")
if lv not in ACCESS_LEVELS:
raise HTTPException(400, f"nível inválido: {lv}")
@router.get("/modules")
def governance_modules(user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
return {
"modules": list(GOVERNANCE_MODULES),
"levels": list(ACCESS_LEVELS),
"groups": sorted(set(ROLE_GROUPS.values())),
}
@router.get("/audit")
def governance_audit(
target_type: str | None = Query(default=None),
target_id: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
return {"events": list_audit(conn, target_type=target_type, target_id=target_id, limit=limit)}
@router.get("/users/{username}/meta")
def user_meta(
username: str,
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
meta = get_user_meta(conn, username)
if not meta:
row = auth._user_row(username)
if not row:
raise HTTPException(404, "user not found")
meta = ensure_user_meta(conn, username, row["role"])
conn.commit()
return {"meta": meta}
@router.get("/users/stats")
def users_stats(
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
rows = conn.execute(
"SELECT username, role, active FROM desk_users"
).fetchall()
total = len(rows)
active = sum(1 for r in rows if r["active"])
frozen = total - active
super_admin = sum(1 for r in rows if r["role"] == "super_admin")
return {
"total": total,
"active": active,
"frozen": frozen,
"super_admin": super_admin,
}
@router.post("/users/wizard")
def create_user_wizard(
body: UserWizardRequest,
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
email = body.email.strip().lower()
if "@" not in email:
raise HTTPException(400, "email inválido")
if body.role not in ROLES:
raise HTTPException(400, "invalid role")
if body.role == "super_admin" and user.role != "super_admin":
raise HTTPException(403, "apenas Super Admin pode criar Super Admin")
perms = (
body.module_permissions.model_dump()
if body.module_permissions
else default_module_permissions(body.role)
)
_validate_levels(perms)
main_group = body.main_group or group_for_role(body.role)
now = datetime.now(timezone.utc).isoformat()
active = body.activate_account and body.account_status == "active"
invite_token = secrets.token_urlsafe(24)
exists = conn.execute(
"SELECT 1 FROM desk_users WHERE username = ? OR email = ?",
(email, email),
).fetchone()
if exists:
raise HTTPException(409, "utilizador já existe")
conn.execute(
"""
INSERT INTO desk_users
(username, password_hash, role, display_name, email, phone,
mfa_enabled, totp_secret, totp_enabled, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)
""",
(
email,
auth.hash_password(body.password),
body.role,
body.display_name.strip(),
email,
body.phone,
1 if body.mfa_required else 0,
1 if active else 0,
now,
now,
),
)
internal_id = f"LB-{secrets.token_hex(4).upper()}"
conn.execute(
"""
INSERT INTO desk_user_meta
(username, internal_id, main_group, secondary_groups_json, account_status,
force_password_change, api_access, notifications_enabled, module_permissions_json,
invite_token, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
email,
internal_id,
main_group,
json.dumps(body.secondary_groups),
body.account_status,
1 if body.force_password_change else 0,
1 if body.api_access else 0,
1 if body.notifications_enabled else 0,
json.dumps(perms),
invite_token,
body.notes,
now,
now,
),
)
actor_label = user.display_name or user.username
audit = log_audit(
conn,
actor_username=user.username,
actor_role=user.role,
action="user.created",
target_type="user",
target_id=email,
summary=f"Created by {actor_label}",
payload={"role": body.role, "internal_id": internal_id},
)
conn.commit()
invite_link = f"https://desk.ligbox.com.br/register.html?invite={invite_token}"
invite_email_sent = False
if body.send_invite_email:
try:
invite_email_sent = mail_notify.send_email(
email,
"Convite Ligbox Ops Desk",
f"Olá {body.display_name},\n\nFoi criada a sua conta.\nDefina a sua senha: {invite_link}\n",
)
except Exception:
invite_email_sent = False
row = conn.execute(
"""
SELECT u.username, u.role, u.display_name, u.active, u.last_login_at,
u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled
FROM desk_users u WHERE u.username = ?
""",
(email,),
).fetchone()
public = auth.user_public_dict(row)
meta = get_user_meta(conn, email)
return {
"user": public,
"meta": meta,
"audit": audit,
"internal_id": internal_id,
"invite_link": invite_link,
"invite_email_sent": invite_email_sent if body.send_invite_email else None,
"message": "Utilizador criado",
}
@router.post("/users/{username}/freeze")
def freeze_user(
username: str,
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
target = username.strip().lower() if username.lower() != "root" else "root"
if target == "root":
raise HTTPException(400, "não é possível congelar root")
row = auth._user_row(target)
if not row:
raise HTTPException(404, "user not found")
new_active = not bool(row["active"])
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"UPDATE desk_users SET active = ?, updated_at = ? WHERE username = ?",
(1 if new_active else 0, now, target),
)
meta = ensure_user_meta(conn, target, row["role"])
upsert_user_meta(conn, target, account_status="active" if new_active else "frozen")
verb = "Activated" if new_active else "Frozen"
actor_label = user.display_name or user.username
audit = log_audit(
conn,
actor_username=user.username,
actor_role=user.role,
action="user.frozen" if not new_active else "user.activated",
target_type="user",
target_id=target,
summary=f"{verb} by {actor_label}",
)
conn.commit()
return {"user": auth.user_public_dict(auth._user_row(target)), "audit": audit}
@router.post("/users/{username}/reset-password")
def admin_reset_password(
username: str,
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
target = username.strip().lower() if username.lower() != "root" else "root"
row = auth._user_row(target)
if not row:
raise HTTPException(404, "user not found")
pwd = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"UPDATE desk_users SET password_hash = ?, updated_at = ? WHERE username = ?",
(auth.hash_password(pwd), now, target),
)
actor_label = user.display_name or user.username
audit = log_audit(
conn,
actor_username=user.username,
actor_role=user.role,
action="user.password.reset",
target_type="user",
target_id=target,
summary=f"Password reset by {actor_label}",
)
conn.commit()
return {"ok": True, "generated_password": pwd, "audit": audit}

View file

@ -28,9 +28,12 @@ from app.migration.router import router as migration_router
from app.billing_routes import router as billing_router
from app.security_routes import router as security_router
from app.infra_stack_routes import router as infra_stack_router
from app.email_relay_routes import router as email_relay_router
from app.vm123.routes import router as vm123_router
from app.agents.routes import router as agents_router
from app.rbac_routes import router as rbac_router
from app.governance_routes import router as governance_router
from app.ops_inbox_routes import router as ops_inbox_router
from app.domain_console_sandbox_routes import router as domain_console_sandbox_router
from app.domain_console_routes import router as domain_console_router
from app.agents.store import init_agent_schema
@ -132,7 +135,7 @@ _cors_raw = os.getenv(
)
_cors_origins = [o.strip() for o in _cors_raw.split(",") if o.strip()]
app = FastAPI(title="Ligbox Ops Platform API", version="0.9.7-spec029-agentic")
app = FastAPI(title="Ligbox Ops Platform API", version="0.13.0-design-system")
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins or ["*"],
@ -152,9 +155,12 @@ app.include_router(carbonio_release_router)
app.include_router(migration_router)
app.include_router(billing_router)
app.include_router(infra_stack_router)
app.include_router(email_relay_router)
app.include_router(vm123_router)
app.include_router(agents_router)
app.include_router(rbac_router)
app.include_router(governance_router)
app.include_router(ops_inbox_router)
app.include_router(domain_console_sandbox_router)
app.include_router(domain_console_router)
@ -213,6 +219,14 @@ def init_db():
from app import agent_bindings
agent_bindings.init_schema(conn)
from app import action_catalog
action_catalog.init_schema(conn)
from app.desk_governance_store import init_governance_schema
from app.ops_inbox_store import init_inbox_schema
init_governance_schema(conn)
init_inbox_schema(conn)
from app.domain_console_sandbox_store import init_schema as init_dcs_schema
init_dcs_schema(conn)

View file

@ -0,0 +1,119 @@
"""Operational feed API. Spec 041 · OF-API-002."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app import auth
from app.ops_inbox_store import (
add_message,
get_event,
inbox_stats,
init_inbox_schema,
list_events,
patch_event,
)
from app.permissions import can_manage_users
router = APIRouter(prefix="/api/v1/ops-inbox", tags=["ops-inbox"])
class InboxMessageBody(BaseModel):
body: str = Field(min_length=1, max_length=8000)
note_type: str = Field(default="reply", pattern="^(reply|internal_note)$")
class InboxPatchBody(BaseModel):
status: str | None = Field(default=None, pattern="^(open|pending|resolved)$")
assignee: str | None = None
priority: str | None = Field(default=None, pattern="^(normal|high|critical)$")
def _db():
conn = auth.db()
try:
init_inbox_schema(conn)
yield conn
finally:
conn.close()
def _require_inbox(user: auth.DeskUser) -> None:
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
@router.get("/stats")
def inbox_statistics(
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
_require_inbox(user)
return inbox_stats(conn)
@router.get("/events")
def inbox_events(
channel: str | None = Query(default="all"),
priority: str | None = Query(default=None),
status: str | None = Query(default=None),
q: str | None = Query(default=None),
limit: int = Query(default=128, ge=1, le=500),
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
_require_inbox(user)
items = list_events(conn, channel=channel, priority=priority, status=status, q=q, limit=limit)
return {"events": items, "total": len(items)}
@router.get("/events/{event_id}")
def inbox_event_detail(
event_id: str,
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
_require_inbox(user)
ev = get_event(conn, event_id)
if not ev:
raise HTTPException(404, "event not found")
return {"event": ev}
@router.post("/events/{event_id}/messages")
def inbox_post_message(
event_id: str,
body: InboxMessageBody,
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
_require_inbox(user)
if not get_event(conn, event_id):
raise HTTPException(404, "event not found")
author_type = "internal" if body.note_type == "internal_note" else "operator"
label = user.display_name or user.username
msg = add_message(conn, event_id, author_type, label, body.body)
conn.commit()
return {"message": msg}
@router.patch("/events/{event_id}")
def inbox_patch(
event_id: str,
body: InboxPatchBody,
user: auth.DeskUser = Depends(auth.get_current_user),
conn=Depends(_db),
):
_require_inbox(user)
ev = patch_event(
conn,
event_id,
status=body.status,
assignee=body.assignee,
priority=body.priority,
)
if not ev:
raise HTTPException(404, "event not found")
conn.commit()
return {"event": ev}

View file

@ -0,0 +1,338 @@
"""Operational feed mock store. Spec 041 · OF-API-001."""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone, timedelta
from typing import Any
CHANNELS = (
("all", "Todos os canais"),
("email", "Email"),
("whatsapp", "WhatsApp API"),
("voice", "Telefonia / Voz"),
("sms", "SMS"),
("telegram", "Telegram"),
("tickets", "Tickets"),
("agents", "Agentes IA"),
("internal", "Solicitações internas"),
("clients", "Clientes"),
("alerts", "Alertas sistema"),
)
PRIORITIES = ("normal", "high", "critical")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _minutes_ago(minutes: int) -> str:
return (datetime.now(timezone.utc) - timedelta(minutes=minutes)).isoformat()
def init_inbox_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS ops_inbox_events (
id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
event_type TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'normal',
title TEXT NOT NULL,
preview TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
assignee TEXT,
status TEXT NOT NULL DEFAULT 'open',
contact_name TEXT,
contact_company TEXT,
contact_cnpj TEXT,
contact_client_id TEXT,
sla_minutes INTEGER NOT NULL DEFAULT 60,
sla_remaining_sec INTEGER NOT NULL DEFAULT 3600,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ops_inbox_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL,
author_type TEXT NOT NULL,
author_label TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (event_id) REFERENCES ops_inbox_events(id)
);
CREATE INDEX IF NOT EXISTS idx_ops_inbox_events_channel ON ops_inbox_events(channel);
CREATE INDEX IF NOT EXISTS idx_ops_inbox_events_status ON ops_inbox_events(status);
"""
)
count = conn.execute("SELECT COUNT(*) c FROM ops_inbox_events").fetchone()["c"]
if count == 0:
_seed_inbox(conn)
def _seed_inbox(conn: sqlite3.Connection) -> None:
now = _now()
seeds = [
{
"id": "evt-wa-001",
"channel": "whatsapp",
"event_type": "message",
"priority": "high",
"title": "Cliente: Empresa Alpha — Não consigo acessar o painel financeiro",
"preview": "Bom dia, após o login aparece erro 403 no módulo billing…",
"tags": ["Cliente", "Acesso", "Infraestrutura"],
"assignee": "Editor",
"status": "open",
"contact_name": "Empresa Alpha",
"contact_company": "Empresa Alpha Ltda",
"contact_cnpj": "12.345.678/0001-90",
"contact_client_id": "CLI-8842",
"sla_minutes": 15,
"sla_remaining_sec": 750,
"created_at": _minutes_ago(2),
},
{
"id": "evt-int-002",
"channel": "internal",
"event_type": "request",
"priority": "normal",
"title": "Carlos — Financeiro: Aprovar reembolso #8821",
"preview": "Solicitação interna aguardando aprovação do Chefe Ops",
"tags": ["Interno", "Financeiro"],
"assignee": "Super Admin",
"status": "pending",
"contact_name": "Carlos Mendes",
"contact_company": "Ligbox Ops",
"contact_cnpj": "",
"contact_client_id": "",
"sla_minutes": 120,
"sla_remaining_sec": 5400,
"created_at": _minutes_ago(18),
},
{
"id": "evt-ai-003",
"channel": "agents",
"event_type": "alert",
"priority": "critical",
"title": "Agente A3 — CPU VM122 acima de 92%",
"preview": "Alerta automático: load average 4.2 — recomendação de escala",
"tags": ["Agente IA", "Infra", "VM122"],
"assignee": "AI Agent",
"status": "open",
"contact_name": "Watchman A3",
"contact_company": "Ligbox Platform",
"contact_cnpj": "",
"contact_client_id": "",
"sla_minutes": 30,
"sla_remaining_sec": 1200,
"created_at": _minutes_ago(5),
},
{
"id": "evt-em-004",
"channel": "email",
"event_type": "message",
"priority": "normal",
"title": "Empresa Beta — Pedido de upgrade de plano",
"preview": "Gostaríamos de migrar para o plano enterprise…",
"tags": ["Cliente", "Comercial"],
"assignee": "Sales Admin",
"status": "open",
"contact_name": "Empresa Beta",
"contact_company": "Beta Serviços SA",
"contact_cnpj": "98.765.432/0001-10",
"contact_client_id": "CLI-1201",
"sla_minutes": 240,
"sla_remaining_sec": 12000,
"created_at": _minutes_ago(45),
},
{
"id": "evt-tk-005",
"channel": "tickets",
"event_type": "ticket",
"priority": "high",
"title": "Ticket #4412 — Erro importação DNS",
"preview": "Falha ao sincronizar zona dns.example.com",
"tags": ["Ticket", "DNS"],
"assignee": "NOC",
"status": "open",
"contact_name": "Suporte N1",
"contact_company": "Cliente Gamma",
"contact_cnpj": "",
"contact_client_id": "CLI-3300",
"sla_minutes": 60,
"sla_remaining_sec": 2100,
"created_at": _minutes_ago(12),
},
{
"id": "evt-vc-006",
"channel": "voice",
"event_type": "missed_call",
"priority": "normal",
"title": "Chamada perdida — +55 11 98765-4321",
"preview": "Duração 0s · fila comercial",
"tags": ["Telefonia"],
"assignee": None,
"status": "open",
"contact_name": "Desconhecido",
"contact_company": "",
"contact_cnpj": "",
"contact_client_id": "",
"sla_minutes": 30,
"sla_remaining_sec": 900,
"created_at": _minutes_ago(8),
},
]
for s in seeds:
conn.execute(
"""
INSERT INTO ops_inbox_events
(id, channel, event_type, priority, title, preview, tags_json, assignee, status,
contact_name, contact_company, contact_cnpj, contact_client_id,
sla_minutes, sla_remaining_sec, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
s["id"], s["channel"], s["event_type"], s["priority"], s["title"], s["preview"],
json.dumps(s["tags"]), s["assignee"], s["status"],
s["contact_name"], s["contact_company"], s["contact_cnpj"], s["contact_client_id"],
s["sla_minutes"], s["sla_remaining_sec"], s["created_at"], s["created_at"],
),
)
msgs = [
("evt-wa-001", "user", "Empresa Alpha", "Bom dia, não consigo acessar o painel financeiro."),
("evt-wa-001", "agent", "AI Agent", "Detectei erro 403 no módulo billing. Encaminhei para suporte."),
("evt-wa-001", "system", "Sistema", "SLA iniciado — 15 min"),
]
for event_id, author_type, author_label, body in msgs:
conn.execute(
"""
INSERT INTO ops_inbox_messages (event_id, author_type, author_label, body, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(event_id, author_type, author_label, body, now),
)
def inbox_stats(conn: sqlite3.Connection) -> dict:
total = conn.execute("SELECT COUNT(*) c FROM ops_inbox_events").fetchone()["c"]
pending = conn.execute(
"SELECT COUNT(*) c FROM ops_inbox_events WHERE status IN ('open','pending')"
).fetchone()["c"]
critical = conn.execute(
"SELECT COUNT(*) c FROM ops_inbox_events WHERE priority = 'critical'"
).fetchone()["c"]
channels = []
for cid, label in CHANNELS:
if cid == "all":
cnt = total
else:
cnt = conn.execute(
"SELECT COUNT(*) c FROM ops_inbox_events WHERE channel = ?",
(cid,),
).fetchone()["c"]
channels.append({"id": cid, "label": label, "count": cnt})
return {
"events_today": total,
"pending": pending,
"critical": critical,
"awaiting_you": conn.execute(
"SELECT COUNT(*) c FROM ops_inbox_events WHERE assignee IS NOT NULL AND status = 'open'"
).fetchone()["c"],
"sla_avg_pct": 96,
"channels": channels,
}
def list_events(
conn: sqlite3.Connection,
*,
channel: str | None = None,
priority: str | None = None,
status: str | None = None,
q: str | None = None,
limit: int = 128,
) -> list[dict]:
sql = "SELECT * FROM ops_inbox_events WHERE 1=1"
params: list[Any] = []
if channel and channel != "all":
sql += " AND channel = ?"
params.append(channel)
if priority:
sql += " AND priority = ?"
params.append(priority)
if status:
sql += " AND status = ?"
params.append(status)
if q:
sql += " AND (title LIKE ? OR preview LIKE ? OR contact_name LIKE ?)"
like = f"%{q}%"
params.extend([like, like, like])
sql += " ORDER BY datetime(created_at) DESC LIMIT ?"
params.append(limit)
rows = conn.execute(sql, params).fetchall()
out = []
for row in rows:
item = dict(row)
try:
item["tags"] = json.loads(item.pop("tags_json") or "[]")
except json.JSONDecodeError:
item["tags"] = []
out.append(item)
return out
def get_event(conn: sqlite3.Connection, event_id: str) -> dict | None:
row = conn.execute("SELECT * FROM ops_inbox_events WHERE id = ?", (event_id,)).fetchone()
if not row:
return None
item = dict(row)
item["tags"] = json.loads(item.pop("tags_json") or "[]")
msgs = conn.execute(
"""
SELECT id, author_type, author_label, body, created_at
FROM ops_inbox_messages WHERE event_id = ? ORDER BY id ASC
""",
(event_id,),
).fetchall()
item["messages"] = [dict(m) for m in msgs]
return item
def add_message(conn: sqlite3.Connection, event_id: str, author_type: str, author_label: str, body: str) -> dict:
now = _now()
cur = conn.execute(
"""
INSERT INTO ops_inbox_messages (event_id, author_type, author_label, body, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(event_id, author_type, author_label, body.strip(), now),
)
conn.execute(
"UPDATE ops_inbox_events SET updated_at = ? WHERE id = ?",
(now, event_id),
)
return {"id": cur.lastrowid, "event_id": event_id, "author_type": author_type,
"author_label": author_label, "body": body.strip(), "created_at": now}
def patch_event(conn: sqlite3.Connection, event_id: str, **fields: Any) -> dict | None:
allowed = {"status", "assignee", "priority"}
updates = []
params: list[Any] = []
for k, v in fields.items():
if k in allowed and v is not None:
updates.append(f"{k} = ?")
params.append(v)
if not updates:
ev = get_event(conn, event_id)
return ev
updates.append("updated_at = ?")
params.append(_now())
params.append(event_id)
conn.execute(
f"UPDATE ops_inbox_events SET {', '.join(updates)} WHERE id = ?",
params,
)
return get_event(conn, event_id)

View file

@ -19,6 +19,7 @@ BUSINESS_ROLES = frozenset(
"security_analyst",
"content_editor",
"agentic_operator",
"partner",
}
)
@ -51,6 +52,7 @@ ROLE_LABELS: dict[str, str] = {
"security_analyst": "Segurança / SOC",
"content_editor": "Conteúdo / CMS",
"agentic_operator": "Operador Agentes IA",
"partner": "Partner",
"api_service": "API Service",
"agent_system": "Agent System",
}
@ -230,6 +232,11 @@ def can_manage_users(role: str) -> bool:
return role == "super_admin"
def can_approve_registration(role: str) -> bool:
"""Aprovar/rejeitar pedidos de cadastro — Spec 039 (SU + CO)."""
return role in ("super_admin", "ops_lead")
def can_manage_vm112_domains(role: str) -> bool:
"""Admin Desk — domínios orquestrados VM112 (Spec 017)."""
return role in ("super_admin", "ops_lead", "devops")
@ -302,7 +309,8 @@ def can_openpanel_provision(role: str) -> bool:
def can_openpanel_delete(role: str) -> bool:
return role in ("super_admin", "devops")
"""Deletar instância OpenPanel — Spec 039: apenas SU + CO."""
return role in ("super_admin", "ops_lead")
def roles_meta() -> dict:

View file

@ -468,6 +468,8 @@ def matrix_export(conn) -> dict[str, Any]:
"tabs": [
{"id": "overview", "label": "Visão da função", "type": "overview",
"hint": "Desk + software + APIs da função seleccionada"},
{"id": "quem-faz-o-que", "label": "Quem faz o quê", "type": "executive_map",
"hint": "Mapa executivo Spec 039 — editar permissões por acção"},
{"id": "vm122", "label": "Matriz Desk", "type": "desk_modules",
"hint": "VM122 — módulos internos da plataforma"},
{"id": "software", "label": "Software & Infra", "type": "software",
@ -476,6 +478,9 @@ def matrix_export(conn) -> dict[str, Any]:
"hint": "Bindings Odoo-style — grupos, roles, permissões"},
{"id": "agents", "label": "Agentes IA", "type": "agents",
"hint": "A0A7 — orquestração e aprovações"},
{"id": "access-control", "label": "Controle de acesso", "type": "user_admin",
"hint": "Criação de acessos · usuários · cadastros · senhas · ambientes",
"separated": True},
],
"scope_layers": [
{"id": "vm122", "label": "Desk VM122", "host": "10.10.10.122", "desc": "Módulos internos"},

View file

@ -8,7 +8,7 @@ import sqlite3
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app import agent_bindings, auth
from app import agent_bindings, auth, action_catalog
from app.rbac_matrix import matrix_export
router = APIRouter(prefix="/api/v1", tags=["rbac"])
@ -77,6 +77,45 @@ class GovernanceCapPatch(BaseModel):
enabled: bool
class ActionOverridePatch(BaseModel):
action_id: str = Field(..., min_length=5, max_length=128)
role_id: str = Field(..., min_length=2, max_length=32)
level: str = Field(..., pattern="^(full|read|link|api|approve|system|none)$")
reset: bool = False
@router.get("/rbac/actions")
def rbac_actions_catalog(
user: auth.DeskUser = Depends(auth.get_current_user),
conn: sqlite3.Connection = Depends(_db),
):
"""Spec 039 — mapa executivo + catálogo de acções (defaults + overrides)."""
_require_matrix_view(user)
return action_catalog.export_catalog(conn, edit_enabled=_access_matrix_edit_enabled())
@router.patch("/rbac/actions/override")
def patch_action_override(
body: ActionOverridePatch,
user: auth.DeskUser = Depends(auth.get_current_user),
conn: sqlite3.Connection = Depends(_db),
):
"""Alterar permissão de uma acção × função (persiste + audit)."""
_require_matrix_edit(user)
try:
result = action_catalog.set_action_override(
conn,
action_id=body.action_id,
role_id=body.role_id,
level=body.level,
username=user.username,
reset=body.reset,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return result
@router.patch("/rbac/agent-bindings")
def patch_agent_binding(
body: AgentBindingPatch,

View file

@ -6,13 +6,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app import auth, desk_tickets, mail_notify, registration_store
from app.permissions import ROLES, can_manage_users
from app.permissions import ASSIGNABLE_ROLES, can_approve_registration
from app import ntfy_notify
from app.totp_util import otpauth_uri
router = APIRouter(prefix="/api/v1/auth", tags=["registration"])
ASSIGNABLE_ROLES = frozenset({"ops_lead", "technician", "noc"})
REGISTRATION_ASSIGNABLE_ROLES = ASSIGNABLE_ROLES
class RegisterRequest(BaseModel):
@ -63,8 +63,14 @@ def register(body: RegisterRequest):
}
def _require_registration_approver(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
if not can_approve_registration(user.role):
raise HTTPException(403, "forbidden")
return user
@router.get("/registration-requests")
def list_registration_requests(user: auth.DeskUser = Depends(auth.require_roles("super_admin"))):
def list_registration_requests(user: auth.DeskUser = Depends(_require_registration_approver)):
with auth.db() as conn:
items = registration_store.list_requests(conn)
pending = sum(1 for i in items if i["status"] == "pending")
@ -75,10 +81,12 @@ def list_registration_requests(user: auth.DeskUser = Depends(auth.require_roles(
def approve_registration(
request_id: int,
body: ApproveRequest,
user: auth.DeskUser = Depends(auth.require_roles("super_admin")),
user: auth.DeskUser = Depends(_require_registration_approver),
):
if body.role not in ASSIGNABLE_ROLES:
raise HTTPException(400, f"role must be one of: {', '.join(sorted(ASSIGNABLE_ROLES))}")
if body.role not in REGISTRATION_ASSIGNABLE_ROLES:
raise HTTPException(
400, f"role must be one of: {', '.join(sorted(REGISTRATION_ASSIGNABLE_ROLES))}"
)
try:
with auth.db() as conn:
row = registration_store.approve_request(conn, request_id, body.role, user.username)
@ -107,7 +115,7 @@ def approve_registration(
def reject_registration(
request_id: int,
body: RejectRequest,
user: auth.DeskUser = Depends(auth.require_roles("super_admin")),
user: auth.DeskUser = Depends(_require_registration_approver),
):
try:
with auth.db() as conn:

View file

@ -27,18 +27,35 @@ def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _normalize_expect_status(expect_status: int | tuple[int, ...] | list[int]) -> tuple[int, ...]:
"""Garante tupla de códigos HTTP — evita bug Python `(200)` vs `(200,)`.
Ver Spec 033 § Incidente probe `expect_status` (2026-06-29).
"""
if isinstance(expect_status, int):
return (expect_status,)
if isinstance(expect_status, list):
return tuple(int(x) for x in expect_status)
if isinstance(expect_status, tuple):
if len(expect_status) == 1 and isinstance(expect_status[0], int):
return expect_status
return tuple(int(x) for x in expect_status)
raise TypeError(f"expect_status inválido: {type(expect_status)!r}")
def _probe_http(
*,
url: str,
timeout: float = 8.0,
verify: bool = True,
headers: dict[str, str] | None = None,
expect_status: tuple[int, ...] = (200),
expect_status: int | tuple[int, ...] | list[int] = (200,),
) -> dict[str, Any]:
try:
codes = _normalize_expect_status(expect_status)
with httpx.Client(timeout=timeout, verify=verify, follow_redirects=True) as client:
res = client.get(url, headers=headers or {})
ok = res.status_code in expect_status
ok = res.status_code in codes
return {
"ok": ok,
"status": "online" if ok else "check",
@ -60,6 +77,15 @@ def _probe_redis(redis_url: str) -> dict[str, Any]:
return {"ok": False, "status": "down", "detail": str(exc)}
def _probe_email_relay() -> dict[str, Any]:
try:
from app import email_relay
return email_relay.probe_stack()
except Exception as exc:
return {"ok": False, "status": "down", "detail": str(exc)}
def build_stack_catalog() -> list[dict[str, Any]]:
"""Catálogo estático — apps, APIs e software do stack Ligbox."""
redis_url = os.getenv("REDIS_URL", "redis://redis:6379/0")
@ -116,7 +142,7 @@ def build_stack_catalog() -> list[dict[str, Any]]:
"url": f"{VM112_API}/api/admin/domains",
"probe": lambda: _probe_http(
url=f"{VM112_API}/api/onboarding/health",
expect_status=(200),
expect_status=(200,),
),
},
],
@ -222,6 +248,16 @@ def build_stack_catalog() -> list[dict[str, Any]]:
"url": "/api/v1/infra/purge-auth-domains",
"probe": lambda: {"ok": True, "status": "online", "detail": "módulo local"},
},
{
"id": "vm122-email-relay",
"title": "Email Relay (Postfix)",
"spec": "004",
"kind": "sw",
"icon": "📨",
"accent": "slate",
"url": "/api/v1/infra/email-relay/status",
"probe": _probe_email_relay,
},
],
},
{

View file

@ -0,0 +1,62 @@
"""Tests — Spec 039 action catalog."""
from __future__ import annotations
import sqlite3
import unittest
from app import action_catalog
class TestActionCatalog(unittest.TestCase):
def setUp(self):
self.conn = sqlite3.connect(":memory:")
self.conn.row_factory = sqlite3.Row
action_catalog.init_schema(self.conn)
action_catalog.load_base_catalog.cache_clear()
def tearDown(self):
action_catalog.load_base_catalog.cache_clear()
self.conn.close()
def test_export_has_executive_map(self):
data = action_catalog.export_catalog(self.conn, edit_enabled=True)
self.assertGreaterEqual(len(data["executive_map"]), 10)
self.assertGreaterEqual(data["stats"]["action_count"], 50)
def test_override_persists(self):
action_catalog.set_action_override(
self.conn,
action_id="desk.auth.user.approve_registration",
role_id="technician",
level="full",
username="root",
)
data = action_catalog.export_catalog(self.conn, edit_enabled=True)
action = next(a for a in data["actions"] if a["id"] == "desk.auth.user.approve_registration")
self.assertEqual(action["effective"]["technician"], "full")
self.assertIn("technician", action["overridden_roles"])
def test_reset_override(self):
action_catalog.set_action_override(
self.conn,
action_id="vm123_openpanel.site.delete",
role_id="devops",
level="full",
username="root",
)
action_catalog.set_action_override(
self.conn,
action_id="vm123_openpanel.site.delete",
role_id="devops",
level="none",
username="root",
reset=True,
)
data = action_catalog.export_catalog(self.conn, edit_enabled=True)
action = next(a for a in data["actions"] if a["id"] == "vm123_openpanel.site.delete")
self.assertEqual(action["effective"]["devops"], "none")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,84 @@
"""Unit tests — Spec 033 stack health probes (expect_status hardening)."""
from __future__ import annotations
import importlib.util
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
API_ROOT = Path(__file__).resolve().parent
if not (API_ROOT / "app" / "stack_health.py").is_file():
API_ROOT = API_ROOT.parent
def _load_stack_health():
path = API_ROOT / "app/stack_health.py"
spec = importlib.util.spec_from_file_location("stack_health_033", path)
if spec is None or spec.loader is None:
raise ImportError(path)
mod = importlib.util.module_from_spec(spec)
sys.modules["stack_health_033"] = mod
spec.loader.exec_module(mod)
return mod
sh = _load_stack_health()
class TestNormalizeExpectStatus(unittest.TestCase):
def test_single_int(self):
self.assertEqual(sh._normalize_expect_status(200), (200,))
def test_single_element_tuple_with_comma(self):
self.assertEqual(sh._normalize_expect_status((200,)), (200,))
def test_multi_status_tuple(self):
self.assertEqual(sh._normalize_expect_status((200, 301, 302)), (200, 301, 302))
def test_list_from_yaml_contract(self):
self.assertEqual(sh._normalize_expect_status([200, 403]), (200, 403))
def test_invalid_type_raises(self):
with self.assertRaises(TypeError):
sh._normalize_expect_status("200") # type: ignore[arg-type]
class TestProbeHttpExpectStatus(unittest.TestCase):
@patch("stack_health_033.httpx.Client")
def test_int_expect_status_does_not_crash(self, client_cls):
res = MagicMock()
res.status_code = 200
client_cls.return_value.__enter__.return_value.get.return_value = res
out = sh._probe_http(url="http://example.test/health", expect_status=200)
self.assertTrue(out["ok"])
self.assertEqual(out["http_status"], 200)
self.assertEqual(out["detail"], "HTTP 200")
@patch("stack_health_033.httpx.Client")
def test_default_expect_status_tuple(self, client_cls):
res = MagicMock()
res.status_code = 200
client_cls.return_value.__enter__.return_value.get.return_value = res
out = sh._probe_http(url="http://example.test/")
self.assertTrue(out["ok"])
@patch("stack_health_033.httpx.Client")
def test_wrong_status_marks_check(self, client_cls):
res = MagicMock()
res.status_code = 503
client_cls.return_value.__enter__.return_value.get.return_value = res
out = sh._probe_http(url="http://example.test/", expect_status=(200,))
self.assertFalse(out["ok"])
self.assertEqual(out["status"], "check")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,676 @@
/**
* Access Control Hub Gestão de utilizadores
* Spec 040 · DS-FE-004 · Matriz aba Controle de acesso
*/
(function () {
'use strict';
const ROLE_META = [
{ value: 'super_admin', label: 'Super Admin', group: 'Ops', code: 'SU' },
{ value: 'ops_lead', label: 'Chefe Ops', group: 'Ops', code: 'CO' },
{ value: 'technician', label: 'Suporte', group: 'Ops', code: 'TEC' },
{ value: 'noc', label: 'NOC', group: 'Ops', code: 'NOC' },
{ value: 'sales_admin', label: 'Sales Admin', group: 'Comercial', code: 'SAD' },
{ value: 'sales_support', label: 'Sales Support', group: 'Comercial', code: 'SSU' },
{ value: 'finance', label: 'Financeiro', group: 'Negócio', code: 'FIN' },
{ value: 'marketing', label: 'Marketing', group: 'Negócio', code: 'MKT' },
{ value: 'seo', label: 'SEO', group: 'Negócio', code: 'SEO' },
{ value: 'developer', label: 'Developer', group: 'Plataforma', code: 'DEV' },
{ value: 'devops', label: 'DevOps', group: 'Plataforma', code: 'DVO' },
{ value: 'security_analyst', label: 'Segurança / SOC', group: 'Plataforma', code: 'SOC' },
{ value: 'content_editor', label: 'Conteúdo / CMS', group: 'Plataforma', code: 'CMS' },
{ value: 'agentic_operator', label: 'Operador Agentes IA', group: 'Plataforma', code: 'AIO' },
{ value: 'partner', label: 'Partner', group: 'Externo', code: 'PTR' },
];
let host = null;
let opts = {};
let subTab = 'users';
let users = [];
let stats = { total: 0, active: 0, frozen: 0, super_admin: 0 };
let selectedUser = null;
let detailTab = 'details';
let filterQ = '';
let filterRole = 'all';
let filterGroup = 'all';
let filterStatus = 'all';
let confirmModal = null;
let createMenuOpen = false;
const favorites = new Set();
const SVG = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round"';
const ICONS = {
users: `<svg ${SVG}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.5"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
userCheck: `<svg ${SVG}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.5"/><path d="m16 11 2 2 4.5-4.5"/></svg>`,
snowflake: `<svg ${SVG}><path d="M12 2v20M2 12h20M4.93 4.93l14.14 14.14M19.07 4.93 4.93 19.07M12 2l2.5 4.5M12 2 9.5 6.5M12 22l2.5-4.5M12 22 9.5 17.5M2 12l4.5 2.5M2 12l4.5-2.5M22 12l-4.5 2.5M22 12l-4.5-2.5"/></svg>`,
shield: `<svg ${SVG}><path d="M12 22s7-3.5 8-10V6l-8-3.5L4 6v6c1 6.5 8 10 8 10z"/></svg>`,
plus: `<svg ${SVG} stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
chevronDown: `<svg ${SVG} stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>`,
filter: `<svg ${SVG}><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>`,
search: `<svg ${SVG}><circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>`,
more: `<svg ${SVG} stroke-width="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>`,
star: `<svg ${SVG}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
close: `<svg ${SVG} stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
edit: `<svg ${SVG}><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>`,
freeze: `<svg ${SVG}><path d="M12 2v20M2 12h20M4.93 4.93l14.14 14.14M19.07 4.93 4.93 19.07"/></svg>`,
copy: `<svg ${SVG}><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
lock: `<svg ${SVG}><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
shieldCheck: `<svg ${SVG}><path d="M12 22s7-3.5 8-10V6l-8-3.5L4 6v6c1 6.5 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>`,
usersGroup: `<svg ${SVG}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.5"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
trash: `<svg ${SVG}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
checkCircle: `<svg ${SVG}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m22 4-10 10-3-3"/></svg>`,
infoShield: `<svg ${SVG}><path d="M12 22s7-3.5 8-10V6l-8-3.5L4 6v6c1 6.5 8 10 8 10z"/></svg>`,
};
function fmtDateTime(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
const date = d.toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit', year: 'numeric' });
const time = d.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit', hour12: false });
return `${date} ${time}`;
} catch (_) {
return iso;
}
}
function kpiCard(iconKey, tone, value, label, sub) {
return `
<article class="ach-kpi-card">
<div class="ach-kpi-icon ach-kpi-icon--${tone}">${ICONS[iconKey]}</div>
<div class="ach-kpi-body">
<span class="ach-kpi-value">${esc(value)}</span>
<span class="ach-kpi-label">${esc(label)}</span>
<span class="ach-kpi-sub">${esc(sub)}</span>
</div>
</article>`;
}
function quickAction(iconKey, label, dataAttr) {
return `
<button type="button" class="ach-quick-card" ${dataAttr}>
${ICONS[iconKey]}
<span>${esc(label)}</span>
</button>`;
}
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function roleMeta(id) {
return ROLE_META.find((r) => r.value === id) || { label: id, group: '—', code: '?' };
}
function fmtDate(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleDateString('pt-PT');
} catch (_) {
return iso;
}
}
function initials(name) {
const p = String(name || '?').trim().split(/\s+/);
return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase() || '?';
}
async function loadData() {
const [uRes, sRes] = await Promise.all([
fetchWithTimeout('/api/v1/auth/users', { headers: authHeaders() }),
fetchWithTimeout('/api/v1/governance/users/stats', { headers: authHeaders() }),
]);
if (!uRes.ok) throw new Error(String(uRes.status));
if (!sRes.ok) throw new Error(String(sRes.status));
const uData = await uRes.json();
stats = await sRes.json();
users = uData.users || [];
}
function filteredUsers() {
const q = filterQ.trim().toLowerCase();
const roleLock = opts.selectedRole;
return users.filter((u) => {
if (selectedUser && u.username === selectedUser) return true;
const rm = roleMeta(u.role);
if (roleLock && u.role !== roleLock && !(roleLock === 'super_admin' && u.username === 'root')) return false;
if (filterRole !== 'all' && u.role !== filterRole) return false;
if (filterGroup !== 'all' && rm.group !== filterGroup) return false;
if (filterStatus === 'active' && !u.active) return false;
if (filterStatus === 'frozen' && u.active) return false;
if (!q) return true;
const hay = [u.username, u.email, u.display_name, rm.label, rm.group].join(' ').toLowerCase();
return hay.includes(q);
});
}
function createSplitButton() {
return `
<div class="ach-create-split">
<button type="button" class="ach-create-main" data-ach-create>
${ICONS.plus}
Criar utilizador
</button>
<button type="button" class="ach-create-menu" data-ach-create-menu aria-label="Mais opções de criação">
${ICONS.chevronDown}
</button>
<div class="ach-create-dropdown${createMenuOpen ? '' : ' is-hidden'}" data-ach-create-dropdown>
<button type="button" data-ach-create-user>Criar utilizador</button>
<button type="button" data-ach-create-support>Criar Perfil Suporte</button>
</div>
</div>`;
}
function subnavHtml() {
return `
<nav class="lb-subnav">
<button type="button" class="lb-subtab${subTab === 'users' ? ' active' : ''}" data-ach-sub="users">Gestão de utilizadores</button>
<button type="button" class="lb-subtab${subTab === 'capabilities' ? ' active' : ''}" data-ach-sub="capabilities">Capacidades da função</button>
</nav>`;
}
function kpiSectionHtml() {
return `
<div class="ach-kpi-section">
<div class="ach-kpi-top">
${subnavHtml()}
<div class="ach-create-col">${createSplitButton()}</div>
</div>
<div class="ach-kpi-row">
${kpiCard('users', 'blue', stats.total, 'Total de usuários', `${stats.active} activos • ${stats.frozen} congelados`)}
${kpiCard('userCheck', 'green', stats.active, 'Activos', 'Usuários com acesso')}
${kpiCard('snowflake', 'orange', stats.frozen, 'Congelados', 'Acesso bloqueado')}
${kpiCard('shield', 'purple', stats.super_admin, 'Super Admin', 'Permissão máxima')}
</div>
</div>`;
}
function filterField(label, inner) {
return `
<label class="ach-field">
<span class="ach-field__label">${esc(label)}</span>
${inner}
</label>`;
}
function userTableRow(u) {
const rm = roleMeta(u.role);
const sel = selectedUser === u.username ? ' ach-table-row--selected' : '';
const tfaHtml = u.totp_enabled
? `<span class="ach-tfa ach-tfa--ok">${ICONS.checkCircle}</span>`
: `<span class="ach-tfa ach-tfa--warn" title="2FA inactivo">⚠</span>`;
const statusHtml = u.active
? `<span class="ach-status ach-status--active"><span class="ach-status-dot"></span>Activo</span>`
: `<span class="ach-status ach-status--frozen">${ICONS.snowflake} Congelado</span>`;
return `
<tr class="ach-table-row${sel}" data-ach-user="${esc(u.username)}">
<td class="ach-col-user">
<div class="ach-user-cell">
<span class="ach-user-cell__avatar">${esc(initials(u.display_name || u.username))}</span>
<span class="ach-user-cell__text">
<strong class="ach-user-cell__name">${esc(u.display_name || u.username)}</strong>
<span class="ach-user-cell__email">${esc(u.email || u.username)}</span>
</span>
</div>
</td>
<td class="ach-col-profile">
<span class="ach-role-pill">${esc(rm.code)}</span>
<span>${esc(rm.label)}</span>
</td>
<td>${esc(rm.group)}</td>
<td>${statusHtml}</td>
<td class="ach-col-center">${tfaHtml}</td>
<td class="ach-col-date">${fmtDateTime(u.last_login_at)}</td>
<td class="ach-col-actions">
<button type="button" class="ach-row-menu" data-ach-user="${esc(u.username)}" aria-label="Acções">${ICONS.more}</button>
</td>
</tr>`;
}
function usersTable(list) {
return `
<div class="ach-table-card">
<table class="ach-users-table">
<thead>
<tr>
<th>Utilizador</th>
<th>Perfil</th>
<th>Grupo</th>
<th>Estado</th>
<th>2FA</th>
<th>Último acesso</th>
<th>Acções</th>
</tr>
</thead>
<tbody>
${list.length
? list.map(userTableRow).join('')
: '<tr><td colspan="7" class="ach-table-empty">Nenhum utilizador encontrado.</td></tr>'}
</tbody>
</table>
</div>
<div class="ach-table-foot">
<span class="ach-table-count">Mostrando ${list.length} de ${users.length}</span>
</div>`;
}
function usersPanel() {
const list = filteredUsers();
const groups = [...new Set(ROLE_META.map((r) => r.group))];
const roleLock = opts.selectedRole;
const roleLockMeta = roleMeta(roleLock);
const roleLockBanner = roleLock ? `
<div class="ach-audit-banner" style="margin:0 0 10px">
${ICONS.infoShield}
<span>A mostrar apenas utilizadores da função <strong>${esc(roleLockMeta.label)}</strong> (seleccionada na Matriz). Para ver todos, escolha outra função na barra lateral ou use o filtro Perfil abaixo.</span>
</div>` : '';
return `
<div class="ach-layout">
<div class="ach-left">
${kpiSectionHtml()}
${roleLockBanner}
<div class="ach-toolbar">
<label class="ach-field ach-field--search">
<span class="ach-field__label">Pesquisar</span>
<span class="ach-search-wrap">
<span class="ach-search-icon" aria-hidden="true">${ICONS.search}</span>
<input type="search" class="ach-field__input ach-field__input--search" data-ach-search
placeholder="Pesquisar por nome, e-mail ou perfil…" value="${esc(filterQ)}"/>
</span>
</label>
${filterField('Perfil', `
<select class="ach-field__select" data-ach-filter-role>
<option value="all"${filterRole === 'all' ? ' selected' : ''}>Todos</option>
${ROLE_META.map((r) => `<option value="${r.value}"${filterRole === r.value ? ' selected' : ''}>${esc(r.label)}</option>`).join('')}
</select>`)}
${filterField('Grupo', `
<select class="ach-field__select" data-ach-filter-group>
<option value="all"${filterGroup === 'all' ? ' selected' : ''}>Todos os grupos</option>
${groups.map((g) => `<option value="${esc(g)}"${filterGroup === g ? ' selected' : ''}>${esc(g)}</option>`).join('')}
</select>`)}
${filterField('Estado', `
<select class="ach-field__select" data-ach-filter-status>
<option value="all"${filterStatus === 'all' ? ' selected' : ''}>Todos</option>
<option value="active"${filterStatus === 'active' ? ' selected' : ''}>Activos</option>
<option value="frozen"${filterStatus === 'frozen' ? ' selected' : ''}>Congelados</option>
</select>`)}
<button type="button" class="ach-filters-btn" data-ach-filters>
${ICONS.filter}
Filtros
</button>
</div>
${usersTable(list)}
</div>
<aside class="ach-detail-col" data-ach-detail>${detailPanel()}</aside>
</div>`;
}
function detailPanel() {
const u = users.find((x) => x.username === selectedUser);
if (!u) {
return `<div class="ach-user-panel"><div class="ach-user-panel__body"><p class="ach-kpi-sub">Seleccione um utilizador</p></div></div>`;
}
const rm = roleMeta(u.role);
const isRoot = u.username === 'root';
const isFav = favorites.has(u.username);
const tfaOn = u.totp_enabled;
let tabBody = '';
if (detailTab === 'details') {
tabBody = `
<div class="ach-kv-list">
<div class="ach-kv-row">
<span class="ach-kv-label">Perfil</span>
<span class="ach-kv-value ach-profile-badge">
<span class="ach-profile-code">${esc(rm.code)}</span>
${esc(rm.label)}
</span>
</div>
<div class="ach-kv-row">
<span class="ach-kv-label">Grupo</span>
<span class="ach-kv-value">${esc(rm.group)}</span>
</div>
<div class="ach-kv-row">
<span class="ach-kv-label">Telefone</span>
<span class="ach-kv-value">${esc(u.phone || '—')}</span>
</div>
<div class="ach-kv-row">
<span class="ach-kv-label">Criado em</span>
<span class="ach-kv-value">${fmtDateTime(u.created_at)}</span>
</div>
<div class="ach-kv-row">
<span class="ach-kv-label">Último acesso</span>
<span class="ach-kv-value">${fmtDateTime(u.last_login_at)}</span>
</div>
<div class="ach-kv-row">
<span class="ach-kv-label">2FA</span>
<span class="ach-kv-value">
${tfaOn
? `<span class="ach-2fa-ok">${ICONS.checkCircle} Activado</span>`
: 'Não activo'}
</span>
</div>
</div>`;
} else if (detailTab === 'permissions') {
tabBody = `<p class="ach-kpi-sub">Permissões herdadas do perfil <strong>${esc(rm.label)}</strong>. Edição avançada na aba «Quem faz o quê».</p>
<p style="margin-top:12px"><a href="#" data-ach-goto-matrix>Abrir mapa de permissões</a></p>`;
} else {
tabBody = `<div class="lb-audit-list" data-ach-audit><p class="ach-kpi-sub">A carregar…</p></div>`;
loadAudit(u.username);
}
return `
<div class="ach-user-panel">
<header class="ach-user-panel__head">
<div class="ach-user-panel__identity">
<div class="ach-user-avatar">${esc(initials(u.display_name || u.username))}</div>
<div>
<div class="ach-user-name-row">
<span class="ach-user-name">${esc(u.display_name || u.username)}</span>
<span class="ach-user-badge${u.active ? '' : ' ach-user-badge--frozen'}">${u.active ? 'Activo' : 'Congelado'}</span>
</div>
<p class="ach-user-email">${esc(u.email || u.username)}</p>
</div>
</div>
<div class="ach-user-panel__tools">
<button type="button" class="ach-icon-btn ach-icon-btn--fav${isFav ? ' active' : ''}" data-ach-favorite title="Favorito">${ICONS.star}</button>
<button type="button" class="ach-icon-btn" data-ach-close-panel title="Fechar">${ICONS.close}</button>
</div>
</header>
<nav class="ach-user-tabs">
<button type="button" class="ach-user-tab${detailTab === 'details' ? ' active' : ''}" data-ach-dtab="details">Detalhes</button>
<button type="button" class="ach-user-tab${detailTab === 'permissions' ? ' active' : ''}" data-ach-dtab="permissions">Permissões</button>
<button type="button" class="ach-user-tab${detailTab === 'activities' ? ' active' : ''}" data-ach-dtab="activities">Atividades</button>
</nav>
<div class="ach-user-panel__body">${tabBody}</div>
<section class="ach-quick-section">
<h4 class="ach-quick-title">Ações rápidas</h4>
<div class="ach-quick-grid">
${quickAction('edit', 'Editar utilizador', 'data-ach-edit')}
${quickAction('freeze', u.active ? 'Congelar conta' : 'Activar conta', 'data-ach-freeze')}
${quickAction('copy', 'Copiar utilizador', 'data-ach-clone')}
${quickAction('lock', 'Redefinir senha', 'data-ach-reset-pwd')}
${quickAction('shieldCheck', 'Resetar 2FA', 'data-ach-reset-2fa')}
${quickAction('usersGroup', 'Gerenciar grupos', 'data-ach-groups')}
</div>
${!isRoot ? `
<button type="button" class="ach-delete-btn" data-ach-deactivate>
${ICONS.trash}
Eliminar utilizador
</button>` : ''}
</section>
<div class="ach-audit-banner">
${ICONS.infoShield}
<span>As alterações realizadas aqui são registadas em audit log e não podem ser desfeitas.</span>
</div>
</div>`;
}
async function loadAudit(username) {
const el = host?.querySelector('[data-ach-audit]');
if (!el) return;
try {
const r = await fetchWithTimeout(
`/api/v1/governance/audit?target_type=user&target_id=${encodeURIComponent(username)}&limit=20`,
{ headers: authHeaders() },
);
const data = await r.json();
const events = data.events || [];
el.innerHTML = events.length
? events.map((e) => `<div class="lb-audit-item"><strong>${esc(e.summary)}</strong><br/><span class="lb-stat-sub">${fmtDate(e.created_at)}</span></div>`).join('')
: '<p class="lb-stat-sub">Sem actividades registadas.</p>';
} catch (e) {
el.innerHTML = `<p class="lb-stat-sub">Erro: ${esc(e.message)}</p>`;
}
}
function capabilitiesPanel() {
if (!window.DeskAccessControlPanel?.paint) {
return '<p class="lb-stat-sub">Módulo de capacidades indisponível.</p>';
}
return '<div id="ach-capabilities-host"></div>';
}
function render() {
if (!host) return;
const body = subTab === 'users' ? usersPanel() : capabilitiesPanel();
host.innerHTML = `
<div class="lb-page${subTab === 'users' ? ' lb-page--ach-users' : ''}" data-ach-root>
${subTab === 'capabilities' ? `<header class="ach-page-head">${subnavHtml()}</header>` : ''}
${body}
</div>
${confirmModal || ''}`;
bindEvents();
if (subTab === 'capabilities') {
const capHost = host.querySelector('#ach-capabilities-host');
window.DeskAccessControlPanel?.paintCapabilitiesOnly?.(capHost, opts)
|| window.DeskAccessControlPanel?.paint?.(capHost, { ...opts, usersOnly: false });
}
}
function showConfirm(title, message, onConfirm, danger = false) {
confirmModal = `
<div class="lb-modal-sm" data-ach-confirm-root>
<div class="lb-modal-sm-card">
<h3 style="margin:0 0 8px">${esc(title)}</h3>
<p class="lb-stat-sub">${esc(message)}</p>
<p class="lb-stat-sub" style="color:var(--lb-danger)">Acção registada no audit log.</p>
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
<button type="button" class="lb-btn-ghost" data-ach-confirm-cancel>Cancelar</button>
<button type="button" class="${danger ? 'lb-btn-danger' : 'lb-btn-primary'}" data-ach-confirm-ok>Confirmar</button>
</div>
</div>
</div>`;
render();
host.querySelector('[data-ach-confirm-cancel]')?.addEventListener('click', () => {
confirmModal = null;
render();
});
host.querySelector('[data-ach-confirm-ok]')?.addEventListener('click', async () => {
confirmModal = null;
await onConfirm();
render();
});
}
async function apiJson(path, init = {}) {
const r = await fetchWithTimeout(path, {
...init,
headers: authHeaders({ 'Content-Type': 'application/json', ...(init.headers || {}) }),
});
if (!r.ok) throw new Error((await r.text()).slice(0, 200));
return r.json();
}
function bindEvents() {
host.querySelectorAll('[data-ach-sub]').forEach((btn) => {
btn.addEventListener('click', () => {
subTab = btn.dataset.achSub;
render();
});
});
function openCreateWizard(defaultRole) {
createMenuOpen = false;
window.DeskUserWizard?.open?.({
defaultRole: defaultRole || opts.selectedRole || 'technician',
onDone: async (wizardResult) => {
await loadData();
const email = wizardResult?.user?.username || wizardResult?.user?.email;
if (email) {
selectedUser = email;
filterRole = 'all';
filterGroup = 'all';
filterStatus = 'all';
filterQ = email.split('@')[0] || '';
}
render();
},
});
}
host.querySelector('[data-ach-create]')?.addEventListener('click', () => openCreateWizard());
host.querySelector('[data-ach-create-user]')?.addEventListener('click', () => openCreateWizard());
host.querySelector('[data-ach-create-support]')?.addEventListener('click', () => openCreateWizard('technician'));
host.querySelector('[data-ach-create-menu]')?.addEventListener('click', (e) => {
e.stopPropagation();
createMenuOpen = !createMenuOpen;
render();
});
host.querySelector('[data-ach-filters]')?.addEventListener('click', () => {
host.querySelector('[data-ach-search]')?.focus();
});
if (createMenuOpen) {
const closeMenu = (e) => {
if (!e.target.closest('[data-ach-create-dropdown]') && !e.target.closest('[data-ach-create-menu]')) {
createMenuOpen = false;
document.removeEventListener('click', closeMenu);
render();
}
};
setTimeout(() => document.addEventListener('click', closeMenu), 0);
}
host.querySelector('[data-ach-search]')?.addEventListener('input', (e) => {
filterQ = e.target.value;
clearTimeout(host._achTimer);
host._achTimer = setTimeout(render, 200);
});
['role', 'group', 'status'].forEach((k) => {
host.querySelector(`[data-ach-filter-${k}]`)?.addEventListener('change', (e) => {
if (k === 'role') filterRole = e.target.value;
if (k === 'group') filterGroup = e.target.value;
if (k === 'status') filterStatus = e.target.value;
render();
});
});
host.querySelectorAll('[data-ach-user]').forEach((el) => {
el.addEventListener('click', (e) => {
if (e.target.closest('.ach-row-menu')) e.preventDefault();
selectedUser = el.dataset.achUser;
detailTab = 'details';
render();
});
});
host.querySelectorAll('[data-ach-dtab]').forEach((btn) => {
btn.addEventListener('click', () => {
detailTab = btn.dataset.achDtab;
render();
});
});
host.querySelector('[data-ach-close-panel]')?.addEventListener('click', () => {
selectedUser = null;
render();
});
const u = users.find((x) => x.username === selectedUser);
if (!u) return;
host.querySelector('[data-ach-favorite]')?.addEventListener('click', () => {
if (favorites.has(u.username)) favorites.delete(u.username);
else favorites.add(u.username);
render();
});
host.querySelector('[data-ach-groups]')?.addEventListener('click', () => {
window.alert('Gerenciar grupos — disponível na próxima fase.');
});
host.querySelector('[data-ach-goto-matrix]')?.addEventListener('click', (e) => {
e.preventDefault();
document.querySelector('[data-am-tab="quem-faz-o-que"]')?.click();
});
host.querySelector('[data-ach-freeze]')?.addEventListener('click', () => {
const label = u.active ? 'Congelar' : 'Activar';
showConfirm(`${label} conta`, `${label} ${u.username}?`, async () => {
await apiJson(`/api/v1/governance/users/${encodeURIComponent(u.username)}/freeze`, { method: 'POST' });
await loadData();
});
});
host.querySelector('[data-ach-reset-pwd]')?.addEventListener('click', () => {
showConfirm('Redefinir senha', `Gerar nova senha para ${u.username}?`, async () => {
const res = await apiJson(`/api/v1/governance/users/${encodeURIComponent(u.username)}/reset-password`, { method: 'POST' });
window.alert(`Nova senha: ${res.generated_password}`);
await loadData();
});
});
host.querySelector('[data-ach-reset-2fa]')?.addEventListener('click', () => {
showConfirm('Resetar 2FA', `Resetar autenticador de ${u.username}?`, async () => {
await apiJson(`/api/v1/auth/users/${encodeURIComponent(u.username)}/reset-2fa`, { method: 'POST' });
await loadData();
});
});
host.querySelector('[data-ach-deactivate]')?.addEventListener('click', () => {
showConfirm('Eliminar utilizador', `Eliminar permanentemente ${u.username}? Esta acção não pode ser desfeita.`, async () => {
await apiJson(`/api/v1/auth/users/${encodeURIComponent(u.username)}`, { method: 'DELETE' });
selectedUser = null;
await loadData();
}, true);
});
host.querySelector('[data-ach-clone]')?.addEventListener('click', () => {
const email = window.prompt('E-mail do novo utilizador (cópia):');
if (!email) return;
apiJson(`/api/v1/auth/users/${encodeURIComponent(u.username)}/clone`, {
method: 'POST',
body: JSON.stringify({ email: email.trim().toLowerCase() }),
}).then(async (res) => {
if (res.generated_password) window.alert(`Copiado. Senha: ${res.generated_password}`);
await loadData();
render();
}).catch((e) => window.alert(e.message));
});
host.querySelector('[data-ach-edit]')?.addEventListener('click', () => {
if (window.DeskUserManagement?.openEdit) {
window.DeskUserManagement.openEdit(u.username, async () => {
await loadData();
render();
});
}
});
}
async function paint(container, options = {}) {
host = container;
opts = options;
if (typeof canManageUsers === 'function' && !canManageUsers()) {
host.innerHTML = '<p class="loading">Sem permissão — apenas Super Admin gere acessos.</p>';
return;
}
host.innerHTML = '<p class="loading">Carregando Controle de acesso…</p>';
try {
await loadData();
if (!selectedUser && users.length) selectedUser = users[0].username;
render();
} catch (e) {
host.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
function selectUser(username) {
selectedUser = username;
subTab = 'users';
filterRole = 'all';
filterQ = String(username || '').split('@')[0] || '';
if (host) {
loadData().then(render).catch(() => render());
}
}
window.DeskAccessControlHub = { paint, selectUser };
})();

View file

@ -0,0 +1,169 @@
/**
* Controle de acesso delega ao Access Control Hub
* Spec 040 · DS-FE-005
*/
(function () {
'use strict';
const ROLE_CODES = {
super_admin: 'SU',
ops_lead: 'CO',
technician: 'TEC',
noc: 'NOC',
sales_admin: 'SAD',
sales_support: 'SSU',
finance: 'FIN',
marketing: 'MKT',
seo: 'SEO',
developer: 'DEV',
devops: 'DVO',
security_analyst: 'SOC',
content_editor: 'CMS',
agentic_operator: 'AIO',
partner: 'PTR',
root: 'RO',
};
const ACCESS_ACTIONS = [
{ id: 'create_user', actionId: 'desk.auth.user.create', label: 'Criar usuário', hint: 'Provisionar conta directa' },
{ id: 'edit_user', actionId: 'desk.auth.user.edit', label: 'Editar usuário', hint: 'Nome, perfil e metadados' },
{ id: 'freeze_user', actionId: 'desk.auth.user.freeze', label: 'Congelar conta', hint: 'Bloquear login — SSU nunca' },
{ id: 'approve_registration', actionId: 'desk.auth.user.approve_registration', label: 'Aprovar pedidos de cadastro', hint: 'SU + CO' },
{ id: 'manage_passwords', actionId: 'desk.auth.user.password.reset', label: 'Gerenciar senhas', hint: 'Reset admin' },
{ id: 'reset_2fa', actionId: 'desk.auth.user.2fa.reset', label: 'Resetar 2FA', hint: 'Recuperação autenticador' },
{ id: 'manage_modules', actionId: 'desk.auth.modules.toggle', label: 'Módulos Desk ON/OFF', hint: 'Feature flags' },
{ id: 'purge_domain', actionId: 'vm112.domain.purge', label: 'Purge domínio', hint: 'Irreversível' },
{ id: 'openpanel_delete', actionId: 'vm123_openpanel.site.delete', label: 'Deletar instância OpenPanel', hint: 'SU + CO' },
{ id: 'validate_billing', actionId: 'desk.billing.state.validate', label: 'Validar billing', hint: 'billing_state' },
{ id: 'agent_approve', actionId: 'desk.agent.runbook.approve', label: 'Aprovar remediação A7', hint: 'Agentes' },
];
let levelsCache = null;
let levelsRole = null;
let catalogEditable = false;
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
async function loadLevelsForRole(roleId) {
if (levelsCache && levelsRole === roleId) return levelsCache;
const r = await fetchWithTimeout('/api/v1/rbac/actions', { headers: authHeaders() });
if (!r.ok) throw new Error(String(r.status));
const data = await r.json();
catalogEditable = !!data.editable;
const map = {};
(data.actions || []).forEach((a) => {
map[a.id] = {
effective: a.effective?.[roleId] || 'none',
default: a.defaults?.[roleId] || 'none',
overridden: a.overridden_roles?.includes(roleId),
};
});
levelsCache = map;
levelsRole = roleId;
return map;
}
function actionOn(level) {
return level === 'full' || level === 'approve' || level === 'api' || level === 'system';
}
function renderPermToggles(selectedRole, roleMeta, levels, editable) {
const code = ROLE_CODES[selectedRole] || selectedRole?.slice(0, 3).toUpperCase() || '—';
const rows = ACCESS_ACTIONS.map((a) => {
const lv = levels[a.actionId]?.effective || 'none';
const on = actionOn(lv);
const partial = on && lv !== 'full';
const ro = !editable ? ' acs-toggle--readonly' : '';
const dis = editable ? '' : ' disabled';
return `
<div class="acs-perm-row" title="${esc(a.hint)} · ${esc(lv)}">
<span class="acs-perm-label">${esc(a.label)}${partial ? ` <em class="acs-partial">(${esc(lv)})</em>` : ''}</span>
<label class="acs-toggle${ro}" aria-label="${esc(a.label)}" data-acs-action="${esc(a.actionId)}">
<input type="checkbox" ${on ? 'checked' : ''}${dis} data-acs-toggle="${esc(a.actionId)}"/>
<span class="acs-toggle-slider"></span>
</label>
</div>`;
}).join('');
const editHint = editable
? 'Toggles ligados ao catálogo Spec 039 — alterações gravam na Matriz «Quem faz o quê».'
: 'Modo consulta — use a aba «Quem faz o quê» com edição activa para alterar.';
return `
<div class="acs-perms-card acs-card lb-card" style="padding:16px">
<header class="acs-perms-head">
<span class="acs-role-code" title="Código da função">${esc(code)}</span>
<div>
<h3>Capacidades da função</h3>
<p class="acs-perms-desc">Função <strong>${esc(roleMeta?.label || selectedRole)}</strong>. ${editHint}</p>
</div>
</header>
<div class="acs-perm-list">${rows}</div>
<p class="ticket-meta"><a href="#" class="acs-goto-exec" data-goto-exec>Abrir mapa completo «Quem faz o quê»</a></p>
</div>`;
}
async function patchToggle(actionId, roleId, checked) {
const level = checked ? 'full' : 'none';
const r = await fetchWithTimeout('/api/v1/rbac/actions/override', {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ action_id: actionId, role_id: roleId, level, reset: !checked }),
});
if (!r.ok) throw new Error(await r.text());
levelsCache = null;
return r.json();
}
async function paintCapabilitiesOnly(host, opts = {}) {
if (!host) return;
const selectedRole = opts.selectedRole || 'super_admin';
const roleMeta = opts.roleMeta || {};
const editable = !!opts.editable;
host.innerHTML = '<p class="loading">Carregando capacidades…</p>';
let levels = {};
try {
levels = await loadLevelsForRole(selectedRole);
} catch (e) {
host.innerHTML = `<p class="loading">Catálogo indisponível: ${esc(e.message)}</p>`;
return;
}
const canEdit = editable && catalogEditable;
host.innerHTML = renderPermToggles(selectedRole, roleMeta, levels, canEdit);
host.querySelector('[data-goto-exec]')?.addEventListener('click', (e) => {
e.preventDefault();
document.querySelector('[data-am-tab="quem-faz-o-que"]')?.click();
});
host.querySelectorAll('[data-acs-toggle]').forEach((inp) => {
inp.addEventListener('change', async () => {
if (!canEdit) return;
const actionId = inp.dataset.acsToggle;
try {
await patchToggle(actionId, selectedRole, inp.checked);
} catch (err) {
inp.checked = !inp.checked;
window.alert(`Erro ao gravar: ${err.message}`);
}
});
});
}
async function paint(host, opts = {}) {
if (!host) return;
if (window.DeskAccessControlHub?.paint) {
return window.DeskAccessControlHub.paint(host, opts);
}
return paintCapabilitiesOnly(host, opts);
}
window.DeskAccessControlPanel = {
ROLE_CODES,
ACCESS_ACTIONS,
paint,
paintCapabilitiesOnly,
invalidateCache: () => { levelsCache = null; levelsRole = null; },
resetUserMgmt: () => window.DeskUserManagement?.reset?.(),
};
})();

File diff suppressed because it is too large Load diff

View file

@ -48,6 +48,13 @@
border-bottom: 1px solid var(--am-border);
}
#access-matrix-content .am-header--toolbar {
justify-content: flex-end;
padding: 0;
border-bottom: none;
min-height: 0;
}
#access-matrix-content .am-page-title {
margin: 0;
font-size: 1.35rem;
@ -128,6 +135,8 @@
top: 0.5rem;
max-height: calc(100vh - 200px);
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: auto;
padding: 0.85rem;
background: linear-gradient(180deg, #fffdf9 0%, #faf6f0 100%);
border: 1px solid var(--am-border);
@ -148,15 +157,54 @@
font-weight: 600;
}
#access-matrix-content .am-role-code,
.context-nav .am-role-code {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
min-width: 2.15rem;
height: 1.35rem;
padding: 0 0.35rem;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.03em;
color: var(--am-accent);
background: var(--am-accent-soft);
border-radius: 4px;
line-height: 1;
}
#access-matrix-content .am-role-label,
.context-nav .am-role-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
}
#access-matrix-content .am-role-btn,
.context-nav .am-role-btn {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
overflow: hidden;
}
#access-matrix-content .am-role-btn {
display: block;
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
text-align: left;
border: none;
border-left: 2px solid transparent;
background: transparent;
border-radius: 0 4px 4px 0;
padding: 0.4rem 0.5rem 0.4rem 0.65rem;
padding: 0.45rem 0.5rem 0.45rem 0.55rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
@ -953,3 +1001,818 @@
grid-template-columns: 1fr;
}
}
/* Aba Controle de acesso — separada após Agentes IA */
#access-matrix-content .am-tab-sep {
width: 1px;
align-self: stretch;
background: var(--am-border);
margin: 0 0.35rem;
}
#access-matrix-content .am-tab--access-control {
font-weight: 600;
border-left: 3px solid var(--am-accent);
}
#access-matrix-content .am-tab--access-control.active {
background: #e8f0fa;
color: #2b6cb0;
box-shadow: inset 0 -2px 0 #2b6cb0;
}
#access-matrix-content .am-panel--access-control {
border: none;
box-shadow: none;
background: transparent;
padding: 0;
overflow: visible;
}
#access-matrix-content .am-role-code {
margin-right: 0;
}
#access-matrix-content .am-role-btn {
gap: 0.45rem;
}
/* Spec 039 — Quem faz o quê (mockup Roger) */
#access-matrix-content .am-panel--executive {
border: none;
box-shadow: none;
background: transparent;
padding: 0;
overflow: visible;
}
#access-matrix-content .am-executive-map-host {
overflow: visible;
contain: layout style;
}
body.qfx-scroll-lock {
overflow: hidden;
overscroll-behavior: none;
}
#access-matrix-content .qfx-layout {
position: relative;
min-height: 420px;
overflow: visible;
}
#access-matrix-content .qfx-layout--panel {
display: grid;
grid-template-columns: minmax(0, 1fr) min(400px, 36%);
gap: 1rem;
align-items: start;
}
@media (max-width: 1100px) {
#access-matrix-content .qfx-layout--panel {
grid-template-columns: 1fr;
}
#access-matrix-content .qfx-side-panel {
order: 2;
}
}
#access-matrix-content .qfx-main {
min-width: 0;
}
#access-matrix-content .qfx-side-panel {
align-self: start;
min-width: 0;
}
#access-matrix-content .qfx-side-card {
display: flex;
flex-direction: column;
max-height: none;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
#access-matrix-content .qfx-side-card .qfx-modal-body {
flex: 1;
overflow: visible;
overscroll-behavior: contain;
}
#access-matrix-content .qfx-level-badge {
display: inline-block;
padding: 0.35rem 0.75rem;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 600;
border: 1px solid transparent;
}
#access-matrix-content .qfx-readonly-field {
margin: 0;
padding: 0.45rem 0.65rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
font-size: 0.84rem;
color: #374151;
}
#access-matrix-content .qfx-readonly-hint {
font-size: 0.82rem;
}
#access-matrix-content .qfx-btn-edit--full {
width: 100%;
justify-content: center;
}
#access-matrix-content .qfx-hint {
margin: 0 0 0.5rem;
font-size: 0.75rem;
color: #9ca3af;
}
#access-matrix-content .qfx-edit-subtitle {
margin: 0.35rem 0 0;
font-size: 0.82rem;
color: #6b7280;
font-weight: 400;
}
#access-matrix-content .qfx-edit-modal-root {
position: fixed;
inset: 0;
z-index: 1400;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
#access-matrix-content .qfx-edit-modal-root .qfx-backdrop {
z-index: 1399;
}
#access-matrix-content .qfx-modal-card--edit {
z-index: 1401;
}
#access-matrix-content .qfx-page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
#access-matrix-content .qfx-title {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
color: #2d3748;
}
#access-matrix-content .qfx-subtitle {
margin: 0.25rem 0 0;
font-size: 0.82rem;
color: #718096;
}
#access-matrix-content .qfx-badge-edit,
#access-matrix-content .qfx-badge-read {
font-size: 0.72rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
white-space: nowrap;
}
#access-matrix-content .qfx-badge-edit {
background: #f0fff4;
color: #276749;
border: 1px solid #9ae6b4;
}
#access-matrix-content .qfx-badge-read {
background: #edf2f7;
color: #4a5568;
}
#access-matrix-content .qfx-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
#access-matrix-content .qfx-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.75rem;
color: #718096;
}
#access-matrix-content .qfx-field--search {
flex: 1;
min-width: 200px;
}
#access-matrix-content .qfx-select,
#access-matrix-content .qfx-search {
padding: 0.45rem 0.65rem;
border: 1px solid #cbd5e0;
border-radius: 8px;
font-size: 0.85rem;
background: #fff;
}
#access-matrix-content .qfx-select--full {
width: 100%;
}
#access-matrix-content .qfx-btn-ghost {
padding: 0.45rem 0.85rem;
border: 1px solid #cbd5e0;
border-radius: 8px;
background: #fff;
font-size: 0.82rem;
cursor: pointer;
color: #4a5568;
}
#access-matrix-content .qfx-layout--modal .qfx-main {
filter: none;
}
#access-matrix-content .qfx-kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.25rem;
}
@media (max-width: 1100px) {
#access-matrix-content .qfx-kpi-grid {
grid-template-columns: 1fr;
}
}
#access-matrix-content .qfx-kpi {
position: relative;
padding: 1.15rem 1.25rem;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
min-height: 118px;
}
#access-matrix-content .qfx-kpi-body {
padding-right: 3.25rem;
}
#access-matrix-content .qfx-kpi-icon-ring {
position: absolute;
top: 1rem;
right: 1rem;
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
#access-matrix-content .qfx-kpi--action .qfx-kpi-icon-ring {
background: #dcfce7;
color: #16a34a;
}
#access-matrix-content .qfx-kpi--who .qfx-kpi-icon-ring {
background: #dbeafe;
color: #2563eb;
}
#access-matrix-content .qfx-kpi--why .qfx-kpi-icon-ring {
background: #ede9fe;
color: #7c3aed;
}
#access-matrix-content .qfx-kpi-label {
margin: 0;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
color: #374151;
}
#access-matrix-content .qfx-kpi-subtitle {
margin: 0.2rem 0 0.85rem;
font-size: 0.78rem;
color: #9ca3af;
line-height: 1.35;
}
#access-matrix-content .qfx-kpi-val {
display: block;
font-size: 2rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
}
#access-matrix-content .qfx-kpi--action .qfx-kpi-val { color: #16a34a; }
#access-matrix-content .qfx-kpi--who .qfx-kpi-val { color: #2563eb; }
#access-matrix-content .qfx-kpi--why .qfx-kpi-val { color: #7c3aed; }
#access-matrix-content .qfx-kpi-foot {
display: block;
margin-top: 0.35rem;
font-size: 0.78rem;
color: #9ca3af;
}
#access-matrix-content .qfx-table-block {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
#access-matrix-content .qfx-table-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid #e2e8f0;
background: #f7fafc;
}
#access-matrix-content .qfx-table-head h4 {
margin: 0;
font-size: 0.95rem;
}
#access-matrix-content .qfx-table-count {
font-size: 0.78rem;
color: #718096;
}
#access-matrix-content .qfx-table-wrap {
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: auto;
scrollbar-gutter: stable;
}
#access-matrix-content .qfx-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
#access-matrix-content .qfx-table th {
text-align: left;
padding: 0.65rem 0.85rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #718096;
background: #fafafa;
border-bottom: 1px solid #e2e8f0;
}
#access-matrix-content .qfx-table td {
padding: 0.75rem 0.85rem;
border-bottom: 1px solid #edf2f7;
vertical-align: middle;
}
#access-matrix-content .qfx-row {
cursor: pointer;
transition: background 0.12s;
}
#access-matrix-content .qfx-row:hover {
background: #f7fafc;
}
#access-matrix-content .qfx-row--open {
background: #ebf8ff;
}
#access-matrix-content .qfx-row--custom {
box-shadow: inset 3px 0 0 #d69e2e;
}
#access-matrix-content .qfx-action-title {
display: block;
font-size: 0.88rem;
color: #2d3748;
margin-bottom: 0.2rem;
}
#access-matrix-content .qfx-action-id {
font-size: 0.68rem;
color: #a0aec0;
font-family: ui-monospace, monospace;
}
#access-matrix-content .qfx-col-who {
max-width: 260px;
}
#access-matrix-content .qfx-split-tag {
display: inline-flex;
align-items: stretch;
margin: 0.12rem 0.2rem 0.12rem 0;
padding: 0;
border: 1px solid #e5e7eb;
border-radius: 999px;
overflow: hidden;
background: #fff;
font: inherit;
vertical-align: middle;
cursor: default;
}
#access-matrix-content button.qfx-split-tag {
cursor: pointer;
}
#access-matrix-content button.qfx-split-tag:hover {
border-color: #cbd5e1;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
#access-matrix-content .qfx-split-tag.off {
opacity: 0.42;
}
#access-matrix-content .qfx-split-tag__code {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.42rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--tag-fg, #475569);
background: var(--tag-bg, #f1f5f9);
}
#access-matrix-content .qfx-split-tag__name {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem 0.18rem 0.35rem;
font-size: 0.68rem;
font-weight: 500;
color: #374151;
background: #fff;
white-space: nowrap;
}
#access-matrix-content .qfx-split-tag--compact .qfx-split-tag__code {
padding: 0.12rem 0.35rem;
font-size: 0.62rem;
}
#access-matrix-content .qfx-split-tag--compact .qfx-split-tag__name {
display: none;
}
#access-matrix-content .qfx-split-tag-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
#access-matrix-content .qfx-col-why {
color: #4a5568;
max-width: 220px;
line-height: 1.35;
}
#access-matrix-content .qfx-level-select {
min-width: 7.5rem;
padding: 0.35rem 0.5rem;
border-radius: 8px;
border: 1px solid #cbd5e0;
font-size: 0.78rem;
font-weight: 600;
}
#access-matrix-content .qfx-level--full {
background: #f0fff4;
color: #276749;
border-color: #9ae6b4;
}
#access-matrix-content .qfx-level--read {
background: #ebf8ff;
color: #2b6cb0;
border-color: #90cdf4;
}
#access-matrix-content .qfx-level--none {
background: #fff5f5;
color: #c53030;
border-color: #feb2b2;
}
#access-matrix-content .qfx-icon-btn {
border: none;
background: transparent;
cursor: pointer;
padding: 0.25rem 0.35rem;
font-size: 1rem;
opacity: 0.65;
}
#access-matrix-content .qfx-icon-btn:hover {
opacity: 1;
}
#access-matrix-content .qfx-muted {
color: #cbd5e0;
}
#access-matrix-content .qfx-empty {
text-align: center;
color: #718096;
padding: 2rem !important;
}
#access-matrix-content .qfx-modal-root {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
#access-matrix-content .qfx-backdrop {
position: fixed;
inset: 0;
z-index: 1299;
background: rgba(15, 23, 42, 0.42);
}
#access-matrix-content .qfx-modal-card {
position: relative;
z-index: 1301;
width: min(440px, 100%);
max-height: min(90vh, 720px);
display: flex;
flex-direction: column;
background: #fff;
border-radius: 12px;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18);
overflow: hidden;
}
#access-matrix-content .qfx-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 1.25rem 1.35rem 0.85rem;
}
#access-matrix-content .qfx-modal-head-text h3 {
margin: 0;
font-size: 1.05rem;
line-height: 1.35;
font-weight: 700;
color: #111827;
}
#access-matrix-content .qfx-tag-pill {
display: inline-block;
margin-top: 0.55rem;
padding: 0.22rem 0.65rem;
border-radius: 999px;
background: #dcfce7;
color: #15803d;
font-size: 0.72rem;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
#access-matrix-content .qfx-modal-close {
border: none;
background: transparent;
width: 2rem;
height: 2rem;
border-radius: 8px;
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
color: #6b7280;
}
#access-matrix-content .qfx-modal-close:hover {
background: #f3f4f6;
color: #111827;
}
#access-matrix-content .qfx-modal-body {
flex: 1;
overflow-y: auto;
padding: 0 1.35rem 1rem;
}
#access-matrix-content .qfx-modal-block {
margin-bottom: 1.1rem;
}
#access-matrix-content .qfx-modal-block h4 {
margin: 0 0 0.4rem;
font-size: 0.88rem;
font-weight: 700;
color: #111827;
}
#access-matrix-content .qfx-modal-block p {
margin: 0;
font-size: 0.84rem;
line-height: 1.45;
color: #6b7280;
}
#access-matrix-content .qfx-finalidade-main {
color: #374151 !important;
font-weight: 500;
}
#access-matrix-content .qfx-finalidade-sub {
margin-top: 0.25rem !important;
font-size: 0.78rem !important;
color: #9ca3af !important;
}
#access-matrix-content .qfx-level-field {
margin-top: 0.15rem;
}
#access-matrix-content .qfx-level-select--modal {
width: 100%;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2315803d' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
#access-matrix-content .qfx-select--modal {
width: 100%;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
#access-matrix-content .qfx-modal-foot {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
padding: 1rem 1.35rem 1.15rem;
border-top: 1px solid #e5e7eb;
}
#access-matrix-content .qfx-btn-cancel {
padding: 0.55rem 1rem;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #374151;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
#access-matrix-content .qfx-btn-cancel:hover {
background: #f9fafb;
}
#access-matrix-content .qfx-btn-edit {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 1rem;
border: none;
border-radius: 8px;
background: #4a152c;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
#access-matrix-content .qfx-btn-edit:hover:not(:disabled) {
background: #3d1124;
}
#access-matrix-content .qfx-btn-edit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#access-matrix-content .qfx-error {
color: #c53030;
font-size: 0.82rem;
margin: 0.5rem 0;
}
#access-matrix-content .sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* Modal editar permissão — portal no body (z-index acima de tudo) */
#qfx-edit-modal-portal .qfx-edit-modal-root {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
overscroll-behavior: none;
}
#qfx-edit-modal-portal .qfx-backdrop {
position: fixed;
inset: 0;
z-index: 1499;
background: rgba(15, 23, 42, 0.42);
}
#qfx-edit-modal-portal .qfx-modal-card {
position: relative;
z-index: 1501;
width: min(440px, 100%);
max-height: min(90vh, 720px);
display: flex;
flex-direction: column;
background: #fff;
border-radius: 12px;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18);
overflow: hidden;
}
#qfx-edit-modal-portal .qfx-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; padding: 1.25rem 1.35rem 0.85rem; }
#qfx-edit-modal-portal .qfx-modal-head-text h3 { margin: 0; font-size: 1.05rem; line-height: 1.35; font-weight: 700; color: #111827; }
#qfx-edit-modal-portal .qfx-tag-pill { display: inline-block; margin-top: 0.55rem; padding: 0.22rem 0.65rem; border-radius: 999px; background: #dcfce7; color: #15803d; font-size: 0.72rem; font-weight: 600; font-family: ui-monospace, Menlo, monospace; }
#qfx-edit-modal-portal .qfx-edit-subtitle { margin: 0.35rem 0 0; font-size: 0.82rem; color: #6b7280; }
#qfx-edit-modal-portal .qfx-modal-close { border: none; background: transparent; width: 2rem; height: 2rem; border-radius: 8px; font-size: 1.35rem; cursor: pointer; color: #6b7280; }
#qfx-edit-modal-portal .qfx-modal-body { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 0 1.35rem 1rem; }
#qfx-edit-modal-portal .qfx-modal-block { margin-bottom: 1.1rem; }
#qfx-edit-modal-portal .qfx-modal-block h4 { margin: 0 0 0.4rem; font-size: 0.88rem; font-weight: 700; color: #111827; }
#qfx-edit-modal-portal .qfx-hint { margin: 0 0 0.5rem; font-size: 0.75rem; color: #9ca3af; }
#qfx-edit-modal-portal .qfx-split-tag-grid { display: flex; flex-wrap: wrap; gap: 0.45rem; }
#qfx-edit-modal-portal .qfx-split-tag { display: inline-flex; align-items: stretch; border: 1px solid #e5e7eb; border-radius: 999px; overflow: hidden; background: #fff; font: inherit; cursor: pointer; }
#qfx-edit-modal-portal .qfx-split-tag.off { opacity: 0.42; }
#qfx-edit-modal-portal .qfx-split-tag__code { display: inline-flex; align-items: center; padding: 0.18rem 0.42rem; font-size: 0.68rem; font-weight: 700; color: var(--tag-fg, #475569); background: var(--tag-bg, #f1f5f9); }
#qfx-edit-modal-portal .qfx-split-tag__name { display: inline-flex; align-items: center; padding: 0.18rem 0.55rem 0.18rem 0.35rem; font-size: 0.68rem; color: #374151; background: #fff; white-space: nowrap; }
#qfx-edit-modal-portal .qfx-level-select { width: 100%; padding: 0.45rem 0.65rem; border-radius: 8px; border: 1px solid #cbd5e0; font-size: 0.85rem; font-weight: 600; }
#qfx-edit-modal-portal .qfx-level-select--modal { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2315803d' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.75rem center; padding-right: 2rem; }
#qfx-edit-modal-portal .qfx-level--full { background: #f0fff4; color: #276749; border-color: #9ae6b4; }
#qfx-edit-modal-portal .qfx-level--read { background: #ebf8ff; color: #2b6cb0; border-color: #90cdf4; }
#qfx-edit-modal-portal .qfx-level--none { background: #fff5f5; color: #c53030; border-color: #feb2b2; }
#qfx-edit-modal-portal .qfx-select { width: 100%; padding: 0.45rem 0.65rem; border: 1px solid #cbd5e0; border-radius: 8px; font-size: 0.85rem; background: #fff; }
#qfx-edit-modal-portal .qfx-modal-foot { display: flex; justify-content: flex-end; gap: 0.65rem; padding: 1rem 1.35rem 1.15rem; border-top: 1px solid #e5e7eb; }
#qfx-edit-modal-portal .qfx-btn-cancel { padding: 0.55rem 1rem; border: 1px solid #d1d5db; border-radius: 8px; background: #fff; color: #374151; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
#qfx-edit-modal-portal .qfx-btn-edit { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.55rem 1rem; border: none; border-radius: 8px; background: #4a152c; color: #fff; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
#qfx-edit-modal-portal .qfx-btn-edit:disabled { opacity: 0.5; cursor: not-allowed; }
#qfx-edit-modal-portal .qfx-error { color: #c53030; font-size: 0.82rem; margin: 0.5rem 0; }
.acs-partial {
font-style: normal;
font-size: 0.75rem;
color: #b7791f;
}
.acs-goto-exec {
color: var(--am-accent, #2b6cb0);
}

View file

@ -19,8 +19,199 @@
softwareFilter: 'all',
saving: false,
saveError: null,
moduleDraft: null,
bindingDraft: null,
};
async function crudApi(method, path, body) {
const opts = {
method,
headers: authHeaders({ 'Content-Type': 'application/json' }),
};
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetchWithTimeout(`/api/v1${path}`, opts);
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
if (method === 'DELETE' || r.status === 204) return {};
const ct = r.headers.get('content-type') || '';
if (ct.includes('json')) return r.json();
return r.text();
}
async function reloadMatrix() {
state.data = await api('/rbac/matrix');
state.moduleDraft = null;
state.bindingDraft = null;
}
function selectedRoleMeta() {
return state.data?.catalog?.roles?.[state.selectedRole] || {};
}
function canCrud() {
return !!state.data?.crud_enabled;
}
function isRoleLocked() {
return !!selectedRoleMeta().locked;
}
async function handleNewRole() {
const id = window.prompt('ID da função (slug, ex.: billing_analyst):');
if (!id) return;
const label = window.prompt('Nome exibido (pt-BR):', id.replace(/_/g, ' '));
if (!label) return;
state.saving = true;
state.saveError = null;
try {
await crudApi('POST', '/rbac/roles', {
id: id.trim().toLowerCase(),
label: label.trim(),
category: 'custom',
description: '',
});
await reloadMatrix();
state.selectedRole = id.trim().toLowerCase();
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} catch (e) {
state.saveError = e.message;
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} finally {
state.saving = false;
}
}
async function handleCloneRole() {
const newId = window.prompt(`Copiar ${state.selectedRole} — novo ID (slug):`);
if (!newId) return;
const newLabel = window.prompt('Nome da cópia:', `${selectedRoleMeta().label || state.selectedRole} (cópia)`);
if (!newLabel) return;
state.saving = true;
try {
await crudApi('POST', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/clone`, {
new_id: newId.trim().toLowerCase(),
new_label: newLabel.trim(),
});
await reloadMatrix();
state.selectedRole = newId.trim().toLowerCase();
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} catch (e) {
state.saveError = e.message;
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} finally {
state.saving = false;
}
}
async function handleFreezeRole() {
const meta = selectedRoleMeta();
const next = meta.status === 'frozen' ? 'active' : 'frozen';
const msg = next === 'frozen' ? 'Pausar esta função? Novas atribuições serão bloqueadas.' : 'Reactivar função?';
if (!window.confirm(msg)) return;
state.saving = true;
try {
await crudApi('PATCH', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/status`, { status: next });
await reloadMatrix();
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} catch (e) {
state.saveError = e.message;
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} finally {
state.saving = false;
}
}
async function handleDeleteRole() {
if (!window.confirm(`Arquivar função ${state.selectedRole}? Falha se houver utilizadores activos.`)) return;
state.saving = true;
try {
await crudApi('DELETE', `/rbac/roles/${encodeURIComponent(state.selectedRole)}`);
await reloadMatrix();
state.selectedRole = state.data.role_columns?.[0] || 'super_admin';
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} catch (e) {
state.saveError = e.message;
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} finally {
state.saving = false;
}
}
function handleReportDownload() {
const token = localStorage.getItem('desk_token') || sessionStorage.getItem('desk_token');
const url = `/api/v1/rbac/roles/${encodeURIComponent(state.selectedRole)}/report.csv`;
fetch(url, { headers: authHeaders() })
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
return r.blob();
})
.then((blob) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `matriz-${state.selectedRole}.csv`;
a.click();
})
.catch((e) => { state.saveError = e.message; paint(document.getElementById('access-matrix-content')); });
}
async function saveModuleDraft() {
if (!state.moduleDraft) return;
state.saving = true;
try {
await crudApi('PUT', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/modules`, {
modules: state.moduleDraft,
});
await reloadMatrix();
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} catch (e) {
state.saveError = e.message;
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} finally {
state.saving = false;
}
}
async function saveBindingDraft() {
if (!state.bindingDraft) return;
state.saving = true;
try {
await crudApi('PUT', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/bindings`, {
bindings: state.bindingDraft,
});
await reloadMatrix();
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} catch (e) {
state.saveError = e.message;
const root = document.getElementById('access-matrix-content');
if (root) paint(root);
} finally {
state.saving = false;
}
}
function renderToolbar() {
if (!canCrud()) return '';
const meta = selectedRoleMeta();
const frozen = meta.status === 'frozen';
return `<div class="am-toolbar">
<button type="button" class="am-tool-btn primary" data-am-action="new-role">+ Nova função</button>
<button type="button" class="am-tool-btn" data-am-action="clone-role" ${isRoleLocked() ? 'disabled' : ''}>Copiar</button>
<button type="button" class="am-tool-btn" data-am-action="freeze-role" ${isRoleLocked() ? 'disabled' : ''}>${frozen ? 'Reactivar' : 'Pausar'}</button>
<button type="button" class="am-tool-btn" data-am-action="delete-role" ${isRoleLocked() ? 'disabled' : ''}>Arquivar</button>
<button type="button" class="am-tool-btn" data-am-action="report-csv">Relatório CSV</button>
</div>`;
}
async function patchApi(path, body) {
const r = await fetchWithTimeout(`/api/v1${path}`, {
method: 'PATCH',
@ -121,7 +312,7 @@
if (!groups[cat]) groups[cat] = [];
groups[cat].push(role);
});
const order = ['ops', 'commercial', 'business', 'platform', 'system'];
const order = ['ops', 'commercial', 'business', 'platform', 'custom', 'system'];
return order
.filter((c) => groups[c]?.length)
.map((c) => ({ id: c, label: categoryLabels[c] || c, roles: groups[c] }));
@ -138,12 +329,20 @@
function renderRoleSidebar() {
const groups = rolesByCategory(state.data.catalog, state.data.category_labels || {});
return groups.map((g) => `
<div class="am-role-group">
<h4>${esc(g.label)}</h4>
${g.roles.map((r) => `
<button type="button" class="am-role-btn${state.selectedRole === r.id ? ' active' : ''}"
data-am-role="${esc(r.id)}">${esc(r.label)}</button>
`).join('')}
<div class="am-role-group nav-zone">
<span class="nav-zone__label">${esc(g.label)}</span>
<div class="nav-zone__links">
${g.roles.map((r) => {
const st = r.status === 'frozen' ? ' <span class="am-role-paused">pausado</span>' : '';
const code = window.DeskAccessControlPanel?.ROLE_CODES?.[r.id] || '';
return `
<button type="button" class="am-role-btn nav-link${state.selectedRole === r.id ? ' active' : ''}${r.status === 'frozen' ? ' am-role-btn--frozen' : ''}"
data-am-role="${esc(r.id)}">
${code ? `<span class="am-role-code" aria-hidden="true">${esc(code)}</span>` : ''}
<span class="am-role-label">${esc(r.label)}</span>${st}
</button>`;
}).join('')}
</div>
</div>`).join('');
}
@ -193,8 +392,43 @@
}
function renderDeskMatrix() {
if (canCrud() && !isRoleLocked()) {
if (!state.moduleDraft) {
const draft = {};
(state.data.desk_modules || []).forEach((row) => {
draft[row.id] = row.levels[state.selectedRole] || 'none';
});
state.moduleDraft = draft;
}
const levels = ['full', 'read', 'api', 'link', 'none'];
const rows = (state.data.desk_modules || []).map((row) => `
<tr>
<td>${esc(row.label)} <code class="am-row-code">${esc(row.id)}</code></td>
<td>
<select class="am-mod-select" data-am-mod="${esc(row.id)}">
${levels.map((lv) =>
`<option value="${lv}" ${state.moduleDraft[row.id] === lv ? 'selected' : ''}>${lv}</option>`
).join('')}
</select>
</td>
</tr>`).join('');
return `
<p class="am-section-lead">Grelha completa módulos internos do Desk (VM122). Use <strong>Visão da função</strong> para resumo ou <strong>Software & Infra</strong> para VM112/123.</p>
<p class="am-section-lead">Editor de módulos Desk função <strong>${esc(roleLabel(state.selectedRole))}</strong></p>
<div class="am-table-wrap">
<table class="am-edit-table">
<thead><tr><th>Módulo</th><th>Acesso</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
<div class="am-editor-actions">
<button type="button" class="am-tool-btn primary" data-am-action="save-modules">Salvar módulos</button>
</div>
<hr class="am-hr"/>
<p class="am-section-lead">Grelha completa (todas as funções)</p>
${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`;
}
return `
<p class="am-section-lead">Grelha completa módulos internos do Desk (VM122).</p>
${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`;
}
@ -342,9 +576,54 @@
function renderBindings() {
const role = state.data.catalog.roles[state.selectedRole];
if (!role) return '<p class="am-empty">Função não encontrada</p>';
let editor = '';
if (canCrud() && !isRoleLocked()) {
if (!state.bindingDraft) {
state.bindingDraft = (role.bindings || []).map((b) => ({ ...b }));
}
const rows = state.bindingDraft.map((b, i) => `
<tr>
<td><input class="am-bind-in" data-am-bind="${i}" data-field="service" value="${esc(b.service || '')}"/></td>
<td><input class="am-bind-in" data-am-bind="${i}" data-field="type" value="${esc(b.type || '')}"/></td>
<td><input class="am-bind-in" data-am-bind="${i}" data-field="value" value="${esc(b.value || '')}"/></td>
<td><input class="am-bind-in" data-am-bind="${i}" data-field="access" value="${esc(b.access || 'full')}"/></td>
<td><button type="button" class="am-tool-btn sm" data-am-rm-bind="${i}">×</button></td>
</tr>`).join('');
editor = `
<section class="am-block">
<h4 class="am-block-title">Editor bindings</h4>
<div class="am-table-wrap">
<table class="am-edit-table">
<thead><tr><th>Serviço</th><th>Tipo</th><th>Valor</th><th>Acesso</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
<div class="am-editor-actions">
<button type="button" class="am-tool-btn" data-am-action="add-binding">+ Binding</button>
<button type="button" class="am-tool-btn primary" data-am-action="save-bindings">Salvar bindings</button>
</div>
</section>`;
}
const groups = (state.data.external_groups || []).map((g) =>
`<li><code>${esc(g.id)}</code> — ${esc(g.label)} <span class="am-sw-meta">${esc(g.service)}</span></li>`
).join('');
return `
${editor}
<p class="am-section-lead">Bindings estilo Odoo grupos, roles e permissões provisionados por função.</p>
${renderBindingCards(role.bindings || [], '')}`;
${renderBindingCards(role.bindings || [], '')}
${groups ? `<section class="am-block"><h4 class="am-block-title">Grupos externos registados</h4><ul class="am-group-list">${groups}</ul></section>` : ''}`;
}
function renderAudit() {
const entries = state.data.role_audit || [];
if (!entries.length) return '<p class="am-empty">Sem alterações registadas ainda.</p>';
return `<div class="am-audit-list">
${entries.map((e) => `
<article class="am-audit-row">
<header><strong>${esc(e.action)}</strong> · ${esc(e.entity_type)} <code>${esc(e.entity_id)}</code></header>
<p class="am-sw-meta">${esc(e.actor)} · ${esc(e.created_at)}</p>
</article>`).join('')}
</div>`;
}
function roleLabel(roleId) {
@ -547,6 +826,18 @@
}
function renderMainPanel() {
if (state.tab === 'access-control') {
return `
<div class="am-panel am-panel--access-control">
<div id="am-access-control-host" class="am-access-control-host"></div>
</div>`;
}
if (state.tab === 'quem-faz-o-que') {
return `
<div class="am-panel am-panel--executive">
<div id="am-executive-map-host" class="am-executive-map-host"></div>
</div>`;
}
const role = state.data.catalog.roles[state.selectedRole];
let body = '';
if (state.tab === 'overview') body = renderRoleOverview();
@ -554,6 +845,7 @@
else if (state.tab === 'software') body = renderSoftwareMatrix();
else if (state.tab === 'bindings') body = renderBindings();
else if (state.tab === 'agents') body = renderAgents();
else if (state.tab === 'audit') body = renderAudit();
return `
<div class="am-panel">
@ -571,21 +863,30 @@
}
function renderTabs() {
return (state.data.tabs || []).map((t) =>
`<button type="button" class="am-tab${state.tab === t.id ? ' active' : ''}" data-am-tab="${esc(t.id)}">${esc(t.label)}</button>`
).join('');
return (state.data.tabs || []).map((t) => {
const sep = t.separated ? '<span class="am-tab-sep" aria-hidden="true"></span>' : '';
const extra = t.id === 'access-control' ? ' am-tab--access-control' : (t.id === 'quem-faz-o-que' ? ' am-tab--quem-faz-o-que' : '');
return `${sep}<button type="button" class="am-tab${extra}${state.tab === t.id ? ' active' : ''}" data-am-tab="${esc(t.id)}">${esc(t.label)}</button>`;
}).join('');
}
function bindEvents(root) {
root.querySelectorAll('[data-am-role]').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedRole = btn.dataset.amRole;
state.moduleDraft = null;
state.bindingDraft = null;
window.DeskExecutiveMap?.resetFilters?.();
paint(root);
});
});
root.querySelectorAll('[data-am-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
state.tab = btn.dataset.amTab;
if (typeof window.getDeskState === 'function') {
const ds = window.getDeskState();
if (ds) ds.matrixTab = state.tab;
}
paint(root);
});
});
@ -609,18 +910,53 @@
toggleCap(capId, roleId);
});
});
root.querySelectorAll('[data-am-action]').forEach((btn) => {
btn.addEventListener('click', () => {
const a = btn.dataset.amAction;
if (a === 'new-role') handleNewRole();
if (a === 'clone-role') handleCloneRole();
if (a === 'freeze-role') handleFreezeRole();
if (a === 'delete-role') handleDeleteRole();
if (a === 'report-csv') handleReportDownload();
if (a === 'save-modules') saveModuleDraft();
if (a === 'save-bindings') saveBindingDraft();
if (a === 'add-binding') {
state.bindingDraft = state.bindingDraft || [];
state.bindingDraft.push({ service: 'desk', type: 'permission', value: 'read_tickets', access: 'read' });
paint(root);
}
});
});
root.querySelectorAll('[data-am-mod]').forEach((sel) => {
sel.addEventListener('change', () => {
if (!state.moduleDraft) return;
state.moduleDraft[sel.dataset.amMod] = sel.value;
});
});
root.querySelectorAll('.am-bind-in').forEach((inp) => {
inp.addEventListener('change', () => {
const i = Number(inp.dataset.amBind);
const field = inp.dataset.field;
if (!state.bindingDraft?.[i]) return;
state.bindingDraft[i][field] = inp.value;
});
});
root.querySelectorAll('[data-am-rm-bind]').forEach((btn) => {
btn.addEventListener('click', () => {
const i = Number(btn.dataset.amRmBind);
state.bindingDraft.splice(i, 1);
paint(root);
});
});
}
function paint(root) {
root.innerHTML = `
<div class="am-wrap">
<div class="am-header">
<div>
<h2 class="am-page-title">Matriz de Acessos</h2>
<p class="am-page-sub">Quatro camadas: Desk VM122 · VM112 · VM123 · Infra externa</p>
</div>
<span class="am-preview-tag">${state.data?.editable ? 'Edição · audit ON' : 'Preview · read-only'}${state.saving ? ' · …' : ''}</span>
<div class="am-header am-header--toolbar">
<span class="am-preview-tag">${state.data?.crud_enabled ? 'CRUD · audit ON' : state.data?.editable ? 'Edição · audit ON' : 'Consulta'}${state.saving ? ' · …' : ''}</span>
</div>
${renderToolbar()}
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
<nav class="am-subnav">${renderTabs()}</nav>
<div class="am-layout">
@ -629,17 +965,41 @@
</div>
</div>`;
bindEvents(root);
window.DeskTopnav?.remountMatrixRoles?.();
if (state.tab === 'access-control') {
const host = root.querySelector('#am-access-control-host');
const roleMeta = state.data?.catalog?.roles?.[state.selectedRole];
window.DeskAccessControlPanel?.paint?.(host, {
selectedRole: state.selectedRole,
roleMeta,
catalog: state.data?.catalog,
editable: !!state.data?.editable,
});
}
if (state.tab === 'quem-faz-o-que') {
const host = root.querySelector('#am-executive-map-host');
window.DeskExecutiveMap?.paint?.(host, { selectedRole: state.selectedRole });
}
}
async function renderAccessMatrix() {
const root = document.getElementById('access-matrix-content');
if (!root) return;
root.innerHTML = '<p class="loading">Carregando matriz de acessos…</p>';
if (typeof ensureValidSession === 'function' && !(await ensureValidSession())) return;
root.innerHTML = '<p class="loading">Carregando Matriz de Acesso…</p>';
try {
state.data = await api('/rbac/matrix');
if (!state.selectedRole && state.data.role_columns?.length) {
state.selectedRole = state.data.role_columns[0];
}
const tabIds = (state.data.tabs || []).map((t) => t.id);
if (typeof window.getDeskState === 'function') {
const ds = window.getDeskState();
if (ds?.matrixTab && tabIds.includes(ds.matrixTab)) {
state.tab = ds.matrixTab;
}
}
if (!tabIds.includes(state.tab) && tabIds.length) state.tab = tabIds[0];
paint(root);
} catch (e) {
root.innerHTML = `<p class="loading">Matriz indisponível: ${esc(e.message)}</p>`;
@ -647,5 +1007,8 @@
}
window.renderAccessMatrix = renderAccessMatrix;
window.DeskAccessMatrix = { renderAccessMatrix };
window.DeskAccessMatrix = {
renderAccessMatrix,
getState: () => state,
};
})();

View file

@ -266,6 +266,30 @@ function setupSidebarUser() {
}
}
function renderAdminTabs() {
if (!state.features?.access_matrix_ui || getUser()?.role !== 'super_admin') return '';
const teamActive = state.adminPanel !== 'matrix';
return `
<nav class="team-admin-tabs" aria-label="Administração">
<button type="button" class="team-admin-tab${teamActive ? ' active' : ''}" data-admin-tab="team">Equipe Ligbox</button>
<button type="button" class="team-admin-tab${!teamActive ? ' active' : ''}" data-admin-tab="matrix">Matriz de Acesso</button>
</nav>`;
}
function adminTabsInContent() {
return document.querySelector('.shell--v2') ? '' : renderAdminTabs();
}
function bindAdminTabs() {
document.querySelectorAll('[data-admin-tab]').forEach((btn) => {
btn.onclick = () => {
state.adminPanel = btn.dataset.adminTab === 'matrix' ? 'matrix' : 'team';
renderAdmin();
window.DeskTopnav?.updateContextSidebar?.('admin');
};
});
}
function applyRoleNav() {
const user = getUser();
if (!user) return;
@ -374,7 +398,7 @@ function setView(name) {
document.getElementById('page-title').textContent = titles[name] || 'Ligbox Ops';
const subEl = document.getElementById('page-subtitle');
if (subEl) subEl.textContent = subtitles[name] || subtitles.dashboard;
document.querySelectorAll('.nav button').forEach((b) => {
document.querySelectorAll('.nav button, [data-desk-nav]').forEach((b) => {
b.classList.toggle('active', b.dataset.view === name);
});
document.querySelectorAll('[data-desk-nav]').forEach((b) => {
@ -3161,6 +3185,13 @@ async function renderAdmin() {
el.innerHTML = '<p class="loading">Sem permissão</p>';
return;
}
const showMatrixTab = state.features?.access_matrix_ui && getUser()?.role === 'super_admin';
if (showMatrixTab && state.adminPanel === 'matrix') {
el.innerHTML = `${adminTabsInContent()}<div id="access-matrix-content"><p class="loading">Carregando matriz…</p></div>`;
bindAdminTabs();
if (typeof window.renderAccessMatrix === 'function') await window.renderAccessMatrix();
return;
}
el.innerHTML = '<p class="loading">Carregando equipe…</p>';
try {
const [usersData, regData] = await Promise.all([
@ -3197,6 +3228,7 @@ async function renderAdmin() {
</tr>`).join('');
el.innerHTML = `
${adminTabsInContent()}
<div class="team-admin">
<header class="team-admin-head">
<div>
@ -3262,6 +3294,7 @@ async function renderAdmin() {
</div>
</div>`;
bindAdminTabs();
const applyFilters = () => {
state.adminFilter = {
q: document.getElementById('team-filter-q')?.value || '',

View file

@ -104,11 +104,11 @@ function resolveBootView() {
return fallback;
}
async function api(path, options = {}) {
async function api(path, options = {}, timeoutMs) {
const res = await fetchWithTimeout(`${API}${path}`, {
headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }),
...options,
});
}, timeoutMs);
if (res.status === 401) {
logout();
throw new Error('sessão expirada');
@ -169,6 +169,7 @@ let state = {
adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' },
adminSelected: null,
adminPanel: 'team',
matrixTab: null,
features: { access_matrix_ui: false },
socWindow: '24h',
socLastEventId: null,
@ -281,7 +282,6 @@ function applyRoleNav() {
}
if (canManageUsers()) {
document.getElementById('nav-messages')?.removeAttribute('hidden');
document.getElementById('nav-admin')?.removeAttribute('hidden');
}
if (user.role === 'super_admin') {
document.getElementById('nav-modules')?.removeAttribute('hidden');
@ -330,26 +330,46 @@ function setView(name) {
if (name === 'access-matrix' && !state.features?.access_matrix_ui) {
name = resolveDefaultView();
} else if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
if (!(name === 'admin' && typeof canManageUsers === 'function' && canManageUsers())) {
name = resolveDefaultView();
}
}
if (name === 'admin') {
/* Controle de acesso só via Matriz de Acesso (aba access-control) — Roger 2026-06-25 */
if (state.features?.access_matrix_ui && getUser()?.role === 'super_admin') {
name = 'access-matrix';
state.matrixTab = state.matrixTab || 'access-control';
} else {
state.adminPanel = 'team';
}
}
if (name !== 'admin' && name !== 'access-matrix') closeTeamDrawer();
if (name === 'access-matrix' && state.matrixTab) {
/* tab aplicada em renderAccessMatrix */
} else if (name !== 'access-matrix') {
state.matrixTab = null;
}
if (state.view === 'account' && name !== 'account') {
state.accountLoaded = false;
}
window.DeskUserWizard?.close?.();
document.body.classList.remove('um-scroll-lock');
state.view = name;
if (name === 'admin') state.adminPanel = 'team';
const titles = {
dashboard: 'Dashboard',
overview: 'Audit Overview',
'overview-home': 'Serviços IaaS',
tickets: 'Tickets',
tickets: 'Sessão Tickets',
events: 'Eventos webhook',
tenants: 'Tenants',
infra: 'INFRA CODE',
infra2: 'SOC — Infra 2',
'agentic-ops': 'Agentic Ops',
messages: 'Mensagens — pedidos de cadastro',
admin: 'Administradores',
'access-matrix': 'Matriz de Acessos',
messages: 'Central Operacional',
admin: 'Controle de acesso — Spec 040',
'access-matrix': state.matrixTab === 'access-control'
? 'Controle de acesso — Spec 040'
: (state.matrixTab === 'quem-faz-o-que' ? 'Quem faz o quê — Spec 039' : 'Matriz de Acesso — Spec 027'),
account: 'Minha conta',
leads: 'Leads abandonados',
modules: 'Módulos',
@ -364,9 +384,13 @@ function setView(name) {
infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130',
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
'agentic-ops': 'Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)',
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
'access-matrix': 'Preview read-only — Spec 027 · funções × VM112/122/123 (estilo Odoo groups)',
messages: 'Spec 041 · Operational feed — inbox unificada (mock até integrações)',
admin: 'Spec 040 · UserWizard + Access Control Hub · audit ON',
'access-matrix': state.matrixTab === 'access-control'
? 'Spec 040 · Gestão de utilizadores · cards · painel direito'
: (state.matrixTab === 'quem-faz-o-que'
? 'Spec 039 · Access capabilities · Quem faz o quê'
: 'Spec 027 · Controle por função · perfil · grupo · módulo'),
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
leads: 'Operações Ligbox — onboarding, tickets e monitoramento',
modules: 'Activar ou desativar funcionalidades do Desk sem afectar o núcleo',
@ -384,6 +408,9 @@ function setView(name) {
});
window.DeskTopnav?.updateTopnavActive?.(name);
window.DeskTopnav?.updateContextSidebar?.(name);
document.querySelector('.main')?.classList.toggle('main--rwd-tickets', name === 'tickets');
document.querySelector('.main')?.classList.toggle('main--access-matrix', name === 'access-matrix');
document.querySelector('.main')?.classList.toggle('main--acs', name === 'admin');
Object.entries(views).forEach(([k, el]) => el?.classList.toggle('active', k === name));
reschedulePoll();
refresh();
@ -412,10 +439,12 @@ window.DeskNavigate = {
let pollTimer = null;
let socRenderInFlight = false;
let infraRenderInFlight = false;
function reschedulePoll() {
if (pollTimer) clearInterval(pollTimer);
let ms = 30000;
if (state.view === 'infra2') ms = 15000;
if (state.view === 'infra') ms = 60000;
// VM112 /admin/domains ~1015s — poll menos agressivo (Spec 018 Serviços IaaS)
if (state.view === 'overview-home') ms = 90000;
pollTimer = setInterval(() => refresh({ poll: true }), ms);
@ -930,6 +959,7 @@ function bindAssistActions(container, sessionId) {
try {
await runAssistAction(btn.dataset.assist, sessionId);
await renderTickets();
await refreshTicketDetailView();
} catch (e) {
alert(e.message || 'Falha na ação de assistência');
} finally {
@ -945,6 +975,7 @@ function bindAssistActions(container, sessionId) {
try {
await api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/actions/${encodeURIComponent(actionId)}`, { method: 'POST' });
await renderTickets();
await refreshTicketDetailView();
} catch (e) {
alert(e.message || 'Falha na ação');
} finally {
@ -2038,6 +2069,7 @@ function buildOverviewHomeTrail(events, domainsFlat, filter, windowHours) {
async function renderOverviewHome(options = {}) {
const el = document.getElementById('overview-home-content');
if (!el) return;
try {
if (window.DeskServices?.renderPage) {
await window.DeskServices.renderPage(el, options);
return;
@ -2047,6 +2079,11 @@ async function renderOverviewHome(options = {}) {
return;
}
el.innerHTML = '<p class="loading">Módulo Serviços não carregado.</p>';
} catch (e) {
console.error('renderOverviewHome failed', e);
el.innerHTML = `<div class="servicos-page"><p class="servicos-empty">Erro ao carregar Serviços: ${esc(e.message)}</p><button type="button" class="btn" id="servicos-retry-boot">Tentar de novo</button></div>`;
el.querySelector('#servicos-retry-boot')?.addEventListener('click', () => renderOverviewHome(options));
}
}
async function renderLeads(options = {}) {
@ -2189,13 +2226,26 @@ async function renderSessionDetail() {
}
}
async function renderTicketDetail() {
const detailEl = document.getElementById('ticket-detail');
if (!state.selectedTicketId) return;
if (window.TicketsDetailPanel) {
await TicketsDetailPanel.render(state.selectedTicketId, detailEl);
async function refreshTicketDetailView() {
if (!state.selectedTicketId || !window.TicketsDetailPanel) return;
const body = document.getElementById('ticket-drawer-body');
if (TicketsDetailPanel.isOpen?.() && body) {
await TicketsDetailPanel.render(state.selectedTicketId, body);
return;
}
if (TicketsDetailPanel.openDrawer) {
await TicketsDetailPanel.openDrawer(state.selectedTicketId);
}
}
async function renderTicketDetail() {
if (!state.selectedTicketId) return;
if (window.TicketsDetailPanel?.openDrawer) {
await TicketsDetailPanel.openDrawer(state.selectedTicketId);
return;
}
const detailEl = document.getElementById('ticket-detail');
if (!detailEl) return;
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando…</p></div>';
try {
const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`);
@ -2282,6 +2332,7 @@ async function updateTicketStatus(status) {
body: JSON.stringify({ status }),
});
await renderTickets();
await refreshTicketDetailView();
}
async function renderEvents(options = {}) {
@ -2374,6 +2425,9 @@ function syncEventsToolbar() {
sub.textContent = subs[state.eventsTab] || 'Operações Ligbox — onboarding, tickets e monitoramento';
}
}
if (state.view === 'events') {
window.DeskTopnav?.remountEventsToolbar?.();
}
}
function carbonioBlockStatusBadge(status) {
@ -3015,7 +3069,11 @@ async function saveUser(username, payload, msgEl) {
msgEl.className = 'admin-msg ok';
}
closeTeamDrawer();
if (state.view === 'access-matrix') {
await window.renderAccessMatrix?.();
} else {
await renderAdmin();
}
} catch (e) {
if (msgEl) {
msgEl.textContent = e.message;
@ -3025,11 +3083,37 @@ async function saveUser(username, payload, msgEl) {
}
}
function filterAdminUsers(users) {
function renderAdminTabs() {
if (!state.features?.access_matrix_ui || getUser()?.role !== 'super_admin') return '';
const teamActive = state.adminPanel !== 'matrix';
return `
<nav class="team-admin-tabs acs-sidebar-tabs" aria-label="Gestão de acesso">
<button type="button" class="team-admin-tab${teamActive ? ' active' : ''}" data-admin-tab="team">Usuários</button>
<button type="button" class="team-admin-tab${!teamActive ? ' active' : ''}" data-admin-tab="matrix">Perfis de acesso</button>
</nav>`;
}
function bindAdminTabs() {
document.querySelectorAll('[data-admin-tab]').forEach((btn) => {
btn.onclick = () => {
if (btn.dataset.adminTab === 'matrix') {
setView('access-matrix');
return;
}
state.adminPanel = 'team';
renderAdmin();
window.DeskTopnav?.updateContextSidebar?.('admin');
};
});
}
function filterAdminUsers(users, opts = {}) {
const { q, role, status, mfa } = state.adminFilter;
const roleLock = opts.roleLock;
const query = (q || '').trim().toLowerCase();
return users.filter((u) => {
if (role !== 'all' && u.role !== role) return false;
if (roleLock && u.role !== roleLock && !(roleLock === 'super_admin' && u.username === 'root')) return false;
if (!roleLock && role !== 'all' && u.role !== role) return false;
if (status === 'active' && !u.active) return false;
if (status === 'inactive' && u.active) return false;
if (mfa === 'on' && !u.totp_enabled) return false;
@ -3074,7 +3158,7 @@ function openTeamDrawer(username) {
body.innerHTML = `
<div class="team-drawer-profile">
<div class="team-avatar team-avatar-lg" aria-hidden="true">${esc(userInitials(user.display_name, user.username))}</div>
<div class="acs-avatar acs-avatar-lg" aria-hidden="true">${esc(userInitials(user.display_name, user.username))}</div>
<div>
<p class="team-drawer-name">${esc(user.display_name || user.username)}</p>
<p class="ticket-meta">${esc(email)}</p>
@ -3087,30 +3171,34 @@ function openTeamDrawer(username) {
<dt>Segurança</dt><dd>${mfaBadgeHtml(user)}</dd>
</dl>
<form id="team-drawer-form" class="team-drawer-form" autocomplete="off">
<label>Nome de exibição
<div class="acs-field">
<label for="team-drawer-display">Nome completo</label>
<input type="text" id="team-drawer-display" value="${esc(user.display_name || '')}" placeholder="Nome"/>
</label>
<label>Perfil
</div>
<div class="acs-field">
<label>Perfil de acesso</label>
${roleSelectHtml(user.username, user.role, !isRoot)}
</div>
<div class="acs-toggle-row">
<span>Conta ativa</span>
<label class="acs-toggle">
<input type="checkbox" id="team-drawer-active" ${user.active ? 'checked' : ''} ${isRoot ? 'disabled' : ''}/>
<span class="acs-toggle-slider"></span>
</label>
<label>Estado da conta
<select id="team-drawer-active" ${isRoot ? 'disabled' : ''}>
<option value="1" ${user.active ? 'selected' : ''}>ativo</option>
<option value="0" ${!user.active ? 'selected' : ''}>inativo</option>
</select>
</label>
<label>Nova senha
</div>
<div class="acs-field">
<label for="team-drawer-password">Nova senha</label>
<input type="password" id="team-drawer-password" placeholder="opcional (mín. 6 caracteres)" minlength="6"/>
</label>
</div>
${user.totp_enabled ? `
<div class="team-drawer-danger">
<p class="ticket-meta">2FA ativo o usuário pode recuperar no login ou você pode resetar aqui.</p>
<button type="button" class="btn btn-ghost btn-sm" id="team-reset-2fa">Resetar 2FA</button>
</div>` : ''}
<p id="team-drawer-msg" class="admin-msg" hidden></p>
<div class="team-drawer-actions">
<div class="acs-drawer-actions">
<button type="button" class="acs-btn-ghost" data-close-team-drawer>Sair sem salvar</button>
<button type="submit" class="btn btn-primary">Salvar alterações</button>
<button type="button" class="btn btn-ghost" data-close-team-drawer>Cancelar</button>
</div>
</form>`;
@ -3125,7 +3213,7 @@ function openTeamDrawer(username) {
const payload = {
display_name: body.querySelector('#team-drawer-display')?.value?.trim() || null,
role: body.querySelector('.admin-role')?.value,
active: body.querySelector('#team-drawer-active')?.value === '1',
active: !!body.querySelector('#team-drawer-active')?.checked,
};
const pwd = body.querySelector('#team-drawer-password')?.value;
if (pwd && pwd.length >= 6) payload.password = pwd;
@ -3155,8 +3243,16 @@ function openTeamDrawer(username) {
});
}
async function renderAdmin() {
const el = document.getElementById('admin-content');
async function renderAccessControlInto(el, opts = {}) {
if (!el) return;
const {
embedded = false,
matrixMode = false,
roleLock = null,
hideHero = false,
onRegistration = null,
} = opts;
if (!canManageUsers()) {
el.innerHTML = '<p class="loading">Sem permissão</p>';
return;
@ -3165,111 +3261,168 @@ async function renderAdmin() {
try {
const [usersData, regData] = await Promise.all([
api('/v1/auth/users'),
api('/v1/auth/registration-requests').catch(() => ({ pending_count: 0 })),
api('/v1/auth/registration-requests').catch(() => ({ requests: [], pending_count: 0 })),
]);
state.adminUsers = usersData.users || [];
const users = state.adminUsers;
const filtered = filterAdminUsers(users);
const activeCount = users.filter((u) => u.active).length;
const mfaCount = users.filter((u) => u.totp_enabled).length;
const inactiveCount = users.length - activeCount;
const pending = regData.pending_count || 0;
const filterOpts = roleLock ? { roleLock } : {};
const filtered = filterAdminUsers(users, filterOpts);
const scopedUsers = roleLock ? users.filter((u) => u.role === roleLock || (roleLock === 'super_admin' && u.username === 'root')) : users;
const activeCount = scopedUsers.filter((u) => u.active).length;
const mfaCount = scopedUsers.filter((u) => u.totp_enabled).length;
const inactiveCount = scopedUsers.length - activeCount;
const pending = regData.pending_count ?? (regData.requests || []).filter((r) => r.status === 'pending').length;
const { q, role, status, mfa } = state.adminFilter;
const showMatrix = !matrixMode && !!state.features?.access_matrix_ui && getUser()?.role === 'super_admin';
const selected = state.adminSelected;
const roleCode = window.DeskAccessControlPanel?.ROLE_CODES?.[roleLock || role] || '';
const rows = filtered.map((u) => `
<tr class="team-row" data-user="${esc(u.username)}" tabindex="0">
<tr class="acs-row team-row${selected === u.username ? ' selected' : ''}" data-user="${esc(u.username)}" tabindex="0">
<td>
<div class="team-user-cell">
<div class="team-avatar" aria-hidden="true">${esc(userInitials(u.display_name, u.username))}</div>
<div class="acs-user-cell">
<div class="acs-avatar" aria-hidden="true">${esc(userInitials(u.display_name, u.username))}</div>
<div>
<strong class="team-user-name">${esc(u.display_name || u.username)}</strong>
<span class="team-user-email">${esc(u.email || u.username)}</span>
<strong class="acs-user-name">${esc(u.display_name || u.username)}</strong>
<span class="acs-user-sub">${esc(u.email || u.username)}</span>
</div>
</div>
</td>
<td>${roleBadgeHtml(u.role)}</td>
<td>${mfaBadgeHtml(u)}</td>
<td class="team-muted">${fmtRelative(u.last_login_at)}</td>
<td>${u.active ? '<span class="badge ok">ativo</span>' : '<span class="badge closed">inativo</span>'}</td>
<td>${u.active ? '<span class="acs-status acs-status--on">Ativado</span>' : '<span class="acs-status acs-status--off">Desativado</span>'}</td>
<td class="team-actions">
<button type="button" class="btn btn-ghost btn-sm team-edit-btn" data-user="${esc(u.username)}">Editar</button>
<button type="button" class="acs-btn-ghost btn-sm team-edit-btn" data-user="${esc(u.username)}">Editar</button>
</td>
</tr>`).join('');
const railItems = filtered.map((u) => `
<li class="acs-rail-item${selected === u.username ? ' selected' : ''}" data-rail-user="${esc(u.username)}" role="button" tabindex="0">
<div class="acs-avatar" aria-hidden="true">${esc(userInitials(u.display_name, u.username))}</div>
<span>${esc(u.display_name || u.username)}</span>
</li>`).join('');
el.innerHTML = `
<div class="team-admin">
<header class="team-admin-head">
<div>
<h2 class="team-admin-title">Equipe Ligbox</h2>
<p class="ticket-meta">Gestão de acessos ao Support Desk</p>
</div>
<button type="button" class="btn btn-ghost btn-sm" id="team-goto-messages">
Pedidos de cadastro${pending ? ` <span class="badge review">${pending}</span>` : ''}
</button>
</header>
<div class="team-kpi-grid">
<div class="team-kpi card"><span class="team-kpi-val">${users.length}</span><span class="team-kpi-label">membros</span></div>
<div class="team-kpi card"><span class="team-kpi-val">${activeCount}</span><span class="team-kpi-label">ativos</span></div>
<div class="team-kpi card"><span class="team-kpi-val">${mfaCount}</span><span class="team-kpi-label">com 2FA</span></div>
<div class="team-kpi card"><span class="team-kpi-val">${inactiveCount}</span><span class="team-kpi-label">inativos</span></div>
<div class="acs-page${embedded ? ' acs-page--embedded' : ''}${matrixMode ? ' acs-page--matrix' : ''}">
${hideHero ? '' : `
<header class="acs-hero">
<h1>Controle de acesso Suporte</h1>
<p class="acs-breadcrumb">Desk · Configurações · Gestão de acesso · Controle de acesso Suporte</p>
</header>`}
<div class="acs-body">
<div class="acs-layout">
<div class="acs-main">
${matrixMode && roleLock ? `
<div class="acs-matrix-users-head">
<span class="acs-matrix-users-title">Usuários da função</span>
<span class="acs-matrix-users-meta">
<code class="acs-role-code-inline">${esc(roleCode)}</code>
${esc(roleLabel(roleLock))}
<span class="acs-matrix-users-count">${filtered.length} usuário${filtered.length === 1 ? '' : 's'}</span>
</span>
</div>` : ''}
<div class="acs-kpi-row${matrixMode ? ' acs-kpi-row--matrix' : ''}">
<div class="acs-card acs-kpi"><span class="acs-kpi-val">${scopedUsers.length}</span><span class="acs-kpi-label">Usuários</span></div>
<div class="acs-card acs-kpi"><span class="acs-kpi-val">${activeCount}</span><span class="acs-kpi-label">Ativados</span></div>
<div class="acs-card acs-kpi"><span class="acs-kpi-val">${mfaCount}</span><span class="acs-kpi-label">Com 2FA</span></div>
<div class="acs-card acs-kpi"><span class="acs-kpi-val">${inactiveCount}</span><span class="acs-kpi-label">Desativados</span></div>
</div>
<div class="team-toolbar card">
<label class="team-search">
<span class="sr-only">Buscar</span>
<input type="search" id="team-filter-q" placeholder="Buscar nome, e-mail ou perfil…" value="${esc(q)}"/>
</label>
<label>Perfil
<select id="team-filter-role">
<option value="all" ${role === 'all' ? 'selected' : ''}>Todos</option>
${ROLE_OPTIONS.map((r) => `<option value="${r.value}" ${role === r.value ? 'selected' : ''}>${r.label}</option>`).join('')}
<div class="acs-card acs-toolbar">
<div class="acs-search">
<input type="search" id="team-filter-q" placeholder="Pesquisar nome, e-mail ou perfil…" value="${esc(q)}" aria-label="Pesquisar usuários"/>
</div>
<label class="acs-filter">Perfil
<select id="team-filter-role" ${roleLock ? 'disabled' : ''}>
<option value="all" ${role === 'all' && !roleLock ? 'selected' : ''}>Todos</option>
${ROLE_OPTIONS.map((r) => `<option value="${r.value}" ${(roleLock ? roleLock === r.value : role === r.value) ? 'selected' : ''}>${r.label}</option>`).join('')}
</select>
</label>
<label>Estado
<label class="acs-filter">Estado
<select id="team-filter-status">
<option value="all" ${status === 'all' ? 'selected' : ''}>Todos</option>
<option value="active" ${status === 'active' ? 'selected' : ''}>Ativos</option>
<option value="inactive" ${status === 'inactive' ? 'selected' : ''}>Inativos</option>
<option value="active" ${status === 'active' ? 'selected' : ''}>Ativados</option>
<option value="inactive" ${status === 'inactive' ? 'selected' : ''}>Desativados</option>
</select>
</label>
<label>2FA
<label class="acs-filter">2FA
<select id="team-filter-mfa">
<option value="all" ${mfa === 'all' ? 'selected' : ''}>Todos</option>
<option value="on" ${mfa === 'on' ? 'selected' : ''}>Com 2FA</option>
<option value="off" ${mfa === 'off' ? 'selected' : ''}>Sem 2FA</option>
</select>
</label>
<button type="button" class="acs-btn-ghost" id="team-goto-messages">
Pedidos de cadastro${pending ? ` (${pending})` : ''}
</button>
${showMatrix ? '<button type="button" class="acs-btn-primary" id="team-goto-matrix">Matriz de Acesso</button>' : ''}
${matrixMode ? '<button type="button" class="acs-btn-ghost" id="team-freeze-hint" title="Use Editar → desactivar conta">Congelar</button>' : ''}
</div>
<div class="card team-table-wrap">
<table class="data-table team-table">
<div class="acs-card acs-table-card">
<div class="acs-table-head">
<h3>Usuários do Support Desk</h3>
<span class="ticket-meta">${filtered.length} de ${users.length}</span>
</div>
<div class="acs-table-wrap">
<table class="acs-table">
<thead>
<tr>
<th>Membro</th>
<th>Usuário</th>
<th>Perfil</th>
<th>Segurança</th>
<th>Último login</th>
<th>Estado</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="6" class="loading">Nenhum membro encontrado</td></tr>'}
${rows || '<tr><td colspan="6" class="loading">Nenhum usuário encontrado</td></tr>'}
</tbody>
</table>
<p class="team-table-foot ticket-meta">${filtered.length} de ${users.length} membros</p>
</div>
<p class="acs-table-foot">${filtered.length} de ${users.length} usuários exibidos</p>
</div>
</div>
<aside class="acs-rail">
<div class="acs-card acs-rail-card">
<div class="acs-rail-head">
<h4>Usuários</h4>
<span class="acs-rail-count">${filtered.length}</span>
</div>
<div class="acs-rail-search">
<input type="search" id="acs-rail-q" placeholder="Digite nome ou e-mail" value="${esc(q)}" aria-label="Filtrar lista rápida"/>
</div>
<ul class="acs-rail-list" role="list">
${railItems || '<li class="ticket-meta">Nenhum usuário</li>'}
</ul>
</div>
</aside>
</div>
</div>
</div>`;
const rerender = () => {
if (matrixMode && state.view === 'access-matrix') {
const root = document.getElementById('access-matrix-content');
if (root && window.DeskAccessMatrix?.getState?.()?.tab === 'access-control') {
window.renderAccessMatrix?.();
return;
}
}
renderAccessControlInto(el, opts);
};
const applyFilters = () => {
state.adminFilter = {
q: document.getElementById('team-filter-q')?.value || '',
role: document.getElementById('team-filter-role')?.value || 'all',
role: roleLock || document.getElementById('team-filter-role')?.value || 'all',
status: document.getElementById('team-filter-status')?.value || 'all',
mfa: document.getElementById('team-filter-mfa')?.value || 'all',
};
renderAdmin();
rerender();
};
document.getElementById('team-filter-q')?.addEventListener('input', () => {
@ -3279,35 +3432,87 @@ async function renderAdmin() {
['team-filter-role', 'team-filter-status', 'team-filter-mfa'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', applyFilters);
});
document.getElementById('team-goto-messages')?.addEventListener('click', () => setView('messages'));
document.getElementById('team-goto-messages')?.addEventListener('click', () => {
if (typeof onRegistration === 'function') onRegistration();
else setView('messages');
});
document.getElementById('team-goto-matrix')?.addEventListener('click', () => {
state.matrixTab = 'overview';
setView('access-matrix');
});
document.getElementById('team-freeze-hint')?.addEventListener('click', () => {
window.alert('Para congelar: abra o usuário → desactive "Conta ativa" → Salvar.');
});
document.getElementById('acs-rail-q')?.addEventListener('input', (e) => {
const mainQ = document.getElementById('team-filter-q');
if (mainQ) mainQ.value = e.target.value;
clearTimeout(state._teamSearchTimer);
state._teamSearchTimer = setTimeout(applyFilters, 200);
});
const openUser = (username) => {
if (!username) return;
openTeamDrawer(username);
el.querySelectorAll('.acs-row, .acs-rail-item').forEach((node) => {
const match = node.dataset.user === username || node.dataset.railUser === username;
node.classList.toggle('selected', match);
});
};
el.querySelectorAll('.team-edit-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
openTeamDrawer(btn.dataset.user);
openUser(btn.dataset.user);
});
});
el.querySelectorAll('.team-row').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
openTeamDrawer(row.dataset.user);
openUser(row.dataset.user);
});
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamDrawer(row.dataset.user);
openUser(row.dataset.user);
}
});
});
el.querySelectorAll('.acs-rail-item').forEach((item) => {
item.addEventListener('click', () => openUser(item.dataset.railUser));
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openUser(item.dataset.railUser);
}
});
});
if (state.adminSelected) {
openTeamDrawer(state.adminSelected);
if (state.adminSelected && (state.view === 'admin' || state.view === 'access-matrix')) {
openUser(state.adminSelected);
}
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderAdmin() {
const el = document.getElementById('admin-content');
if (!el) return;
if (state.features?.access_matrix_ui && getUser()?.role === 'super_admin') {
state.matrixTab = 'access-control';
setView('access-matrix');
return;
}
await renderAccessControlInto(el, { embedded: false });
}
window.DeskAccessControl = {
renderInto: renderAccessControlInto,
};
window.getDeskState = () => state;
async function renderModules() {
const el = document.getElementById('modules-content');
if (!el) return;
@ -3367,6 +3572,19 @@ async function renderModules() {
const REG_ROLE_LABELS = ROLE_LABELS;
async function renderMessages() {
const el = document.getElementById('messages-content');
if (!el) return;
if (window.DeskOperationalFeed?.paint) {
return window.DeskOperationalFeed.paint(el);
}
if (!canManageUsers()) {
el.innerHTML = '<p class="loading">Sem permissão</p>';
return;
}
el.innerHTML = '<p class="loading">Módulo Central Operacional indisponível.</p>';
}
async function renderRegistrationRequestsLegacy() {
const el = document.getElementById('messages-content');
if (!canManageUsers()) {
el.innerHTML = '<p class="loading">Sem permissão</p>';
@ -4248,17 +4466,84 @@ function procCardHtml(opts) {
).join('');
return `
<article class="proc-card proc-card--${accent}" data-stack-id="${esc(id)}">
<span class="badge proc-card-badge ${statusCls}">${esc(statusLabel)}</span>
<header class="proc-card-head">
<header class="proc-card-top">
<span class="proc-card-icon" aria-hidden="true">${icon}</span>
<span class="proc-card-spec">${esc(spec)}</span>
</header>
<div class="proc-card-heading">
<h3 class="proc-card-title">${esc(title)}</h3>
<div class="proc-card-meta">
<span class="proc-card-spec">${esc(spec)}</span>
<span class="badge proc-card-badge ${statusCls}">${esc(statusLabel)}</span>
</div>
</div>
</header>
<p class="proc-card-desc">${desc}</p>
<footer class="proc-card-foot">${acts}</footer>
</article>`;
}
function infraDetailListHtml(items) {
if (!items?.length) return '<li class="muted">—</li>';
return items.map((line) => `<li><code>${esc(line)}</code></li>`).join('');
}
function infraServiceDetailHtml(vm, svc) {
const meta = window.STACK_SERVICE_META?.[svc.id] || {};
const statusCls = stackServiceStatusCls(svc);
const specLabel = svc.spec && svc.spec !== '—' ? `Spec ${svc.spec}` : (svc.kind || 'stack').toUpperCase();
const url = svc.url || '—';
const urlLink = url.startsWith('http')
? `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(url)}</a>`
: `<code>${esc(url)}</code>`;
return `
<div class="infra-detail">
<div class="infra-detail-hero proc-card--${esc(svc.accent || 'teal')}">
<span class="infra-detail-hero__icon" aria-hidden="true">${svc.icon || '⚙️'}</span>
<div class="infra-detail-hero__copy">
<p class="infra-detail-hero__eyebrow">${esc(vm.vm_label)} · VM${esc(vm.vm)} · ${esc(vm.ip)}</p>
<h4 class="infra-detail-hero__title">${esc(svc.title)}</h4>
<p class="infra-detail-hero__sub">${esc(specLabel)} · ${esc(svc.kind || 'serviço')}</p>
</div>
<span class="badge ${statusCls} infra-detail-hero__status">${esc(svc.status || '—')}</span>
</div>
<div class="infra-detail-grid">
<section class="infra-detail-card">
<h5 class="infra-detail-card__title">VM &amp; Rede</h5>
<dl class="infra-detail-kv">
<dt>VM</dt><dd>VM${esc(vm.vm)}</dd>
<dt>IP</dt><dd><code>${esc(vm.ip)}</code></dd>
<dt>Rótulo</dt><dd>${esc(vm.vm_label)}</dd>
</dl>
</section>
<section class="infra-detail-card">
<h5 class="infra-detail-card__title">API &amp; Probe</h5>
<dl class="infra-detail-kv">
<dt>Endpoint</dt><dd>${urlLink}</dd>
<dt>API Desk</dt><dd><code>${esc(meta.apiPath || '')}</code></dd>
<dt>HTTP</dt><dd>${svc.http_status != null ? esc(String(svc.http_status)) : ''}</dd>
<dt>Detalhe probe</dt><dd>${esc(svc.detail || '')}</dd>
</dl>
</section>
<section class="infra-detail-card">
<h5 class="infra-detail-card__title">Spec &amp; Código</h5>
<dl class="infra-detail-kv">
<dt>Pasta Spec</dt><dd><code>${esc(meta.specFolder || `specs/*${svc.spec}*/`)}</code></dd>
<dt>Probe backend</dt><dd><code>${esc(meta.probeCode || 'api/app/stack_health.py')}</code></dd>
</dl>
<ul class="infra-detail-code-list">${infraDetailListHtml(meta.codePaths)}</ul>
</section>
<section class="infra-detail-card infra-detail-card--wide">
<h5 class="infra-detail-card__title">Validação deste card</h5>
<p class="infra-detail-validation">${esc(meta.validation || 'Probe automático no stack health')}</p>
<p class="infra-detail-id"><span>ID</span> <code>${esc(svc.id)}</code></p>
</section>
</div>
<div class="infra-detail-actions">
${url.startsWith('http') ? `<a class="btn secondary btn-sm" href="${esc(url)}" target="_blank" rel="noopener">Abrir endpoint</a>` : ''}
<button type="button" class="btn btn-ghost btn-sm" data-close-infra-process-modal>Fechar</button>
</div>
</div>`;
}
function closeInfraProcessModal() {
const modal = document.getElementById('infra-process-modal');
if (!modal) return;
@ -4303,6 +4588,12 @@ function stackServiceActions(svcId) {
if (svcId === 'vm122-purge-auth') {
return [{ label: 'Gerir códigos', action: 'purge-manage', primary: true }];
}
if (svcId === 'vm122-email-relay') {
return [
{ label: 'Configuração', action: 'detail', primary: true },
{ label: 'Testar envio', action: 'test-email-relay', primary: false },
];
}
const actions = [{ label: 'Detalhes', action: 'detail', primary: true }];
if (svcId === 'vm122-webhook-soc') actions.push({ label: 'Testar', action: 'test-webhook', primary: false });
if (svcId === 'vm123-openpanel-bridge') actions.push({ label: 'Testar', action: 'test-openpanel', primary: false });
@ -4324,11 +4615,58 @@ function bindStackCardActions(root) {
else if (action === 'test-webhook') runWebhookIntegrationTest('infra');
else if (action === 'test-openpanel') runOpenPanelApiTest();
else if (action === 'purge-manage') openInfraProcessDetail('purge');
else if (action === 'test-email-relay') runEmailRelayTest();
});
});
}
function openStackServiceDetail(svcId) {
async function runEmailRelayTest() {
const to = window.prompt('E-mail de teste (relay VM122 → VM112):', state.user?.email || 'admin@ligbox.com.br');
if (!to?.trim()) return;
try {
const res = await api('/v1/infra/email-relay/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: to.trim() }),
});
const ok = res?.ok;
toast(ok ? 'E-mail de teste enviado' : 'Falha no envio — ver Infra → Email Relay', ok ? 'success' : 'error');
if (state.view === 'infra') await renderInfra({ force: true });
} catch (err) {
toast(err?.message || 'Erro no teste de relay', 'error');
}
}
function emailRelayConfigHtml(relayStatus) {
const cfg = relayStatus?.config || {};
const checks = relayStatus?.checks || {};
const localOk = checks.smtp_local?.ok ? 'ok' : 'escalated';
const relayOk = checks.smtp_relay?.ok ? 'ok' : 'escalated';
const transport = cfg.transport_local || {};
const transportRows = Object.entries(transport)
.map(([dom, dest]) => `<tr><td><code>${esc(dom)}</code></td><td><code>${esc(dest)}</code></td></tr>`)
.join('');
return `
<section class="infra-detail-card infra-detail-card--wide">
<h5 class="infra-detail-card__title">Relay VM122 VM112</h5>
<dl class="infra-detail-kv">
<dt>SMTP local</dt><dd><code>${esc(cfg.smtp_host || '')}:${esc(String(cfg.smtp_port || ''))}</code>
<span class="badge ${localOk}">${checks.smtp_local?.ok ? 'online' : 'down'}</span></dd>
<dt>Relayhost</dt><dd><code>${esc(cfg.relayhost || '')}:${esc(String(cfg.relayport || ''))}</code>
<span class="badge ${relayOk}">${checks.smtp_relay?.ok ? 'online' : 'down'}</span></dd>
<dt>Remetente</dt><dd><code>${esc(cfg.mail_from || '')}</code></dd>
<dt>Origem</dt><dd><code>${esc(cfg.myorigin || '')}</code></dd>
<dt>Rota externa</dt><dd>${esc(cfg.external_route || 'relayhost mail.ligbox.com.br')}</dd>
</dl>
<table class="infra-table" style="margin-top:0.75rem;width:100%">
<thead><tr><th>Domínio local</th><th>Transport</th></tr></thead>
<tbody>${transportRows || '<tr><td colspan="2" class="muted">—</td></tr>'}</tbody>
</table>
<p class="infra-hint">Convites Desk e OTP usam <code>mail_notify.py</code> Postfix :25 Carbonio VM112 Internet (DKIM).</p>
</section>`;
}
async function openStackServiceDetail(svcId) {
const hit = findStackService(svcId);
if (!hit) {
if (svcId === 'integrations-json') openInfraProcessDetail('integrations');
@ -4336,20 +4674,22 @@ function openStackServiceDetail(svcId) {
}
const { vm, svc } = hit;
const specLabel = svc.spec && svc.spec !== '—' ? `Spec ${svc.spec}` : svc.kind || 'stack';
let extra = '';
if (svcId === 'vm122-email-relay') {
try {
const relayStatus = await api('/v1/infra/email-relay/status');
extra = emailRelayConfigHtml(relayStatus);
} catch {
extra = '<p class="muted">Não foi possível carregar configuração do relay.</p>';
}
}
openInfraProcessModal(
svc.title,
`${vm.vm_label} · ${specLabel}`,
`${infraKvHtml([
['VM', `${vm.vm} · ${esc(vm.ip)}`],
['Tipo', esc(svc.kind || '—')],
['URL', `<code>${esc(svc.url || '—')}</code>`],
['Status', esc(svc.status || '—')],
['HTTP', svc.http_status != null ? String(svc.http_status) : '—'],
['Detalhe', esc(svc.detail || '—')],
])}
<div class="infra-actions">
${svc.url && svc.url.startsWith('http') ? `<a class="btn btn-ghost btn-sm" href="${esc(svc.url)}" target="_blank" rel="noopener">Abrir URL</a>` : ''}
</div>`
`${vm.vm_label} · ${specLabel} · VM${vm.vm}`,
infraServiceDetailHtml(vm, svc).replace(
'</div>\n <div class="infra-detail-actions">',
`${extra}</div>\n <div class="infra-detail-actions">`,
),
);
}
@ -4432,8 +4772,11 @@ function openInfraProcessDetail(procId) {
}
async function renderInfra(options = {}) {
const { poll = false } = options;
const { poll = false, force = false } = options;
const el = document.getElementById('infra-content');
if (!el) return;
if (infraRenderInFlight && !force) return;
infraRenderInFlight = true;
if (!viewHasContent(el, '.infra-vm-section')) {
el.innerHTML = '<p class="loading">Verificando stack…</p>';
} else if (poll) {
@ -4441,9 +4784,9 @@ async function renderInfra(options = {}) {
}
try {
const [stack, integrations, health, vm123Health, purgeMeta] = await Promise.all([
api('/v1/infra/stack/status'),
api('/v1/infra/stack/status', {}, 45000),
api('/v1/integrations').catch(() => null),
api('/v1/integrations/health').catch(() => ({})),
api('/v1/integrations/health', {}, 20000).catch(() => ({})),
api('/v1/vm123/health').catch(() => null),
api('/v1/infra/purge-auth-domains').catch(() => ({ domains: [], can_generate: false })),
]);
@ -4498,8 +4841,12 @@ async function renderInfra(options = {}) {
document.getElementById('btn-infra-refresh-stack')?.addEventListener('click', () => renderInfra({ force: true }));
bindStackCardActions(el);
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
const msg = e?.name === 'AbortError' || /aborted/i.test(e?.message || '')
? 'Verificação do stack excedeu o tempo — tente «Atualizar stack» (probes em 5 VMs podem levar ~20s)'
: e.message;
el.innerHTML = `<p class="loading">Erro: ${esc(msg)}</p>`;
} finally {
infraRenderInFlight = false;
el?.classList.remove('view--refreshing');
}
}
@ -4638,6 +4985,19 @@ async function refresh(options = {}) {
if (state.view === 'account') await renderAccount();
}
function bindDeskNav() {
if (document.body.dataset.deskNavBound === '1') return;
document.body.dataset.deskNavBound = '1';
document.addEventListener('click', (ev) => {
const btn = ev.target.closest('[data-desk-nav][data-view]');
if (!btn || btn.hasAttribute('hidden')) return;
const view = btn.dataset.view;
if (!view) return;
ev.preventDefault();
setView(view);
});
}
document.querySelectorAll('.nav button').forEach((btn) => {
btn.addEventListener('click', () => setView(btn.dataset.view));
});
@ -4727,6 +5087,7 @@ document.getElementById('nav-console')?.addEventListener('click', (ev) => {
bindInfraProcessModal();
bindTeamDrawerClose();
bindSocTestModal();
bindDeskNav();
setView(resolveBootView());
ensureValidSession().then((valid) => {

View file

@ -30,9 +30,9 @@
.app-chrome__inner {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
min-height: 58px;
padding: 0.45rem 1.25rem;
padding: 0.45rem 0.85rem;
max-width: 100%;
}
@ -65,28 +65,35 @@
font-weight: 700;
}
/* Nav — mockup Roger: grupos com largura natural, separadores finos, sem stretch/ellipsis */
.app-chrome__nav {
display: flex;
align-items: center;
gap: 0.45rem;
gap: 0;
flex: 1;
min-width: 0;
padding: 0.15rem 0.25rem;
padding: 0.15rem 0;
overflow-x: auto;
scrollbar-width: none;
mask-image: linear-gradient(90deg, transparent, #000 12px, #000 calc(100% - 12px), transparent);
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(92, 46, 46, 0.2) transparent;
}
.app-chrome__nav::-webkit-scrollbar {
display: none;
height: 4px;
}
.app-chrome__nav::-webkit-scrollbar-thumb {
background: rgba(92, 46, 46, 0.18);
border-radius: 999px;
}
.nav-zone {
display: flex;
flex-direction: column;
gap: 0.22rem;
flex-shrink: 0;
padding: 0.2rem 0.35rem 0.15rem;
flex: 0 0 auto;
padding: 0.2rem 0.28rem 0.15rem;
border-radius: 0;
transition: background 0.2s ease;
}
@ -121,9 +128,9 @@
background: transparent;
color: #5f5852;
font: inherit;
font-size: 0.8125rem;
font-size: 0.78rem;
font-weight: 520;
padding: 0.42rem 0.75rem;
padding: 0.4rem 0.62rem;
border-radius: 0;
cursor: pointer;
text-decoration: none;
@ -158,7 +165,8 @@
text-decoration: none;
color: inherit;
align-self: center;
padding: 0.15rem 0.25rem;
flex: 0 0 auto;
padding: 0.15rem 0.28rem;
}
.nav-zone--console .nav-zone__label {
@ -169,11 +177,14 @@
display: inline-block;
color: #0f4c81;
font-weight: 620;
font-size: 0.78rem;
border: 1px solid rgba(14, 165, 233, 0.28);
background: #f0f9ff;
margin-top: 0.12rem;
padding: 0.4rem 0.62rem;
border-radius: 0;
border-right: none;
white-space: nowrap;
}
.nav-link--console:hover {
@ -187,17 +198,17 @@
width: 1px;
align-self: center;
height: 28px;
margin: 0 0.1rem;
background: linear-gradient(180deg, transparent, rgba(92, 46, 46, 0.12), transparent);
margin: 0 0.08rem;
flex-shrink: 0;
background: linear-gradient(180deg, transparent, rgba(92, 46, 46, 0.12), transparent);
}
.app-chrome__meta {
display: flex;
align-items: center;
gap: 0.45rem;
gap: 0.4rem;
flex-shrink: 0;
padding-left: 0.35rem;
padding-left: 0.25rem;
}
.app-chrome__meta .header-user {
@ -248,6 +259,7 @@
border-radius: 0;
font-size: 0.78rem;
font-weight: 550;
padding: 0.32rem 0.55rem;
border-color: rgba(92, 46, 46, 0.1);
color: #5f5852;
}
@ -257,19 +269,86 @@
color: var(--accent);
}
.app-chrome__actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.app-chrome__action {
appearance: none;
border: 1px solid rgba(92, 46, 46, 0.12);
background: #fff;
color: #5f5852;
font: inherit;
font-size: 0.78rem;
font-weight: 550;
padding: 0.32rem 0.65rem;
cursor: pointer;
line-height: 1.2;
}
.app-chrome__action:hover {
background: rgba(92, 46, 46, 0.06);
color: var(--accent);
}
.app-chrome__action--muted {
color: #64748b;
}
.header-user--chrome {
padding: 0.2rem 0.55rem;
border: 1px solid rgba(92, 46, 46, 0.1);
background: rgba(255, 255, 255, 0.85);
}
.status-pill--chrome {
border-radius: 0;
}
.workspace {
display: grid;
grid-template-columns: 0 1fr;
grid-template-columns: 1fr;
flex: 1;
min-height: 0;
min-width: 0;
width: 100%;
}
.shell--v2:not(.shell--no-context) .workspace {
grid-template-columns: 232px 1fr;
grid-template-columns: 232px minmax(0, 1fr);
align-items: start;
}
.shell--v2:not(.shell--no-context):has(.main--access-matrix) .workspace {
grid-template-columns: minmax(248px, 260px) minmax(0, 1fr);
}
.shell--v2:not(.shell--no-context):has(.main--access-matrix) .context-sidebar {
overflow-x: hidden;
overflow-y: auto;
max-height: calc(100vh - 58px);
}
.context-sidebar {
grid-column: 1;
grid-row: 1;
}
.workspace-main {
grid-column: 1;
grid-row: 1;
min-width: 0;
width: 100%;
}
.shell--v2:not(.shell--no-context) .workspace-main {
grid-column: 2;
}
.shell--v2.shell--no-context .context-sidebar {
display: none !important;
}
.shell--v2 .main {
@ -280,25 +359,28 @@
background: rgba(255, 253, 249, 0.92);
backdrop-filter: blur(8px);
border-right: 1px solid rgba(92, 46, 46, 0.08);
padding: 1rem 0.75rem 0.75rem;
padding: 0.35rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
min-height: calc(100vh - 58px);
align-self: start;
overflow: hidden;
}
.context-sidebar__head {
padding: 0 0.35rem 0.75rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.75rem;
margin: -0.2rem 0 0.75rem;
}
.context-sidebar__label {
margin: 0;
margin: 0 0 0.12rem;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
line-height: 1;
}
.context-sidebar__title {
@ -352,22 +434,78 @@
.context-nav .am-role-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.35rem;
padding: 0;
border: none;
background: transparent;
min-width: 0;
}
.context-nav .am-role-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
.context-nav .am-role-group.nav-zone {
flex: 0 0 auto;
padding: 0.2rem 0.28rem 0.15rem;
}
.context-nav .am-role-btn {
.context-nav .am-role-group .nav-zone__links {
flex-direction: column;
align-items: stretch;
}
.context-nav .am-role-group .nav-link {
width: 100%;
text-align: left;
justify-content: flex-start;
align-items: center;
gap: 0.45rem;
border-right: none;
border-bottom: 1px solid rgba(92, 46, 46, 0.08);
padding: 0.42rem 0.35rem;
min-height: 2rem;
overflow: hidden;
}
.context-nav .am-role-btn .am-role-code {
color: var(--accent);
background: var(--accent-soft);
}
.context-nav .am-role-btn.active .am-role-code {
background: rgba(92, 46, 46, 0.12);
}
.context-nav .am-role-label {
font-size: 0.78rem;
}
.context-nav {
overflow-x: hidden;
overflow-y: auto;
min-width: 0;
flex: 1 1 auto;
}
.context-nav .am-role-group .nav-link:last-child {
border-bottom: none;
}
.context-nav .am-role-btn--frozen {
opacity: 0.55;
}
.context-nav .am-role-paused {
font-size: 0.62rem;
color: var(--muted);
font-weight: 500;
}
.context-sidebar__head:has(+ .context-nav .am-role-list) .context-sidebar__title:empty {
display: none;
margin: 0;
}
.context-sidebar__head:has(+ .context-nav .am-role-list) {
padding-bottom: 0.45rem;
margin-bottom: 0.45rem;
}
.context-sidebar__foot {
@ -383,6 +521,10 @@
border-bottom: 1px solid var(--border);
}
.shell--v2:not(.shell--no-context) .workspace-main .page-header {
margin-top: 0.15rem;
}
.shell--v2 .page-header h2 {
font-size: 1.35rem;
letter-spacing: -0.02em;
@ -396,6 +538,34 @@
grid-template-columns: 1fr;
}
.shell--v2 .main.main--access-matrix {
padding-top: 0.65rem;
}
.shell--v2 .main.main--access-matrix .page-header {
margin-bottom: 0.4rem;
padding-bottom: 0.4rem;
}
.shell--v2 .main.main--access-matrix .page-header p {
margin-top: 0.2rem;
}
.shell--v2 #access-matrix-content .am-wrap {
gap: 0.65rem;
}
.shell--v2 #access-matrix-content .am-header--toolbar {
padding: 0;
margin: -0.15rem 0 0;
min-height: 0;
}
.shell--v2 #access-matrix-content .am-subnav {
margin-top: 0.15rem;
margin-bottom: 0.2rem;
}
@media (max-width: 960px) {
.app-chrome__inner {
flex-wrap: wrap;
@ -405,7 +575,6 @@
.app-chrome__nav {
order: 3;
flex-basis: 100%;
mask-image: none;
}
.app-chrome__meta .header-user__text {
display: none;

View file

@ -0,0 +1,727 @@
/**
* Spec 039 Quem faz o quê (UX mockup Roger 2026-06-29)
* Cards KPI · tabela · painel lateral · modal de edição
*/
(function () {
'use strict';
const LEVELS = [
{ id: 'full', label: 'Total', cls: 'qfx-level--full' },
{ id: 'approve', label: 'Aprovação', cls: 'qfx-level--approve' },
{ id: 'read', label: 'Leitura', cls: 'qfx-level--read' },
{ id: 'link', label: 'Link', cls: 'qfx-level--link' },
{ id: 'api', label: 'API', cls: 'qfx-level--api' },
{ id: 'none', label: 'Negado', cls: 'qfx-level--none' },
];
const CHIP_COLORS = {
SU: '#2563eb', CO: '#7c3aed', TEC: '#0284c7', NOC: '#64748b',
SAD: '#9333ea', SSU: '#6d28d9', FIN: '#0891b2', MKT: '#ea580c',
SEO: '#16a34a', DEV: '#0d9488', DVO: '#1d4ed8', SOC: '#dc2626',
CMS: '#b45309', AIO: '#6b21a8', PTR: '#a16207',
};
const CHIP_BG = {
SU: '#dbeafe', CO: '#ede9fe', TEC: '#e0f2fe', NOC: '#f1f5f9',
SAD: '#f3e8ff', SSU: '#ede9fe', FIN: '#cffafe', MKT: '#ffedd5',
SEO: '#dcfce7', DEV: '#ccfbf1', DVO: '#dbeafe', SOC: '#fee2e2',
CMS: '#fef3c7', AIO: '#f3e8ff', PTR: '#fef3c7',
};
const KPI_ICONS = {
action: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
who: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19c0-2.5 2.7-4.5 6-4.5s6 2 6 4.5"/><path d="M17 19c0-1.8 1.5-3.2 3.5-3.5"/></svg>',
why: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="1" fill="currentColor"/></svg>',
};
let catalogState = null;
let filterGroup = 'all';
let searchQuery = '';
let drawerActionId = null;
let drawerDraft = null;
let editModalOpen = false;
let editDraft = null;
let editModalHost = null;
let saving = false;
let saveError = null;
let searchDebounceTimer = null;
const EDIT_PORTAL_ID = 'qfx-edit-modal-portal';
const BODY_LOCK_CLASS = 'qfx-scroll-lock';
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
async function loadCatalog() {
const r = await fetchWithTimeout('/api/v1/rbac/actions', { headers: authHeaders() });
if (!r.ok) throw new Error(`${r.status}`);
catalogState = await r.json();
return catalogState;
}
async function patchLevel(actionId, roleId, level, reset) {
saving = true;
saveError = null;
const r = await fetchWithTimeout('/api/v1/rbac/actions/override', {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ action_id: actionId, role_id: roleId, level, reset: !!reset }),
});
saving = false;
if (!r.ok) throw new Error((await r.text()).slice(0, 200) || String(r.status));
return r.json();
}
function roleCode(roleId) {
return catalogState?.role_codes?.[roleId] || roleId?.slice(0, 3).toUpperCase();
}
function roleLabel(roleId) {
return catalogState?.roles?.[roleId]?.label || roleId;
}
function levelMeta(level) {
return LEVELS.find((l) => l.id === level) || { id: level, label: level, cls: 'qfx-level--read' };
}
function whyForAction(action) {
if (action.why) return action.why;
const map = catalogState?.executive_map || [];
const hit = map.find((r) => r.action_ids?.includes(action.id));
return hit?.why || 'Operação registada no catálogo Spec 039';
}
function rolesWithAccess(action) {
const roles = catalogState?.roles || {};
return Object.keys(roles).filter((rid) => {
const lv = action.effective?.[rid] || 'none';
return lv !== 'none';
});
}
function filteredActions() {
let list = catalogState?.actions || [];
if (filterGroup !== 'all') list = list.filter((a) => a.group === filterGroup);
const q = searchQuery.trim().toLowerCase();
if (q) {
list = list.filter((a) =>
a.id.toLowerCase().includes(q)
|| (a.label || '').toLowerCase().includes(q)
|| whyForAction(a).toLowerCase().includes(q)
);
}
return list;
}
function kpiStats(actions) {
const roles = catalogState?.roles || {};
const roleIds = Object.keys(roles);
let activeProfiles = 0;
roleIds.forEach((rid) => {
if (actions.some((a) => (a.effective?.[rid] || 'none') !== 'none')) activeProfiles += 1;
});
const purposes = new Set(actions.map((a) => whyForAction(a)));
return {
actions: actions.length,
profiles: activeProfiles || roleIds.length,
purposes: purposes.size,
};
}
function descForAction(action) {
return `Permite ${(action.label || '').toLowerCase()} no ecossistema Ligbox OPS.`;
}
function finalidadeSub(action) {
const g = (catalogState?.groups || []).find((x) => x.id === action.group);
return g?.label || 'Operação registada no catálogo';
}
function splitTagHtml(roleId, on, editable, toggle, compact) {
const code = roleCode(roleId);
const bg = CHIP_BG[code] || '#f1f5f9';
const fg = CHIP_COLORS[code] || '#64748b';
const cls = `qfx-split-tag${on ? ' on' : ''}${!on ? ' off' : ''}${compact ? ' qfx-split-tag--compact' : ''}`;
const attrs = toggle && editable
? ` type="button" data-qfx-toggle-role="${esc(roleId)}"`
: '';
const tag = toggle && editable ? 'button' : 'span';
return `<${tag} class="${cls}"${attrs} style="--tag-bg:${bg};--tag-fg:${fg}">
<span class="qfx-split-tag__code">${esc(code)}</span>
<span class="qfx-split-tag__name">${esc(roleLabel(roleId))}</span>
</${tag}>`;
}
function chipHtml(roleId) {
return splitTagHtml(roleId, true, false, false, true);
}
function renderKpiCards(stats) {
const cards = [
{ key: 'action', label: 'AÇÃO', sub: 'O que pode ser feito no sistema', val: stats.actions, foot: 'ações cadastradas', mod: 'action' },
{ key: 'who', label: 'QUEM REALIZA', sub: 'Perfis/Grupos que executam', val: stats.profiles, foot: 'perfis e grupos', mod: 'who' },
{ key: 'why', label: 'POR QUÊ', sub: 'Finalidade da ação', val: stats.purposes, foot: 'finalidades mapeadas', mod: 'why' },
];
return `<div class="qfx-kpi-grid">${cards.map((c) => `
<article class="qfx-kpi qfx-kpi--${c.mod}">
<div class="qfx-kpi-body">
<h3 class="qfx-kpi-label">${c.label}</h3>
<p class="qfx-kpi-subtitle">${c.sub}</p>
<strong class="qfx-kpi-val">${c.val}</strong>
<span class="qfx-kpi-foot">${c.foot}</span>
</div>
<div class="qfx-kpi-icon-ring" aria-hidden="true">${KPI_ICONS[c.mod]}</div>
</article>`).join('')}</div>`;
}
function renderToolbar(groups) {
const groupOpts = ['<option value="all">Todos os grupos</option>']
.concat(groups.map((g) =>
`<option value="${esc(g.id)}"${filterGroup === g.id ? ' selected' : ''}>${esc(g.label)}</option>`
)).join('');
return `
<div class="qfx-toolbar">
<label class="qfx-field">
<span>Grupo</span>
<select class="qfx-select" data-qfx-group>${groupOpts}</select>
</label>
<label class="qfx-field qfx-field--search">
<span class="sr-only">Buscar</span>
<input type="search" class="qfx-search" data-qfx-search placeholder="Buscar acção, perfil ou nível…" value="${esc(searchQuery)}"/>
</label>
${filterGroup !== 'all' || searchQuery ? '<button type="button" class="qfx-btn-ghost" data-qfx-clear-filters>Limpar filtros</button>' : ''}
</div>`;
}
function renderActionsTable(actions, selectedRole, editable) {
const rows = actions.map((a) => {
const eff = a.effective?.[selectedRole] || 'none';
const lm = levelMeta(eff);
const who = rolesWithAccess(a);
const isOpen = drawerActionId === a.id;
const levelOpts = LEVELS.map((l) =>
`<option value="${l.id}"${eff === l.id ? ' selected' : ''}>${esc(l.label)}</option>`
).join('');
return `
<tr class="qfx-row${isOpen ? ' qfx-row--open' : ''}${a.overridden_roles?.includes(selectedRole) ? ' qfx-row--custom' : ''}" data-qfx-action="${esc(a.id)}">
<td class="qfx-col-action">
<strong class="qfx-action-title">${esc(a.label)}</strong>
<code class="qfx-action-id">${esc(a.id)}</code>
</td>
<td class="qfx-col-who">${who.map(chipHtml).join('') || '<span class="qfx-muted">—</span>'}</td>
<td class="qfx-col-why">${esc(whyForAction(a))}</td>
<td class="qfx-col-level">
<select class="qfx-level-select ${lm.cls}" data-qfx-level data-action-id="${esc(a.id)}" data-role-id="${esc(selectedRole)}" ${!editable || saving ? 'disabled' : ''}>
${levelOpts}
</select>
</td>
<td class="qfx-col-ops">
<button type="button" class="qfx-icon-btn" data-qfx-open="${esc(a.id)}" title="Ver detalhe">👁</button>
<button type="button" class="qfx-icon-btn" data-qfx-open="${esc(a.id)}" title="Editar"></button>
</td>
</tr>`;
}).join('');
return `
<section class="qfx-table-block">
<header class="qfx-table-head">
<h4>Ações e permissões</h4>
<span class="qfx-table-count">${actions.length} resultado${actions.length === 1 ? '' : 's'}</span>
</header>
<div class="qfx-table-wrap">
<table class="qfx-table">
<thead>
<tr>
<th>AÇÃO</th>
<th>QUEM REALIZA</th>
<th>POR QUÊ</th>
<th>NÍVEL · ${esc(roleCode(selectedRole))}</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody>${rows || '<tr><td colspan="5" class="qfx-empty">Nenhuma acção encontrada.</td></tr>'}</tbody>
</table>
</div>
</section>`;
}
function renderSidePanel(selectedRole, editable) {
if (!drawerActionId || !drawerDraft) return '';
const a = drawerDraft;
const groups = catalogState?.groups || [];
const roleIds = Object.keys(catalogState?.roles || {}).filter((r) => !['api_service', 'agent_system'].includes(r));
const effLevel = a.effective?.[selectedRole] || 'none';
const lm = levelMeta(effLevel);
const roleBadges = roleIds.map((rid) => {
const lv = a.effective?.[rid] || 'none';
return splitTagHtml(rid, lv !== 'none', false, false);
}).join('');
const groupLabel = groups.find((g) => g.id === a.group)?.label || a.group || '—';
return `
<aside class="qfx-side-panel" aria-label="Detalhe da acção">
<article class="qfx-side-card">
<header class="qfx-modal-head">
<div class="qfx-modal-head-text">
<h3>${esc(a.label)}</h3>
<span class="qfx-tag-pill">${esc(a.id)}</span>
</div>
<button type="button" class="qfx-modal-close" data-qfx-close-panel aria-label="Fechar painel">×</button>
</header>
<div class="qfx-modal-body">
<section class="qfx-modal-block">
<h4>Descrição</h4>
<p>${esc(descForAction(a))}</p>
</section>
<section class="qfx-modal-block">
<h4>Finalidade</h4>
<p class="qfx-finalidade-main">${esc(whyForAction(a))}</p>
<p class="qfx-finalidade-sub">${esc(finalidadeSub(a))}</p>
</section>
<section class="qfx-modal-block">
<h4>Quem realiza</h4>
<div class="qfx-split-tag-grid">${roleBadges || '<span class="qfx-muted">Nenhum perfil</span>'}</div>
</section>
<section class="qfx-modal-block">
<h4>Nível padrão · ${esc(roleLabel(selectedRole))}</h4>
<span class="qfx-level-badge ${lm.cls}">${esc(lm.label)}</span>
</section>
<section class="qfx-modal-block">
<h4>Grupos</h4>
<p class="qfx-readonly-field">${esc(groupLabel)}</p>
</section>
</div>
<footer class="qfx-modal-foot">
${editable ? `
<button type="button" class="qfx-btn-edit qfx-btn-edit--full" data-qfx-open-edit ${saving ? 'disabled' : ''}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
Editar permissão
</button>` : '<span class="qfx-muted qfx-readonly-hint">Modo consulta</span>'}
</footer>
</article>
</aside>`;
}
function renderEditModal(selectedRole, editable) {
if (!editModalOpen || !editDraft) return '';
const a = editDraft;
const groups = catalogState?.groups || [];
const roleIds = Object.keys(catalogState?.roles || {}).filter((r) => !['api_service', 'agent_system'].includes(r));
const effLevel = a.effective?.[selectedRole] || 'none';
const lm = levelMeta(effLevel);
const roleBadges = roleIds.map((rid) => {
const lv = a.effective?.[rid] || 'none';
return splitTagHtml(rid, lv !== 'none', true, true);
}).join('');
const levelOpts = LEVELS.map((l) =>
`<option value="${l.id}"${effLevel === l.id ? ' selected' : ''}>${esc(l.label)}</option>`
).join('');
const groupOpts = groups.map((g) =>
`<option value="${esc(g.id)}"${a.group === g.id ? ' selected' : ''}>${esc(g.label)}</option>`
).join('');
return `
<div class="qfx-edit-modal-root" role="dialog" aria-modal="true" aria-label="Editar permissão">
<div class="qfx-backdrop" data-qfx-backdrop></div>
<article class="qfx-modal-card qfx-modal-card--edit" data-qfx-edit-card>
<header class="qfx-modal-head">
<div class="qfx-modal-head-text">
<h3>Editar permissão</h3>
<span class="qfx-tag-pill">${esc(a.id)}</span>
<p class="qfx-edit-subtitle">${esc(a.label)}</p>
</div>
<button type="button" class="qfx-modal-close" data-qfx-close-edit aria-label="Fechar">×</button>
</header>
<div class="qfx-modal-body">
<section class="qfx-modal-block">
<h4>Quem realiza</h4>
<p class="qfx-hint">Clique nos perfis para activar (Total) ou desactivar (Negado).</p>
<div class="qfx-split-tag-grid">${roleBadges}</div>
</section>
<section class="qfx-modal-block">
<h4>Nível · ${esc(roleLabel(selectedRole))}</h4>
<div class="qfx-level-field">
<select class="qfx-level-select qfx-level-select--modal ${lm.cls}" data-qfx-edit-level data-role-id="${esc(selectedRole)}" ${!editable ? 'disabled' : ''}>
${levelOpts}
</select>
</div>
</section>
<section class="qfx-modal-block">
<h4>Grupos</h4>
<select class="qfx-select qfx-select--modal" disabled title="Grupo fixo por acção">
${groupOpts}
</select>
</section>
${saveError ? `<p class="qfx-error">${esc(saveError)}</p>` : ''}
</div>
<footer class="qfx-modal-foot">
<button type="button" class="qfx-btn-cancel" data-qfx-close-edit>Cancelar</button>
<button type="button" class="qfx-btn-edit" data-qfx-save ${!editable || saving ? 'disabled' : ''}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>
Guardar alterações
</button>
</footer>
</article>
</div>`;
}
function syncBodyScrollLock() {
document.body.classList.toggle(BODY_LOCK_CLASS, !!editModalOpen);
}
function repaintEditModalOnly(selectedRole, editable, onFullRepaint) {
mountEditModal(renderEditModal(selectedRole, editable));
bindEditModalEvents(onFullRepaint, selectedRole, editable);
syncBodyScrollLock();
}
function updateListArea(host, selectedRole, editable, groups) {
const area = host.querySelector('[data-qfx-list-area]');
if (!area) return false;
const actions = filteredActions();
const stats = kpiStats(catalogState?.actions || []);
area.innerHTML = `
${renderToolbar(groups)}
${renderKpiCards(stats)}
${saveError && !editModalOpen ? `<p class="qfx-error">${esc(saveError)}</p>` : ''}
${renderActionsTable(actions, selectedRole, editable)}`;
return true;
}
function updateSideArea(host, selectedRole, editable) {
const slot = host.querySelector('[data-qfx-side-slot]');
const layout = host.querySelector('.qfx-layout');
if (!slot || !layout) return;
layout.classList.toggle('qfx-layout--panel', !!drawerActionId);
slot.innerHTML = renderSidePanel(selectedRole, editable);
}
function ensureEditPortal() {
let portal = document.getElementById(EDIT_PORTAL_ID);
if (!portal) {
portal = document.createElement('div');
portal.id = EDIT_PORTAL_ID;
portal.className = 'qfx-edit-portal';
document.body.appendChild(portal);
}
return portal;
}
function clearEditPortal() {
const portal = document.getElementById(EDIT_PORTAL_ID);
if (portal) portal.innerHTML = '';
editModalHost = null;
document.body.classList.remove(BODY_LOCK_CLASS);
}
function mountEditModal(html) {
if (!html) {
clearEditPortal();
return null;
}
const portal = ensureEditPortal();
portal.innerHTML = html;
editModalHost = portal;
return portal;
}
function openDrawer(actionId) {
const a = (catalogState?.actions || []).find((x) => x.id === actionId);
if (!a) return;
drawerActionId = actionId;
drawerDraft = JSON.parse(JSON.stringify(a));
editModalOpen = false;
editDraft = null;
clearEditPortal();
saveError = null;
}
function closeDrawer() {
drawerActionId = null;
drawerDraft = null;
editModalOpen = false;
editDraft = null;
clearEditPortal();
saveError = null;
}
function openEditModal() {
if (!drawerDraft) return;
editDraft = JSON.parse(JSON.stringify(drawerDraft));
if (!editDraft.effective) editDraft.effective = {};
editDraft._dirty = false;
editModalOpen = true;
saveError = null;
}
function closeEditModal() {
editModalOpen = false;
editDraft = null;
clearEditPortal();
saveError = null;
}
async function saveEditModal() {
if (!editDraft || !drawerActionId) return;
const orig = (catalogState.actions || []).find((a) => a.id === drawerActionId);
if (!orig) return;
saving = true;
saveError = null;
try {
const roleIds = Object.keys(catalogState?.roles || {});
for (const rid of roleIds) {
const newLv = editDraft.effective?.[rid] || 'none';
const oldLv = orig.effective?.[rid] || 'none';
const def = orig.defaults?.[rid] || 'none';
if (newLv !== oldLv) {
await patchLevel(drawerActionId, rid, newLv, newLv === def);
}
}
await loadCatalog();
const fresh = (catalogState.actions || []).find((a) => a.id === drawerActionId);
if (fresh) drawerDraft = JSON.parse(JSON.stringify(fresh));
closeEditModal();
} catch (err) {
saveError = err.message;
} finally {
saving = false;
}
}
function bindEditModalEvents(onFullRepaint, selectedRole, editable) {
const root = editModalHost || document.getElementById(EDIT_PORTAL_ID);
if (!root) return;
const onEditRefresh = () => repaintEditModalOnly(selectedRole, editable, onFullRepaint);
root.querySelector('[data-qfx-backdrop]')?.addEventListener('click', (e) => {
if (e.target !== e.currentTarget) return;
closeEditModal();
syncBodyScrollLock();
onFullRepaint({ side: true, list: true });
});
root.querySelectorAll('[data-qfx-close-edit]').forEach((el) => {
el.addEventListener('click', (e) => {
e.stopPropagation();
closeEditModal();
syncBodyScrollLock();
onFullRepaint({ side: true });
});
});
root.querySelector('[data-qfx-edit-card]')?.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
root.querySelectorAll('[data-qfx-toggle-role]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (!catalogState?.editable || !editDraft) return;
if (!editDraft.effective) editDraft.effective = {};
const rid = btn.dataset.qfxToggleRole;
const cur = editDraft.effective[rid] || 'none';
editDraft.effective[rid] = cur === 'none' ? 'full' : 'none';
editDraft._dirty = true;
onEditRefresh();
});
});
root.querySelector('[data-qfx-edit-level]')?.addEventListener('change', (e) => {
e.stopPropagation();
if (!editDraft) return;
if (!editDraft.effective) editDraft.effective = {};
const rid = e.target.dataset.roleId;
editDraft.effective[rid] = e.target.value;
editDraft._dirty = true;
onEditRefresh();
});
root.querySelector('[data-qfx-save]')?.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await saveEditModal();
syncBodyScrollLock();
onFullRepaint({ side: true, list: true });
});
}
function bindEvents(host, selectedRole, editable, groups, onRefresh) {
host.querySelector('[data-qfx-group]')?.addEventListener('change', (e) => {
filterGroup = e.target.value;
onRefresh({ list: true });
});
const searchInput = host.querySelector('[data-qfx-search]');
searchInput?.addEventListener('input', (e) => {
searchQuery = e.target.value;
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => onRefresh({ list: true }), 280);
});
host.querySelector('[data-qfx-clear-filters]')?.addEventListener('click', () => {
filterGroup = 'all';
searchQuery = '';
if (searchInput) searchInput.value = '';
onRefresh({ list: true });
});
host.querySelectorAll('[data-qfx-level]').forEach((sel) => {
sel.addEventListener('change', async () => {
if (!catalogState?.editable) return;
const actionId = sel.dataset.actionId;
const roleId = sel.dataset.roleId;
const level = sel.value;
const action = (catalogState.actions || []).find((a) => a.id === actionId);
const def = action?.defaults?.[roleId] || 'none';
try {
await patchLevel(actionId, roleId, level, level === def);
await loadCatalog();
saveError = null;
onRefresh({ list: true, side: true });
} catch (err) {
saveError = err.message;
onRefresh({ list: true });
}
});
});
host.querySelectorAll('.qfx-row').forEach((row) => {
row.addEventListener('click', (e) => {
if (editModalOpen) return;
if (e.target.closest('select, button')) return;
openDrawer(row.dataset.qfxAction);
onRefresh({ list: true, side: true });
});
});
host.querySelectorAll('[data-qfx-open]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
openDrawer(btn.dataset.qfxOpen);
onRefresh({ list: true, side: true });
});
});
host.querySelectorAll('[data-qfx-close-panel]').forEach((el) => {
el.addEventListener('click', () => {
closeDrawer();
onRefresh({ list: true, side: true });
});
});
host.querySelector('[data-qfx-open-edit]')?.addEventListener('click', (e) => {
e.stopPropagation();
openEditModal();
syncBodyScrollLock();
repaintEditModalOnly(selectedRole, editable, () => onRefresh({ side: true, list: true }));
});
}
function syncDraftsFromCatalog() {
if (drawerActionId) {
const fresh = (catalogState.actions || []).find((a) => a.id === drawerActionId);
if (fresh && drawerDraft && !editModalOpen) {
drawerDraft.label = fresh.label;
drawerDraft.group = fresh.group;
drawerDraft.defaults = fresh.defaults;
drawerDraft.effective = { ...fresh.effective };
}
}
if (editModalOpen && editDraft && drawerActionId) {
const fresh = (catalogState.actions || []).find((a) => a.id === drawerActionId);
if (fresh && !editDraft._dirty) {
editDraft.label = fresh.label;
editDraft.group = fresh.group;
editDraft.defaults = fresh.defaults;
editDraft.effective = { ...fresh.effective };
}
}
}
function refreshView(host, selectedRole, editable, groups, scope = { all: true }) {
syncDraftsFromCatalog();
if (scope.all) {
updateListArea(host, selectedRole, editable, groups);
updateSideArea(host, selectedRole, editable);
if (editModalOpen) {
repaintEditModalOnly(selectedRole, editable, () => refreshView(host, selectedRole, editable, groups, { all: true }));
} else {
clearEditPortal();
}
} else {
if (scope.list) updateListArea(host, selectedRole, editable, groups);
if (scope.side) updateSideArea(host, selectedRole, editable);
if (scope.edit && editModalOpen) {
repaintEditModalOnly(selectedRole, editable, () => refreshView(host, selectedRole, editable, groups, { all: true }));
}
}
bindEvents(host, selectedRole, editable, groups, (partial) => {
refreshView(host, selectedRole, editable, groups, partial);
});
if (editModalOpen) {
bindEditModalEvents(
() => refreshView(host, selectedRole, editable, groups, { all: true }),
selectedRole,
editable
);
}
}
async function paint(host, opts = {}) {
if (!host) return;
const selectedRole = opts.selectedRole || 'super_admin';
try {
if (!catalogState) {
host.innerHTML = '<p class="loading">Carregando Quem faz o quê…</p>';
await loadCatalog();
}
} catch (e) {
host.innerHTML = `<p class="loading">Catálogo indisponível: ${esc(e.message)}</p>`;
clearEditPortal();
return;
}
const editable = !!catalogState?.editable;
const groups = catalogState?.groups || [];
syncDraftsFromCatalog();
host.innerHTML = `
<div class="qfx-layout${drawerActionId ? ' qfx-layout--panel' : ''}">
<div class="qfx-main">
<header class="qfx-page-head">
<div>
<h2 class="qfx-title">Mapa executivo Quem faz o quê</h2>
<p class="qfx-subtitle">Controle visual por acção, perfil e finalidade · Spec 039</p>
</div>
${editable ? '<span class="qfx-badge-edit">Edição activa</span>' : '<span class="qfx-badge-read">Consulta</span>'}
</header>
<div data-qfx-list-area></div>
</div>
<div data-qfx-side-slot></div>
</div>`;
refreshView(host, selectedRole, editable, groups, { all: true });
}
function resetFilters() {
filterGroup = 'all';
searchQuery = '';
clearTimeout(searchDebounceTimer);
drawerActionId = null;
drawerDraft = null;
editModalOpen = false;
editDraft = null;
clearEditPortal();
catalogState = null;
}
window.DeskExecutiveMap = { paint, resetFilters, loadCatalog };
})();

View file

@ -0,0 +1,198 @@
/** Metadados INFRA CODE — API, VM, código-fonte e pasta Spec por serviço (Spec 033). */
window.STACK_SERVICE_META = {
'vm112-onboard-api': {
specFolder: 'specs/001-onboarding-webhooks/',
apiPath: 'GET /api/onboarding/health',
codePaths: ['deploy/vm112-wizard/', 'VM112 onboard API :8090'],
probeCode: 'api/app/stack_health.py → vm112-onboard-api',
validation: 'HTTP 200 no health do portal onboard',
},
'vm112-onboard-ui': {
specFolder: 'specs/025-wizard-ui/',
apiPath: 'GET https://onboard.ligbox.com.br/',
codePaths: ['deploy/vm112-wizard/frontend/', 'CT114 Traefik router onboard'],
probeCode: 'api/app/stack_health.py → vm112-onboard-ui',
validation: 'Wizard público responde 200/301/302/403',
},
'vm112-carbonio': {
specFolder: 'specs/022-carbonio-mail/',
apiPath: 'GET https://10.10.10.112/',
codePaths: ['VM112 Carbonio CE', 'deploy/carbonio/'],
probeCode: 'api/app/stack_health.py → vm112-carbonio',
validation: 'HTTPS Carbonio (TLS interno, verify=false)',
},
'vm112-domain-api': {
specFolder: 'specs/017-vm112-domain-orchestration/',
apiPath: 'GET /api/onboarding/health (proxy admin)',
codePaths: ['deploy/vm112-wizard/', 'api/admin/domains'],
probeCode: 'api/app/stack_health.py → vm112-domain-api',
validation: 'API VM112 acessível — domínios via Spec 017',
},
'vm114-traefik': {
specFolder: 'specs/026-traefik-edge/',
apiPath: 'GET :8080/api/overview',
codePaths: ['CT114 docker/traefik/', 'dynamic routers YAML'],
probeCode: 'api/app/stack_health.py → vm114-traefik',
validation: 'Traefik API responde (200/401/403)',
},
'vm114-desk-route': {
specFolder: 'specs/026-traefik-edge/',
apiPath: 'GET https://desk.ligbox.com.br/',
codePaths: ['CT114 router desk.ligbox.com.br', 'VM122 frontend :8091'],
probeCode: 'api/app/stack_health.py → vm114-desk-route',
validation: 'Rota WAN Desk via Traefik',
},
'vm114-api-route': {
specFolder: 'specs/027-desk-api-public/',
apiPath: 'GET https://api.ops.ligbox.com.br/health',
codePaths: ['CT114 router api.ops', 'VM122 api :8080'],
probeCode: 'api/app/stack_health.py → vm114-api-route',
validation: 'API pública Ops responde /health',
},
'vm122-desk-api': {
specFolder: 'specs/003-desk-auth-rbac/',
apiPath: 'GET /health · /api/v1/*',
codePaths: ['projects/ops-desk/api/app/', 'docker ligbox-ops-platform_api'],
probeCode: 'api/app/stack_health.py → vm122-desk-api',
validation: 'FastAPI local :8080/health',
},
'vm122-desk-ui': {
specFolder: 'specs/040-desk-design-system-v013/',
apiPath: 'GET http://10.10.10.122:8091/',
codePaths: ['projects/ops-desk/frontend/', 'assets/ligbox-ds.css', 'assets/user-wizard.js', 'assets/access-control-hub.js'],
probeCode: 'api/app/stack_health.py → vm122-desk-ui',
validation: 'Nginx frontend · footer v0.13.0 · Spec 040',
},
'vm122-governance-api': {
specFolder: 'specs/040-desk-design-system-v013/',
apiPath: 'POST /api/v1/governance/users/wizard · GET /governance/audit',
codePaths: ['api/app/governance_routes.py', 'api/app/desk_governance_store.py'],
probeCode: 'DS-API-002 governance_routes',
validation: 'Wizard create user + audit log · Spec 040',
},
'vm122-ops-inbox-api': {
specFolder: 'specs/041-desk-operational-feed/',
apiPath: 'GET /api/v1/ops-inbox/stats · GET /events · POST /events/{id}/messages',
codePaths: ['api/app/ops_inbox_routes.py', 'api/app/ops_inbox_store.py', 'assets/operational-feed.js'],
probeCode: 'OF-API-002 ops_inbox_routes',
validation: 'Central Operacional mock · Spec 041',
},
'vm122-redis': {
specFolder: '—',
apiPath: 'redis://redis:6379/0 PING',
codePaths: ['docker-compose redis', 'api/app assist_store / cache'],
probeCode: 'api/app/stack_health.py → _probe_redis',
validation: 'PING OK no Redis do stack',
},
'vm122-webhook-soc': {
specFolder: 'specs/001-onboarding-webhooks/',
apiPath: 'GET /api/v1/integrations/health',
codePaths: ['api/app/integration_health.py', 'api/app/assist_routes.py'],
probeCode: 'infra_stack_routes.py enrich + integration_health',
validation: 'Gap webhook VM112 + status integração',
},
'vm122-purge-auth': {
specFolder: 'specs/032-purge-domain-extra-auth/',
apiPath: 'GET/POST /api/v1/infra/purge-auth-*',
codePaths: ['api/app/infra_stack_routes.py', 'api/app/purge_auth*'],
probeCode: 'Módulo local Desk — sem probe HTTP externo',
validation: 'Códigos purge Spec 032 · super_admin',
},
'vm122-email-relay': {
specFolder: 'docs/postfix-vm122.md',
apiPath: 'GET /api/v1/infra/email-relay/status · POST /test',
codePaths: [
'/etc/postfix/main.cf (VM122 host)',
'/etc/postfix/transport',
'api/app/email_relay.py',
'api/app/mail_notify.py',
'VM112 mail.ligbox.com.br (Carbonio relay)',
],
probeCode: 'api/app/stack_health.py → vm122-email-relay',
validation: 'Postfix VM122 · relayhost VM112 · ligbox-ops@ ligbox.com.br · convites Desk',
},
'vm123-foss': {
specFolder: 'specs/024-fossbilling-odoo/',
apiPath: 'GET http://10.10.10.123:8092/',
codePaths: ['VM123 FOSSBilling container'],
probeCode: 'api/app/stack_health.py → vm123-foss',
validation: 'FOSSBilling UI responde',
},
'vm123-odoo': {
specFolder: 'specs/024-fossbilling-odoo/',
apiPath: 'GET :8069/web/login',
codePaths: ['VM123 Odoo 16'],
probeCode: 'api/app/stack_health.py → vm123-odoo',
validation: 'Odoo login page responde',
},
'vm123-openpanel-ui': {
specFolder: 'specs/028-openpanel-reengineering/',
apiPath: 'GET https://openpanel.ligbox.com.br/',
codePaths: ['VM123 OpenPanel CE', 'CT114 router'],
probeCode: 'api/app/stack_health.py → vm123-openpanel-ui',
validation: 'OpenPanel WAN via Traefik',
},
'vm123-openadmin': {
specFolder: 'specs/024-fossbilling-odoo/',
apiPath: 'GET https://admin.openpanel.ligbox.com.br/',
codePaths: ['VM123 OpenAdmin'],
probeCode: 'api/app/stack_health.py → vm123-openadmin',
validation: 'OpenAdmin UI responde',
},
'vm123-openpanel-bridge': {
specFolder: 'specs/028-openpanel-reengineering/',
apiPath: 'GET :18087/api (Bearer bridge token)',
codePaths: ['VM123 bridge service', 'api/app/vm123_routes.py'],
probeCode: 'api/app/stack_health.py → _probe_openpanel_bridge',
validation: 'Bridge API com OPENPANEL_BRIDGE_TOKEN',
},
'vm123-ops-console': {
specFolder: 'specs/019-ops-console/',
apiPath: 'GET :8100/health',
codePaths: ['VM123 ops console service'],
probeCode: 'api/app/stack_health.py → vm123-ops-console',
validation: 'Health endpoint Ops Console',
},
'vm123-phpmyadmin': {
specFolder: '—',
apiPath: 'GET :8888/',
codePaths: ['VM123 phpMyAdmin stack'],
probeCode: 'api/app/stack_health.py → vm123-phpmyadmin',
validation: 'phpMyAdmin HTTP responde',
},
'vm123-ollama': {
specFolder: 'specs/029-agentic-ops/',
apiPath: 'GET :11434/api/tags',
codePaths: ['VM123 Ollama', 'api/app/agents*'],
probeCode: 'api/app/stack_health.py → vm123-ollama',
validation: 'Ollama API lista modelos',
},
'vm130-forgejo': {
specFolder: 'specs/031-spec-hub-forgejo/',
apiPath: 'GET :3000/',
codePaths: ['CT130 Forgejo', '/opt/ligbox-spec-hub repos'],
probeCode: 'api/app/stack_health.py → vm130-forgejo',
validation: 'Forgejo Git UI responde',
},
'vm130-spec-portal': {
specFolder: 'specs/031-spec-hub-forgejo/',
apiPath: 'GET :8080/',
codePaths: ['CT130 spec portal', 'obsidian-vault/'],
probeCode: 'api/app/stack_health.py → vm130-spec-portal',
validation: 'Spec portal LAN responde',
},
'vm130-spec-public': {
specFolder: 'specs/031-spec-hub-forgejo/',
apiPath: 'GET https://spec.ligbox.com.br/',
codePaths: ['CT130 + Traefik WAN', 'spec.ligbox.com.br'],
probeCode: 'api/app/stack_health.py → vm130-spec-public',
validation: 'Spec Hub público via WAN',
},
'integrations-json': {
specFolder: 'specs/001-onboarding-webhooks/',
apiPath: 'GET /api/v1/integrations',
codePaths: ['api/app/integration_health.py', 'webhook registry DB'],
probeCode: 'renderInfra · integrations snapshot',
validation: 'Registry onboard + Wazuh activo',
},
};

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,9 @@ const DeskModules = {
},
isViewEnabled(view) {
const btn = document.querySelector(`.nav button[data-view="${view}"]`);
const btn =
document.querySelector(`[data-desk-nav][data-view="${view}"]`) ||
document.querySelector(`.nav button[data-view="${view}"]`);
if (!btn || btn.hasAttribute('hidden')) return false;
const modId = btn.dataset.module;
if (!modId) return true;

View file

@ -0,0 +1,317 @@
/**
* Central Operacional Operational feed
* Spec 041 · OF-FE-001 · desk.ligbox.com.br (aba messages Central Operacional)
*/
(function () {
'use strict';
let host = null;
let stats = null;
let events = [];
let selectedId = null;
let channel = 'all';
let priority = '';
let searchQ = '';
let detailTab = 'conversation';
let replyMode = 'reply';
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function fmtRel(iso) {
if (!iso) return '—';
const diff = (Date.now() - new Date(iso).getTime()) / 60000;
if (diff < 1) return 'agora';
if (diff < 60) return `${Math.round(diff)} min atrás`;
if (diff < 1440) return `${Math.round(diff / 60)} h atrás`;
return new Date(iso).toLocaleDateString('pt-PT');
}
function fmtSla(sec) {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function channelIcon(ch) {
const map = { whatsapp: '💬', email: '✉', voice: '📞', tickets: '🎫', agents: '🤖', internal: '📋', alerts: '⚠', sms: '📱', telegram: '✈', clients: '👤' };
return map[ch] || '•';
}
async function loadStats() {
const r = await fetchWithTimeout('/api/v1/ops-inbox/stats', { headers: authHeaders() });
if (!r.ok) throw new Error(String(r.status));
stats = await r.json();
}
async function loadEvents() {
const qs = new URLSearchParams({ channel, limit: '128' });
if (priority) qs.set('priority', priority);
if (searchQ) qs.set('q', searchQ);
const r = await fetchWithTimeout(`/api/v1/ops-inbox/events?${qs}`, { headers: authHeaders() });
if (!r.ok) throw new Error(String(r.status));
const data = await r.json();
events = data.events || [];
}
async function loadEvent(id) {
const r = await fetchWithTimeout(`/api/v1/ops-inbox/events/${encodeURIComponent(id)}`, { headers: authHeaders() });
if (!r.ok) throw new Error(String(r.status));
const data = await r.json();
return data.event;
}
function kpisHtml() {
if (!stats) return '';
return `
<div class="lb-ops-kpis">
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">Eventos hoje</span><span class="lb-stat-value">${stats.events_today}</span></div>
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">Pendentes</span><span class="lb-stat-value">${stats.pending}</span></div>
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">Críticos</span><span class="lb-stat-value">${stats.critical}</span></div>
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">Aguardando você</span><span class="lb-stat-value">${stats.awaiting_you}</span></div>
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">SLA médio</span><span class="lb-stat-value">${stats.sla_avg_pct}%</span></div>
</div>`;
}
function sidebarHtml() {
const channels = stats?.channels || [];
return `
<aside class="lb-card lb-ops-sidebar">
<button type="button" class="lb-btn-primary" style="width:100%;margin-bottom:12px">+ Nova mensagem</button>
<p class="lb-stat-label">CANAIS</p>
${channels.map((c) => `
<button type="button" class="lb-ops-channel${channel === c.id ? ' active' : ''}" data-of-channel="${esc(c.id)}">
<span>${esc(c.label)}</span><span>${c.count}</span>
</button>`).join('')}
<p class="lb-stat-label" style="margin-top:16px">FILTROS RÁPIDOS</p>
<button type="button" class="lb-ops-channel" data-of-priority="high">Prioridade alta</button>
<button type="button" class="lb-ops-channel" data-of-priority="critical">Críticos</button>
</aside>`;
}
function eventCard(ev) {
const sel = selectedId === ev.id ? ' selected' : '';
const pri = ev.priority === 'high' || ev.priority === 'critical'
? `<span class="lb-badge lb-badge--high">${esc(ev.priority)}</span>` : '';
return `
<article class="lb-entity-card${sel}" data-of-event="${esc(ev.id)}">
<div class="lb-entity-card__head">
<div>
<p class="lb-entity-card__title">${channelIcon(ev.channel)} ${esc(ev.title)}</p>
<p class="lb-entity-card__preview">${esc(ev.preview)}</p>
</div>
${pri}
</div>
<div class="lb-tag-row">
${(ev.tags || []).map((t) => `<span class="lb-tag">${esc(t)}</span>`).join('')}
<span class="lb-tag">${fmtRel(ev.created_at)}</span>
${ev.assignee ? `<span class="lb-tag">${esc(ev.assignee)}</span>` : ''}
</div>
</article>`;
}
function detailHtml(ev) {
if (!ev) {
return `<div class="lb-card lb-detail-panel"><div class="lb-detail-body"><p class="lb-stat-sub">Seleccione um evento</p></div></div>`;
}
const slaPct = Math.min(100, Math.round((ev.sla_remaining_sec / (ev.sla_minutes * 60)) * 100));
let tabContent = '';
if (detailTab === 'conversation') {
const msgs = (ev.messages || []).map((m) => {
const cls = m.author_type === 'user' ? 'user' : m.author_type === 'agent' ? 'agent'
: m.author_type === 'system' ? 'system' : 'operator';
return `<div class="lb-chat-bubble lb-chat-bubble--${cls}"><strong>${esc(m.author_label)}</strong><br/>${esc(m.body)}</div>`;
}).join('');
tabContent = `
<div class="lb-chat">${msgs || '<p class="lb-stat-sub">Sem mensagens</p>'}</div>
<nav class="lb-subnav" style="margin-bottom:8px">
<button type="button" class="lb-subtab${replyMode === 'reply' ? ' active' : ''}" data-of-reply="reply">Responder</button>
<button type="button" class="lb-subtab${replyMode === 'internal_note' ? ' active' : ''}" data-of-reply="internal_note">Nota interna</button>
</nav>
<textarea data-of-msg rows="3" style="width:100%;border:1px solid var(--lb-border);border-radius:8px;padding:8px" placeholder="Escreva a resposta…"></textarea>
<button type="button" class="lb-btn-primary" style="margin-top:8px" data-of-send>Enviar resposta</button>`;
} else if (detailTab === 'details') {
tabContent = `
<dl style="font-size:0.85rem;line-height:1.7">
<dt>Canal</dt><dd>${esc(ev.channel)}</dd>
<dt>Tipo</dt><dd>${esc(ev.event_type)}</dd>
<dt>Estado</dt><dd>${esc(ev.status)}</dd>
<dt>Responsável</dt><dd>${esc(ev.assignee || '')}</dd>
</dl>`;
} else {
tabContent = `<p class="lb-stat-sub">Histórico simulado — integração futura.</p>`;
}
return `
<div class="lb-card lb-detail-panel">
<header class="lb-detail-head">
<span class="lb-badge lb-badge--active">${esc(ev.status)}</span>
<h3 style="margin:8px 0 4px">${esc(ev.contact_name || ev.title)}</h3>
<p class="lb-stat-sub">${esc(ev.channel)}</p>
</header>
<nav class="lb-detail-tabs">
<button type="button" class="lb-detail-tab${detailTab === 'conversation' ? ' active' : ''}" data-of-tab="conversation">Conversa</button>
<button type="button" class="lb-detail-tab${detailTab === 'details' ? ' active' : ''}" data-of-tab="details">Detalhes</button>
<button type="button" class="lb-detail-tab${detailTab === 'history' ? ' active' : ''}" data-of-tab="history">Histórico</button>
</nav>
<div class="lb-detail-body">${tabContent}</div>
<div class="lb-detail-body" style="border-top:1px solid var(--lb-border)">
<div class="lb-actions-grid">
<button type="button" class="lb-action-btn" data-of-assign>Atribuir</button>
<button type="button" class="lb-action-btn" data-of-escalate>Escalar</button>
<button type="button" class="lb-action-btn" data-of-resolve>Marcar resolvido</button>
</div>
<p class="lb-stat-label" style="margin-top:12px">Informações do contacto</p>
<p class="lb-stat-sub">${esc(ev.contact_company || '—')}<br/>CNPJ: ${esc(ev.contact_cnpj || '—')}<br/>ID: ${esc(ev.contact_client_id || '—')}</p>
<p class="lb-stat-label" style="margin-top:12px">SLA: ${ev.sla_minutes} min · Restante: ${fmtSla(ev.sla_remaining_sec)}</p>
<div class="lb-sla-bar"><span style="width:${slaPct}%"></span></div>
</div>
</div>`;
}
let currentEvent = null;
async function renderDetail() {
const detailHost = host?.querySelector('[data-of-detail]');
if (!detailHost || !selectedId) return;
try {
currentEvent = await loadEvent(selectedId);
detailHost.innerHTML = detailHtml(currentEvent);
bindDetailEvents();
} catch (e) {
detailHost.innerHTML = `<p class="loading">${esc(e.message)}</p>`;
}
}
function render() {
if (!host) return;
host.innerHTML = `
<div class="lb-page" data-of-root>
<header style="margin-bottom:16px">
<h2 style="margin:0">Central Operacional</h2>
<p class="lb-stat-sub">Operational feed mensagens, solicitações e eventos de todos os canais</p>
</header>
${kpisHtml()}
<div class="lb-toolbar">
<input type="search" class="lb-search" data-of-search placeholder="Buscar eventos, pessoas, tickets…" value="${esc(searchQ)}"/>
<select class="lb-select" data-of-priority-sel>
<option value="">Prioridade</option>
<option value="high"${priority === 'high' ? ' selected' : ''}>Alta</option>
<option value="critical"${priority === 'critical' ? ' selected' : ''}>Crítica</option>
</select>
</div>
<div class="lb-ops-layout">
${sidebarHtml()}
<div>
<p class="lb-stat-sub" style="margin-bottom:8px">${events.length} resultados · Mais recentes</p>
<div class="lb-feed-list" data-of-list>${events.map(eventCard).join('')}</div>
</div>
<div data-of-detail>${detailHtml(currentEvent)}</div>
</div>
</div>`;
bindEvents();
if (selectedId) renderDetail();
}
function bindDetailEvents() {
host.querySelectorAll('[data-of-tab]').forEach((btn) => {
btn.addEventListener('click', () => { detailTab = btn.dataset.ofTab; renderDetail(); });
});
host.querySelectorAll('[data-of-reply]').forEach((btn) => {
btn.addEventListener('click', () => { replyMode = btn.dataset.ofReply; renderDetail(); });
});
host.querySelector('[data-of-send]')?.addEventListener('click', async () => {
const body = host.querySelector('[data-of-msg]')?.value?.trim();
if (!body || !selectedId) return;
try {
await fetchWithTimeout(`/api/v1/ops-inbox/events/${encodeURIComponent(selectedId)}/messages`, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ body, note_type: replyMode }),
});
renderDetail();
} catch (e) {
window.alert(e.message);
}
});
host.querySelector('[data-of-resolve]')?.addEventListener('click', async () => {
await fetchWithTimeout(`/api/v1/ops-inbox/events/${encodeURIComponent(selectedId)}`, {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ status: 'resolved' }),
});
await refresh();
});
host.querySelector('[data-of-assign]')?.addEventListener('click', () => {
const name = window.prompt('Atribuir a:');
if (!name) return;
fetchWithTimeout(`/api/v1/ops-inbox/events/${encodeURIComponent(selectedId)}`, {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ assignee: name }),
}).then(refresh).catch((e) => window.alert(e.message));
});
}
function bindEvents() {
host.querySelectorAll('[data-of-channel]').forEach((btn) => {
btn.addEventListener('click', async () => {
channel = btn.dataset.ofChannel;
await refresh();
});
});
host.querySelectorAll('[data-of-priority]').forEach((btn) => {
btn.addEventListener('click', async () => {
priority = btn.dataset.ofPriority;
await refresh();
});
});
host.querySelector('[data-of-priority-sel]')?.addEventListener('change', async (e) => {
priority = e.target.value;
await refresh();
});
host.querySelector('[data-of-search]')?.addEventListener('input', (e) => {
searchQ = e.target.value;
clearTimeout(host._ofTimer);
host._ofTimer = setTimeout(refresh, 300);
});
host.querySelectorAll('[data-of-event]').forEach((card) => {
card.addEventListener('click', () => {
selectedId = card.dataset.ofEvent;
detailTab = 'conversation';
render();
});
});
}
async function refresh() {
try {
await loadEvents();
if (!selectedId && events.length) selectedId = events[0].id;
render();
} catch (e) {
host.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function paint(container) {
host = container;
if (typeof canManageUsers === 'function' && !canManageUsers()) {
host.innerHTML = '<p class="loading">Sem permissão</p>';
return;
}
host.innerHTML = '<p class="loading">Carregando Central Operacional…</p>';
try {
await loadStats();
await loadEvents();
if (!selectedId && events.length) selectedId = events[0].id;
render();
} catch (e) {
host.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
window.DeskOperationalFeed = { paint };
})();

View file

@ -1207,15 +1207,11 @@ const DeskServices = (() => {
if (!hasPage) {
const hydrated = hydrateFromCache();
if (hydrated) {
renderPageShell(container, {
staleHint: hydrated.fresh
? 'A actualizar lista VM112…'
: 'Lista em cache — a actualizar VM112…',
staleHint: hydrated
? (hydrated.fresh ? 'A actualizar lista VM112…' : 'Lista em cache — a actualizar VM112…')
: 'A carregar clientes VM112…',
});
} else {
container.innerHTML = '<p class="loading">A carregar clientes e serviços VM112…</p>';
}
}
try {

View file

@ -6,6 +6,59 @@ const TicketsDetailPanel = {
lastTicketId: null,
mirrorTimer: null,
mirrorSessionId: null,
_drawerOpen: false,
_escHandler: null,
isOpen() {
return this._drawerOpen;
},
getDrawerEls() {
return {
drawer: document.getElementById('ticket-drawer'),
body: document.getElementById('ticket-drawer-body'),
title: document.getElementById('ticket-drawer-title'),
};
},
bindDrawerClose() {
const { drawer } = this.getDrawerEls();
if (!drawer || drawer.dataset.boundClose) return;
drawer.dataset.boundClose = '1';
drawer.querySelectorAll('[data-close-ticket-drawer]').forEach((el) => {
el.addEventListener('click', () => this.closeDrawer());
});
this._escHandler = (ev) => {
if (ev.key === 'Escape' && this._drawerOpen) this.closeDrawer();
};
document.addEventListener('keydown', this._escHandler);
},
openDrawer(ticketId) {
const { drawer, body, title } = this.getDrawerEls();
if (!drawer || !body) {
console.warn('[TicketsDetailPanel] Modal ASM indisponível (#ticket-drawer)');
return Promise.resolve();
}
this.bindDrawerClose();
this._drawerOpen = true;
drawer.classList.remove('hidden');
drawer.setAttribute('aria-hidden', 'false');
document.body.classList.add('ticket-drawer-open');
if (title) title.textContent = `Ticket #${ticketId}`;
return this.render(ticketId, body);
},
closeDrawer() {
const { drawer } = this.getDrawerEls();
this.stopMirrorPoll();
this._drawerOpen = false;
if (drawer) {
drawer.classList.add('hidden');
drawer.setAttribute('aria-hidden', 'true');
}
document.body.classList.remove('ticket-drawer-open');
},
stopMirrorPoll() {
if (this.mirrorTimer) {
@ -230,42 +283,61 @@ const TicketsDetailPanel = {
</nav>`;
},
resumoHtml(t, { sessionId, assistMeta, carbonioBlock, timeline, timing }) {
resumoHtml(t, { sessionId, assistMeta, carbonioBlock }) {
const closeStatuses = ['open', 'escalated', 'assisting', 'resolved'];
const funnelKv = typeof ticketFunnelKvHtml === 'function'
? ticketFunnelKvHtml(t)
: `<dt>Evento</dt><dd>${esc(t.event || '—')}</dd>`;
return `
<div class="ticket-detail-pane" data-ticket-pane="resumo"${this.activeTab !== 'resumo' ? ' hidden' : ''}>
<dl class="kv">
<div class="ticket-detail-layout">
<section class="ticket-detail-section">
<h4>Identificação</h4>
<dl class="kv kv--compact kv--indented">
<dt>Domínio / Agente</dt><dd>${esc(t.domain || t.agent || '')}</dd>
<dt>E-mail</dt><dd>${esc(t.email || '')}</dd>
${funnelKv}
${t.description ? `<dt>Descrição</dt><dd>${esc(t.description)}</dd>` : ''}
${t.desk_message ? `<dt>Nota Desk</dt><dd>${esc(t.desk_message)}</dd>` : ''}
<dt>Sessão / Alert ID</dt><dd><code>${esc(t.session_id || '')}</code></dd>
${t.wizard_ticket_id ? `<dt>Chamado wizard</dt><dd><code class="session-hash">${esc(t.wizard_ticket_id)}</code></dd>` : ''}
${t.wizard_client_note ? `<dt>Nota cliente</dt><dd>${esc(t.wizard_client_note)}</dd>` : ''}
</dl>
</section>
<section class="ticket-detail-section">
<h4>Estado &amp; Operação</h4>
<dl class="kv kv--compact kv--indented">
<dt>Origem</dt><dd>${sourceBadge(t.source)}</dd>
<dt>Domínio/Agente</dt><dd>${esc(t.domain || t.agent || '')}</dd>
<dt>Email</dt><dd>${esc(t.email || '')}</dd>
${typeof ticketFunnelKvHtml === 'function' ? ticketFunnelKvHtml(t) : `<dt>Evento</dt><dd>${esc(t.event || '—')}</dd>`}
<dt>Status</dt><dd><span class="badge ${t.status}">${esc(statusLabel(t.status))}</span></dd>
${t.assigned_to ? `<dt>Atribuído</dt><dd>${esc(t.assigned_to)}</dd>` : ''}
${t.assisted_by ? `<dt>Assistido por</dt><dd>${esc(t.assisted_by)}</dd>` : ''}
${t.client_paused ? '<dt>Cliente</dt><dd><span class="badge assisting">pausado</span></dd>' : ''}
${t.ready_for_ops ? '<dt>Ops</dt><dd><span class="badge ok">ready for ops</span></dd>' : ''}
${t.severity != null ? `<dt>Severidade</dt><dd>${severityBadge(t.severity)}</dd>` : ''}
${t.rule_id ? `<dt>Regra</dt><dd>${esc(t.rule_id)}</dd>` : ''}
${t.description ? `<dt>Descrição</dt><dd>${esc(t.description)}</dd>` : ''}
${t.desk_message ? `<dt>Nota</dt><dd>${esc(t.desk_message)}</dd>` : ''}
${t.registration_role ? `<dt>Perfil</dt><dd>${esc(roleLabel(t.registration_role))}</dd>` : ''}
${t.activation_url ? `<dt>Ativar conta</dt><dd><a class="btn btn-primary btn-sm" href="${esc(t.activation_url)}" target="_blank" rel="noopener">Abrir link de ativação</a></dd>` : ''}
<dt>Sessão/Alert ID</dt><dd><code>${esc(t.session_id || '')}</code></dd>
${t.wizard_ticket_id ? `<dt>Chamado wizard</dt><dd><code class="session-hash">${esc(t.wizard_ticket_id)}</code></dd>` : ''}
${t.wizard_client_note ? `<dt>Nota cliente</dt><dd>${esc(t.wizard_client_note)}</dd>` : ''}
${t.activation_url ? `<dt>Ativar conta</dt><dd><a class="btn btn-primary btn-sm" href="${esc(t.activation_url)}" target="_blank" rel="noopener">Abrir link</a></dd>` : ''}
<dt>Verificado</dt><dd>${t.account_verified ? 'Sim' : 'Não'}</dd>
<dt>Revisão</dt><dd>${t.needs_review ? 'Necessária' : 'Não'}</dd>
<dt>Criado</dt><dd>${fmtDate(t.created_at)}</dd>
</dl>
${sessionId && t.source === 'vm112-onboard' ? assistActionsHtml(sessionId, {
</section>
</div>
${sessionId && t.source === 'vm112-onboard' ? `
<div class="ticket-detail-layout ticket-detail-layout--assist">
<section class="ticket-detail-section ticket-detail-section--wide">
${assistActionsHtml(sessionId, {
can_escalate: assistMeta?.can_escalate,
assist_status: assistMeta?.assist_status || assistMeta?.ticket_status,
ticket_status: assistMeta?.ticket_status || t.status,
client_paused: assistMeta?.ticket?.client_paused ?? t.client_paused,
assisted_by: assistMeta?.assisted_by,
actions: assistMeta?.actions,
}, assistMeta?._console || {}) : ''}
}, assistMeta?._console || {})}
</section>
</div>` : ''}
${carbonioBlock ? `<div id="ticket-carbonio-block">${carbonioBlockPanelHtml(carbonioBlock)}</div>` : ''}
<div class="actions">
<div class="ticket-detail-footer-actions">
${typeof canPatchTickets === 'function' && canPatchTickets()
? (closeStatuses.includes(t.status)
? '<button type="button" class="btn btn-primary" data-action="close">Fechar ticket</button>'
@ -343,6 +415,7 @@ const TicketsDetailPanel = {
try {
await runAssistAction('takeover', sessionId);
await renderTickets();
if (typeof refreshTicketDetailView === 'function') await refreshTicketDetailView();
} catch (err) {
alert(err.message || 'Falha ao assumir sessão');
} finally {
@ -355,6 +428,7 @@ const TicketsDetailPanel = {
try {
await runAssistAction('resume-wizard', sessionId);
await renderTickets();
if (typeof refreshTicketDetailView === 'function') await refreshTicketDetailView();
} catch (err) {
alert(err.message || 'Falha ao reabrir wizard ASM');
} finally {
@ -367,6 +441,7 @@ const TicketsDetailPanel = {
try {
await runAssistAction('escalate', sessionId);
await renderTickets();
if (typeof refreshTicketDetailView === 'function') await refreshTicketDetailView();
} catch (err) {
alert(err.message || 'Falha ao escalar');
} finally {
@ -391,7 +466,7 @@ const TicketsDetailPanel = {
this.activeTab = 'resumo';
this.lastTicketId = ticketId;
}
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando…</p></div>';
detailEl.innerHTML = '<p class="loading">Carregando…</p>';
try {
const t = await api(`/v1/desk/tickets/${ticketId}`);
const sessionId = t.session_id || state.selectedSessionId;
@ -428,22 +503,34 @@ const TicketsDetailPanel = {
const nextAction = this.computeNextAction(t, assistMeta, carbonioBlock);
detailEl.innerHTML = `
<div class="card detail-panel ticket-detail-shell" data-session-id="${esc(sessionId || '')}">
<div class="ticket-detail-shell" data-session-id="${esc(sessionId || '')}">
${detailEl.id !== 'ticket-drawer-body' ? `
<div class="ticket-detail-header">
<div>
<h3 style="margin:0">Ticket #${t.id}${t.wizard_ticket_id ? ` · <code class="session-hash">${esc(t.wizard_ticket_id)}</code>` : ''}</h3>
<p class="ticket-meta">${esc(t.domain || t.subject || t.agent || '')}</p>
</div>
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
</div>
</div>` : `
<div class="ticket-modal-meta">
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
${t.source === 'vm112-onboard' ? '<span class="ticket-modal-asm-badge">Onboard · ASM</span>' : ''}
${t.client_paused ? '<span class="badge assisting">Cliente pausado</span>' : ''}
${t.assisted_by ? `<span class="ticket-meta">Técnico: <strong>${esc(t.assisted_by)}</strong></span>` : ''}
</div>`}
${this.nextActionHtml(nextAction)}
${this.tabsHtml({ t, sessionId, hasLive, hasFunil, hasEspelho })}
${this.resumoHtml(t, { sessionId, assistMeta, carbonioBlock, timeline, timing })}
${this.resumoHtml(t, { sessionId, assistMeta, carbonioBlock })}
${hasEspelho ? this.espelhoPaneHtml() : ''}
${hasLive ? this.livePaneHtml(sessionId) : ''}
${hasFunil ? this.funilPaneHtml(timeline, timing) : ''}
</div>`;
const drawerTitle = document.getElementById('ticket-drawer-title');
if (drawerTitle && detailEl.id === 'ticket-drawer-body') {
drawerTitle.textContent = `Ticket #${t.id}${t.domain ? ` · ${t.domain}` : ''}`;
}
this.bindTabs(detailEl);
this.bindNextActions(detailEl, sessionId);
if (sessionId && t.source === 'vm112-onboard') bindAssistActions(detailEl, sessionId);

View file

@ -0,0 +1,162 @@
/**
* Tickets SLA + Priority Score v0 port do ResultsDesk (React)
* Spec negócio Roger: SLA_SETTINGS + fórmula composta origem × idle
*/
const TicketsSla = {
SETTINGS: {
ONBOARD: { response: 2, resolution: 72 },
DESK: { response: 1, resolution: 8 },
SECURITY: { response: 0.5, resolution: 4 },
AGENTIC_OPS: { response: 0.5, resolution: 4 },
},
PRIORITY_CRITICAL: 7,
PRIORITY_HIGH: 4,
PRIORITY_WARN: 2,
mapTenant(t) {
const src = t.source || '';
const ev = t.event || '';
if (src === 'wazuh' || ev === 'wazuh.alert' || src === 'vm112-security' || (t.subject || '').startsWith('[security]')) {
return 'SECURITY';
}
if (src === 'vm112-onboard' || ev.startsWith('onboarding') || ev.startsWith('domain.') || ev.startsWith('dns.')) {
return 'ONBOARD';
}
if (src === 'agentic-ops' || (t.subject || '').toLowerCase().includes('[agentic')) {
return 'AGENTIC_OPS';
}
return 'DESK';
},
hoursSince(iso) {
if (!iso) return 0;
try {
return Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 3600000));
} catch {
return 0;
}
},
isInternalDomain(domain) {
const d = String(domain || '').toLowerCase();
return d.includes('itecnologys.com') || d.includes('ligbox.com.br');
},
computePriority(t) {
const tenant = this.mapTenant(t);
const sla = this.SETTINGS[tenant] || { response: 2, resolution: 24 };
const totalAgeHours = this.hoursSince(t.created_at);
const lastEvent = t._lastEventAt || null;
const idleHours = t._idleHours != null
? t._idleHours
: this.hoursSince(lastEvent || t.created_at);
const wazuhLevel = t.severity != null ? Number(t.severity) : null;
const isLead = t.crm_track === 'lead' || t.is_lead;
let originScore = 1.0;
if (tenant === 'SECURITY' && wazuhLevel != null && wazuhLevel >= 1) originScore = 4.0;
else if (this.isInternalDomain(t.domain || t.email)) originScore = 3.0;
else if (isLead) originScore = 2.0;
let idleScore = 0.5;
if (idleHours >= sla.resolution * 2) idleScore = 2.5;
else if (idleHours >= sla.resolution) idleScore = 2.0;
else if (idleHours >= sla.response * 2) idleScore = 1.5;
else if (idleHours >= sla.response) idleScore = 1.0;
const priorityScore = parseFloat((originScore * idleScore).toFixed(1));
let responseSla = 'OK';
let resolutionSla = 'OK';
if (idleHours > sla.response) responseSla = 'WARNING';
if (idleHours > sla.resolution) resolutionSla = `STALE (${sla.resolution}h+)`;
let priorityBand = 'info';
if (priorityScore >= this.PRIORITY_CRITICAL) priorityBand = 'critical';
else if (priorityScore >= this.PRIORITY_HIGH) priorityBand = 'high';
else if (priorityScore >= this.PRIORITY_WARN) priorityBand = 'warn';
return {
...t,
_tenant: tenant,
_priorityScore: priorityScore,
_priorityBand: priorityBand,
_totalAgeHours: totalAgeHours,
_idleHours: idleHours,
_responseSla: responseSla,
_resolutionSla: resolutionSla,
_slaLimits: sla,
};
},
processList(tickets) {
return tickets
.map((t) => this.computePriority(t))
.sort((a, b) => b._priorityScore - a._priorityScore);
},
computeOverview(tickets) {
const rows = tickets.map((t) => this.computePriority(t));
const active = rows.filter((t) => !['closed'].includes(t.status));
const resolved = rows.filter((t) => ['resolved', 'closed'].includes(t.status));
const slaOk = active.filter((t) => t._responseSla === 'OK' && t._resolutionSla === 'OK').length;
const slaPct = active.length ? Math.round((slaOk / active.length) * 100) : 100;
const mttrSamples = resolved.map((t) => t._totalAgeHours).filter((h) => h > 0);
const mttr = mttrSamples.length
? (mttrSamples.reduce((a, b) => a + b, 0) / mttrSamples.length).toFixed(1)
: '—';
const responseSamples = rows
.filter((t) => t.assigned_at || t.assisted_at)
.map((t) => {
try {
const start = new Date(t.created_at).getTime();
const end = new Date(t.assigned_at || t.assisted_at).getTime();
return Math.max(0, Math.round((end - start) / 3600000));
} catch {
return null;
}
})
.filter((h) => h != null);
const avgResponse = responseSamples.length
? (responseSamples.reduce((a, b) => a + b, 0) / responseSamples.length).toFixed(1)
: (active.length
? (active.reduce((s, t) => s + t._idleHours, 0) / active.length).toFixed(1)
: '—');
const buckets = { critical: 0, high: 0, warn: 0, info: 0 };
for (const t of rows) buckets[t._priorityBand] = (buckets[t._priorityBand] || 0) + 1;
const criticalWaiting = active.filter(
(t) => t._priorityBand === 'critical' && ['open', 'escalated', 'assisting'].includes(t.status),
).length;
return {
slaPct,
mttr,
avgResponse,
buckets,
criticalWaiting,
total: rows.length,
};
},
getInitials(name) {
if (!name) return '—';
const parts = String(name).trim().split(/\s+/);
return ((parts[0]?.[0] || '') + (parts[1]?.[0] || parts[0]?.[1] || '')).toUpperCase() || '—';
},
assigneeLabel(t, userMap = {}) {
const raw = t.assisted_by || t.assigned_to || '';
if (!raw) return { label: 'Sem atribuição', initials: '—' };
const label = userMap[raw] || raw.replace(/[._]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return { label, initials: this.getInitials(label) };
},
};
window.TicketsSla = TicketsSla;

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,15 @@
/**
* Tickets Workspace P0 lista (8 KPIs + 3 sinais) · P1 filas · P2 KPI click + softRefresh
* Arquitectura separada; app.js delega render aqui.
* Results and Works Desk UI aprovada (Spec 032 / Roger 2026-06-28)
* KPI Overview + tabela operacional + alertas agentic.
*/
const TicketsWorkspace = {
context: null,
searchQuery: '',
queueFilter: null,
priorityFilter: null,
_pageReady: false,
_listFingerprint: '',
QUEUE_CHIPS: [
{ key: 'live', label: 'Live agora', icon: '🟢' },
{ key: 'stale', label: 'Parados', icon: '⏸' },
{ key: 'unassigned', label: 'Sem dono', icon: '👤' },
{ key: 'billing', label: 'Billing', icon: '💳' },
{ key: 'wazuh', label: 'Wazuh', icon: '⚠️' },
{ key: 'escalated', label: 'Escalados', icon: '🚨' },
],
async loadContext() {
const user = typeof getUser === 'function' ? getUser() : null;
const [presence, funnel, summary] = await Promise.all([
window.DeskLive?.enabled()
? api('/v1/live/presence').catch(() => ({ sessions: [] }))
@ -39,7 +29,7 @@ const TicketsWorkspace = {
liveBySession,
funnelBySession,
staleHours: summary.onboard_stale_hours ?? 24,
user,
summary,
};
return this.context;
},
@ -55,441 +45,420 @@ const TicketsWorkspace = {
enrichTicket(t) {
const ctx = this.context || { liveBySession: {}, funnelBySession: {}, staleHours: 24 };
const sid = (t.session_id || '').trim();
const live = sid ? ctx.liveBySession[sid] : null;
const funnel = sid ? ctx.funnelBySession[sid] : null;
const isActive = ['open', 'escalated', 'assisting', 'resolved'].includes(t.status);
const lastAt = funnel?.last_event_at || t.created_at;
const lastAt = funnel?.last_event_at || t.assisted_at || t.created_at;
let idleHours = 0;
if (lastAt) {
idleHours = (Date.now() - new Date(lastAt).getTime()) / 3600000;
}
if (lastAt) idleHours = (Date.now() - new Date(lastAt).getTime()) / 3600000;
const ageHours = t.created_at
? (Date.now() - new Date(t.created_at).getTime()) / 3600000
: 0;
const stale = funnel?.stale || (isActive && idleHours >= ctx.staleHours);
const stage = funnel?.current_stage || t.lead_funnel_stage || t.event;
return {
const sev = Number(t.severity);
const isWazuh = t.source === 'wazuh' || t.event === 'wazuh.alert';
let priority = 'info';
if (sev >= 12 || (isWazuh && isActive && sev >= 10)) priority = 'critical';
else if (sev >= 10 || t.status === 'escalated') priority = 'high';
else if (sev >= 7 || stale) priority = 'warn';
let voScore = 3;
if (sev >= 12) voScore = 10;
else if (sev >= 10) voScore = 8;
else if (sev >= 7) voScore = 6;
else if (stale) voScore = 5;
else if (t.status === 'escalated') voScore = 7;
else if (t.status === 'open') voScore = 4;
const live = sid ? !!ctx.liveBySession[sid] : false;
let base = {
...t,
_live: Boolean(live),
_livePath: (live?.path && !String(live.path).startsWith('/api/')) ? live.path : (funnel?.current_path && !String(funnel.current_path).startsWith('/api/') ? funnel.current_path : null),
_stage: stage,
_stageLabel: typeof FUNNEL_LABELS !== 'undefined' ? (FUNNEL_LABELS[stage] || stage) : stage,
_idleHours: Math.round(idleHours),
_ageHours: Math.round(ageHours),
_stale: stale && isActive,
_live: live,
_lastEventAt: funnel?.last_event_at || null,
_unassigned: isActive && !t.assigned_to && !t.assisted_by,
_billing: Boolean(t.billing_state) || (t.subject || '').includes('[billing'),
_wazuh: t.source === 'wazuh' || t.event === 'wazuh.alert',
_carbonioHint: (t.subject || '').toLowerCase().includes('carbonio')
|| (t.subject || '').toLowerCase().includes('account_exists'),
_wazuh: isWazuh,
_security: isWazuh || (t.subject || '').toLowerCase().includes('security'),
_priority: priority,
_voScore: voScore,
_stageLabel: typeof FUNNEL_LABELS !== 'undefined'
? (FUNNEL_LABELS[funnel?.current_stage] || funnel?.current_stage || '')
: '',
};
},
computeIndicators(tickets) {
const enriched = tickets.map((t) => this.enrichTicket(t));
const active = enriched.filter((t) => ['open', 'escalated', 'assisting', 'resolved'].includes(t.status));
return {
open: active.filter((t) => t.status === 'open').length,
assisting: enriched.filter((t) => t.status === 'assisting').length,
escalated: enriched.filter((t) => t.status === 'escalated').length,
live: enriched.filter((t) => t._live).length,
unassigned: active.filter((t) => t._unassigned).length,
stale: active.filter((t) => t._stale).length,
billing: enriched.filter((t) => t._billing).length,
wazuh: enriched.filter((t) => t._wazuh).length,
total: enriched.length,
};
},
indicatorsHtml(ind) {
const items = [
{ key: 'open', label: 'Abertos', value: ind.open, tone: 'info' },
{ key: 'assisting', label: 'Assistindo', value: ind.assisting, tone: 'brand' },
{ key: 'escalated', label: 'Escalados', value: ind.escalated, tone: 'danger' },
{ key: 'live', label: 'Live agora', value: ind.live, tone: 'live' },
{ key: 'unassigned', label: 'Sem dono', value: ind.unassigned, tone: 'warn' },
{ key: 'stale', label: 'Parados', value: ind.stale, tone: 'muted' },
{ key: 'billing', label: 'Billing', value: ind.billing, tone: 'billing', icon: '💳' },
{ key: 'wazuh', label: 'Wazuh', value: ind.wazuh, tone: 'security', icon: '⚠️' },
];
return `
<div class="tickets-kpi-strip" role="group" aria-label="Indicadores da fila">
${items.map((it) => `
<button type="button"
class="tickets-kpi tickets-kpi--${it.tone}${this.queueFilter === it.key ? ' active' : ''}"
data-ticket-kpi="${it.key}"
title="Filtrar: ${esc(it.label)}">
<span class="tickets-kpi-value">${it.icon && it.value ? `${it.icon} ` : ''}${it.value}</span>
<span class="tickets-kpi-label">${esc(it.label)}</span>
</button>`).join('')}
</div>`;
},
queueChipsHtml() {
return `
<div class="tickets-queue-bar" role="group" aria-label="Filas inteligentes">
<span class="tickets-queue-label">Filas:</span>
${this.QUEUE_CHIPS.map((c) => `
<button type="button"
class="tickets-queue-chip${this.queueFilter === c.key ? ' active' : ''}"
data-ticket-queue="${c.key}">
${c.icon} ${esc(c.label)}
</button>`).join('')}
${this.queueFilter ? '<button type="button" class="tickets-queue-clear" data-ticket-queue-clear">Limpar fila</button>' : ''}
</div>`;
},
phaseSignal(t) {
if (t._stale) return { text: `parado ${t._idleHours}h`, cls: 'stale' };
if (t._stageLabel && t._stageLabel !== t.event) {
const short = String(t._stageLabel).replace(/validado|aplicado|criada/gi, '').trim() || t._stageLabel;
return { text: short.slice(0, 28), cls: 'phase' };
if (window.TicketsSla?.computePriority) {
base = TicketsSla.computePriority(base);
}
if (t.event) return { text: String(t.event).replace('onboarding.', '').replace('.', ' '), cls: 'phase' };
return { text: '—', cls: 'muted' };
return base;
},
ticketCardHtml(t) {
const phase = this.phaseSignal(t);
const isOnboard = t.source === 'vm112-onboard' || t.event?.startsWith?.('onboarding');
const title = t.event === 'wazuh.alert'
? esc(t.description || t.agent || t.subject)
: t.domain
? esc(t.domain)
: isOnboard
? `Onboarding · ${esc(t._stageLabel || 'VM112')}`
: esc(t.subject || `Ticket #${t.id}`);
const metaParts = [];
metaParts.push(`#${t.id}`);
if (t.wizard_ticket_id) metaParts.push(esc(t.wizard_ticket_id));
if (t.session_id) metaParts.push(sessionHashHtml(t.session_id, { full: false }));
if (t.email) metaParts.push(esc(t.email));
if (t.assigned_to || t.assisted_by) metaParts.push(esc(t.assisted_by || t.assigned_to));
metaParts.push(fmtDate(t.created_at));
const icons = [
t._billing ? '<span class="ticket-icon-chip" title="Billing">💳</span>' : '',
t._carbonioHint ? '<span class="ticket-icon-chip" title="Carbonio">🔒</span>' : '',
t._wazuh ? `<span class="ticket-icon-chip" title="Wazuh">${severityBadge(t.severity) || '⚠️'}</span>` : '',
t.crm_track === 'lead' ? '<span class="ticket-icon-chip ticket-icon-chip--lead">LEAD</span>' : '',
].filter(Boolean).join('');
const selected = state.selectedTicketId === t.id;
return `
<button type="button"
class="ticket-card ticket-card--${t.status}${selected ? ' selected' : ''}${t._live ? ' ticket-card--live' : ''}"
data-id="${t.id}"${t.session_id ? ` data-session="${esc(t.session_id)}"` : ''}
data-live="${t._live ? '1' : '0'}" data-stale="${t._stale ? '1' : '0'}">
<span class="ticket-card-rail ticket-card-rail--${t.status}" aria-hidden="true"></span>
<span class="ticket-card-main">
<span class="ticket-card-signals">
<span class="ticket-signal ticket-signal--status ticket-signal--${t.status}">${esc(statusLabel(t.status))}</span>
<span class="ticket-signal ticket-signal--live ${t._live ? 'is-live' : 'is-offline'}">
<i aria-hidden="true"></i>${t._live ? 'LIVE' : 'offline'}</span>
<span class="ticket-signal ticket-signal--phase ticket-signal--${phase.cls}">${esc(phase.text)}</span>
</span>
<span class="ticket-card-title">${title}</span>
<span class="ticket-card-meta">${metaParts.join(' · ')}</span>
${t._live && t._livePath ? `<span class="ticket-card-livepath" data-live-path>${esc(t._livePath)}</span>` : ''}
${icons ? `<span class="ticket-card-icons">${icons}</span>` : ''}
</span>
<span class="ticket-card-aside">${sourceBadge(t.source)}</span>
</button>`;
formatMetric(val) {
if (val === '—' || val == null || val === '') return '—';
const n = parseFloat(val);
return Number.isFinite(n) ? `${n.toFixed(1)}h` : String(val);
},
filterByQueue(tickets) {
if (!this.queueFilter) return tickets;
const rules = {
open: (t) => t.status === 'open',
assisting: (t) => t.status === 'assisting',
escalated: (t) => t.status === 'escalated',
live: (t) => t._live,
unassigned: (t) => t._unassigned,
stale: (t) => t._stale,
billing: (t) => t._billing,
wazuh: (t) => t._wazuh,
processTickets(tickets) {
const enriched = tickets.map((t) => this.enrichTicket(this.stripEnrichment(t)));
if (window.TicketsSla?.processList) return TicketsSla.processList(enriched);
return enriched.sort((a, b) => (b._priorityScore || b._voScore || 0) - (a._priorityScore || a._voScore || 0));
},
computeMetrics(tickets) {
const enriched = this.processTickets(tickets);
let overview = null;
if (window.TicketsSla?.computeOverview) {
overview = TicketsSla.computeOverview(enriched);
}
const priorities = overview?.buckets || { critical: 0, high: 0, warn: 0, info: 0 };
const mttrNum = overview ? (overview.mttr === '—' ? 0 : parseFloat(overview.mttr) || 0) : 0;
const respNum = overview ? (overview.avgResponse === '—' ? 0 : parseFloat(overview.avgResponse) || 0) : 0;
if (!overview) {
for (const t of enriched) priorities[t._priorityBand || t._priority] = (priorities[t._priorityBand || t._priority] || 0) + 1;
}
return {
enriched,
slaPct: overview?.slaPct ?? 0,
mttr: overview?.mttr ?? '0',
resp: overview?.avgResponse ?? '0',
mttrNum,
respNum,
priorities,
criticalWaiting: overview?.criticalWaiting ?? priorities.critical ?? 0,
maxPriority: Math.max(priorities.critical || 0, priorities.high || 0, priorities.warn || 0, priorities.info || 0, 1),
};
const fn = rules[this.queueFilter];
return fn ? tickets.filter(fn) : tickets;
},
filterBySearch(tickets) {
const raw = (this.searchQuery || '').trim();
if (!raw) return tickets;
const q = raw.toLowerCase();
const ticketIdQuery = q.replace(/^ticket\s*#?/, '').replace(/^#/, '').trim();
gaugeHtml(pct) {
const p = Math.max(0, Math.min(100, Number(pct) || 0));
const r = 42;
const circ = Math.PI * r;
const filled = ((p / 100) * circ).toFixed(2);
const uid = `rwdG${Math.random().toString(36).slice(2, 8)}`;
return `
<div class="rwd-gauge" role="img" aria-label="SLA ${p}% compliant">
<svg viewBox="0 0 124 72" class="rwd-gauge__svg">
<defs>
<linearGradient id="${uid}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#1f4d45"/>
<stop offset="50%" stop-color="#5a8f3c"/>
<stop offset="100%" stop-color="#c9a227"/>
</linearGradient>
</defs>
<path class="rwd-gauge__track" d="M 20 60 A 42 42 0 0 1 104 60" fill="none" stroke="#d8d2c8" stroke-width="11" stroke-linecap="round"/>
${p > 0 ? `<path class="rwd-gauge__fill" d="M 20 60 A 42 42 0 0 1 104 60" fill="none" stroke="url(#${uid})" stroke-width="11" stroke-linecap="round"
stroke-dasharray="${filled} ${circ.toFixed(2)}" pathLength="${circ.toFixed(2)}"/>` : ''}
<circle cx="20" cy="60" r="5" fill="#1f4d45"/>
<circle cx="104" cy="60" r="5" fill="#c9a227"/>
</svg>
<span class="rwd-gauge__pct">${p}%</span>
</div>`;
},
return tickets.filter((t) => {
if (/^\d+$/.test(ticketIdQuery) && String(t.id) === ticketIdQuery) return true;
const wiz = (t.wizard_ticket_id || '').toLowerCase();
if (wiz && (wiz === q || wiz.includes(q))) return true;
const liveIp = this.context?.liveBySession?.[t.session_id]?.client_ip;
const hay = [
t.id,
`#${t.id}`,
`ticket ${t.id}`,
t.subject,
t.domain,
t.email,
t.session_id,
t.agent,
t.description,
t.assigned_to,
t.assisted_by,
t.wizard_ticket_id,
liveIp,
].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
priorityBarsHtml(priorities, max) {
const items = [
{ key: 'critical', label: 'Critical', count: priorities.critical || 0 },
{ key: 'high', label: 'High', count: priorities.high || 0 },
{ key: 'warn', label: 'Warn', count: priorities.warn || 0 },
{ key: 'info', label: 'Info', count: priorities.info || 0 },
];
const maxVal = Math.max(15, max, ...items.map((i) => i.count), 1);
return `
<div class="rwd-vbars" role="img" aria-label="Tickets by priority">
${items.map((it) => {
const h = it.count > 0 ? Math.max(16, Math.round((it.count / maxVal) * 100)) : 8;
return `
<div class="rwd-vbars__col">
<span class="rwd-vbars__count">${it.count}</span>
<div class="rwd-vbars__track">
<div class="rwd-vbars__bar rwd-vbars__bar--${it.key}" style="height:${h}%"></div>
</div>
<span class="rwd-vbars__label">${it.label}</span>
</div>`;
}).join('')}
</div>`;
},
kpiOverviewHtml(m) {
return `
<section class="rwd-kpi-section" aria-label="KPI Overview">
<h3 class="rwd-kpi-section__title">KPI Overview</h3>
<div class="rwd-kpi-grid">
<article class="rwd-kpi-card rwd-kpi-card--gauge">
<span class="rwd-kpi-card__label">% SLA Compliant (General)</span>
<div class="rwd-kpi-card__body rwd-kpi-card__body--gauge">
${this.gaugeHtml(m.slaPct)}
</div>
</article>
<article class="rwd-kpi-card rwd-kpi-card--metric">
<span class="rwd-kpi-card__label">Avg. Resolution Time (MTTR)</span>
<div class="rwd-kpi-card__body rwd-kpi-card__body--metric">
<span class="rwd-kpi-card__value">${this.formatMetric(m.mttr)}</span>
</div>
</article>
<article class="rwd-kpi-card rwd-kpi-card--metric">
<span class="rwd-kpi-card__label">Avg. Response Time</span>
<div class="rwd-kpi-card__body rwd-kpi-card__body--metric">
<span class="rwd-kpi-card__value">${this.formatMetric(m.resp)}</span>
</div>
</article>
<article class="rwd-kpi-card rwd-kpi-card--bars">
<span class="rwd-kpi-card__label">Tickets by Priority</span>
<div class="rwd-kpi-card__body rwd-kpi-card__body--bars">
${this.priorityBarsHtml(m.priorities, m.maxPriority)}
</div>
</article>
<article class="rwd-kpi-card rwd-kpi-card--metric">
<span class="rwd-kpi-card__label">Critical Waiting</span>
<div class="rwd-kpi-card__body rwd-kpi-card__body--metric">
<span class="rwd-kpi-card__value rwd-kpi-card__value--danger">${m.criticalWaiting}</span>
</div>
</article>
</div>
</section>`;
},
headerHtml() {
return `
<header class="rwd-header">
<div class="rwd-header__copy">
<p class="rwd-header__label">Sessão Tickets</p>
<h2>Results and Works Desk</h2>
<p class="rwd-header__sub">Operações Ligbox onboarding, tickets e monitoramento</p>
</div>
<div class="rwd-header__actions">
<button type="button" class="btn btn-ghost btn-sm" id="rwd-filter-toggle">Filter</button>
<button type="button" class="btn btn-primary btn-sm" id="rwd-refresh">Atualizar</button>
</div>
</header>`;
},
tenantCell(t) {
const name = t.domain || t.agent || (t.subject || '').slice(0, 32) || `Ticket #${t.id}`;
const tags = [
t._tenant === 'SECURITY' || t._security ? '<span class="rwd-tag rwd-tag--security">SECURITY</span>' : '',
t._wazuh ? '<span class="rwd-tag rwd-tag--security">WAZUH</span>' : '',
t._tenant === 'ONBOARD' || t.source === 'vm112-onboard' ? '<span class="rwd-tag rwd-tag--onboard">ONBOARD</span>' : '',
].filter(Boolean).join('');
const resp = t._responseSla || (t.assisted_at ? 'OK' : (t._stale ? 'WARNING' : 'PENDING'));
const res = t._resolutionSla || (t.status === 'closed' || t.status === 'resolved' ? 'OK' : (t._stale ? 'STALE (4h+)' : 'OPEN'));
return `
<div>${tags}<strong>${esc(name)}</strong></div>
<div class="rwd-sla-line">Resp: ${resp} · Res: ${res}</div>`;
},
detailsCell(t) {
const parts = [];
if (t.agent) parts.push(esc(t.agent));
parts.push(`ID: ${t.id}`);
if (t.severity != null) parts.push(`Wazuh Lvl ${t.severity}`);
else if (t._stageLabel) parts.push(esc(t._stageLabel));
else if (t.event) parts.push(esc(String(t.event).replace('onboarding.', '')));
return `
<div class="rwd-detail-title">${esc(t.description || t.subject || parts[0] || '—')}</div>
<div class="rwd-detail-sub">${parts.join(' · ')}</div>`;
},
scoreCell(t) {
const stale = t._stale ? `<span class="rwd-status-pill rwd-status-pill--stale">PARADO ${t._idleHours}H</span>` : '';
const st = t.status || 'open';
const live = t._live
? '<span class="rwd-status-pill rwd-status-pill--live">LIVE</span>'
: '<span class="rwd-status-pill rwd-status-pill--off">OFF</span>';
const score = t._priorityScore ?? t._voScore ?? '—';
return `
<div class="rwd-score-col">
<span class="rwd-status-pill rwd-status-pill--${st}">${esc(statusLabel(st))}</span>
${live}
${stale}
<span class="rwd-vo-score" title="Priority Score v0">${score}</span>
<span class="ticket-meta">V0 SCORE</span>
</div>`;
},
tableHtml(tickets) {
if (!tickets.length) {
return '<p class="loading" style="padding:1rem">Nenhum ticket neste filtro</p>';
}
const rows = tickets.map((t) => {
const sel = state.selectedTicketId === t.id ? ' selected' : '';
return `
<tr class="rwd-row${sel}" data-id="${t.id}"${t.session_id ? ` data-session="${esc(t.session_id)}"` : ''}>
<td>${this.scoreCell(t)}</td>
<td>${this.tenantCell(t)}</td>
<td>${this.detailsCell(t)}</td>
<td><span class="${t._idleHours >= 4 ? 'rwd-idle-warn' : ''}">${t._idleHours}h</span><br><span class="ticket-meta">Inativo</span></td>
<td>${t._ageHours}h<br><span class="ticket-meta">Desde a abertura</span></td>
<td>${t.assisted_by || t.assigned_to
? `<strong>${esc(t.assisted_by || t.assigned_to)}</strong>`
: '<span class="rwd-agent-empty">Sem atribuição</span><br><span class="ticket-meta">AGENT</span>'}</td>
</tr>`;
}).join('');
return `
<div class="rwd-table-wrap">
<table class="rwd-table">
<thead>
<tr>
<th>Priority Score</th>
<th>Tenant &amp; SLAs</th>
<th>Details</th>
<th>Idle (last event)</th>
<th>Total Age</th>
<th>Responsável</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
},
applyFilters(tickets) {
return this.filterBySearch(this.filterByQueue(tickets));
let list = tickets;
const qf = state.ticketFilter || this.queueFilter || 'all';
if (this.priorityFilter) {
list = list.filter((t) => (t._priorityBand || t._priority) === this.priorityFilter);
}
if (qf === 'active') {
list = list.filter((t) => ['open', 'escalated', 'assisting', 'resolved'].includes(t.status));
} else if (qf && qf !== 'all') {
list = list.filter((t) => t.status === qf);
}
if (state.sourceFilter && state.sourceFilter !== 'all') {
list = list.filter((t) => t.source === state.sourceFilter);
}
return list;
},
async fetchAgenticAlerts() {
try {
const data = await api('/v1/agents/findings?open_only=true&limit=6');
return (data.findings || []).map((f) => ({
title: f.title || 'Finding',
sub: `${f.severity || 'warn'} · ${f.category || 'agent'}`,
id: f.id,
}));
} catch {
return [];
}
},
alertsHtml(items) {
if (!items.length) return '';
return `
<aside class="rwd-alerts" aria-label="Agentic Alerts Active">
<div class="rwd-alerts__head">Agentic Alerts Active</div>
<ul class="rwd-alerts__list">
${items.map((a) => `
<li data-goto-agentic="1">
<strong>${esc(a.title)}</strong>
<span>${esc(a.sub)} (open)</span>
</li>`).join('')}
</ul>
</aside>`;
},
bindTable(root, tickets) {
root.querySelectorAll('.rwd-row').forEach((row) => {
row.addEventListener('click', async () => {
state.selectedTicketId = Number(row.dataset.id);
state.selectedSessionId = row.dataset.session || null;
root.querySelectorAll('.rwd-row').forEach((r) => r.classList.remove('selected'));
row.classList.add('selected');
if (window.TicketsDetailPanel?.openDrawer) {
await TicketsDetailPanel.openDrawer(state.selectedTicketId);
} else if (typeof renderTicketDetail === 'function') {
await renderTicketDetail();
}
});
});
},
bindPage(root) {
root.querySelector('#rwd-refresh')?.addEventListener('click', () => {
this._pageReady = false;
if (typeof refresh === 'function') refresh();
});
root.querySelector('#rwd-filter-toggle')?.addEventListener('click', () => {
document.getElementById('view-tickets')?.classList.toggle('rwd-filters-open');
});
root.querySelector('[data-goto-agentic]')?.addEventListener('click', () => setView('agentic-ops'));
root.querySelectorAll('[data-goto-agentic]').forEach((el) => {
el.addEventListener('click', () => setView('agentic-ops'));
});
},
syncPageTitle() {
document.querySelector('.main')?.classList.add('main--rwd-tickets');
},
listFingerprint(tickets) {
return tickets.map((t) => [
t.id, t.status, t._live ? 1 : 0, t._stale ? 1 : 0, t._stage, t._idleHours, t._livePath || '',
].join(':')).join('|');
return tickets.map((t) => [t.id, t.status, t._idleHours, t._priority].join(':')).join('|');
},
setQueueFilter(key) {
this.queueFilter = this.queueFilter === key ? null : key;
const bar = document.getElementById('tickets-queue-bar');
if (bar) {
bar.innerHTML = this.queueChipsHtml();
this.bindQueueBar(bar);
}
this.syncQueueUi();
this.renderListOnly();
renderShell(metrics, filtered, alerts) {
return `
<div class="rwd-page">
${this.headerHtml()}
${this.kpiOverviewHtml(metrics)}
${this.tableHtml(filtered)}
${this.alertsHtml(alerts)}
</div>`;
},
syncQueueUi() {
document.querySelectorAll('[data-ticket-kpi]').forEach((el) => {
el.classList.toggle('active', el.dataset.ticketKpi === this.queueFilter);
});
document.querySelectorAll('[data-ticket-queue]').forEach((el) => {
el.classList.toggle('active', el.dataset.ticketQueue === this.queueFilter);
});
const clearBtn = document.querySelector('[data-ticket-queue-clear]');
if (clearBtn) clearBtn.hidden = !this.queueFilter;
},
bindKpiStrip(strip) {
strip.querySelectorAll('[data-ticket-kpi]').forEach((el) => {
el.addEventListener('click', () => this.setQueueFilter(el.dataset.ticketKpi));
});
},
bindQueueBar(bar) {
bar.querySelectorAll('[data-ticket-queue]').forEach((el) => {
el.addEventListener('click', () => this.setQueueFilter(el.dataset.ticketQueue));
});
bar.querySelector('[data-ticket-queue-clear]')?.addEventListener('click', () => {
this.queueFilter = null;
this.syncQueueUi();
this.renderListOnly();
});
},
bindList(listEl) {
listEl.querySelectorAll('.ticket-card').forEach((btn) => {
btn.addEventListener('click', () => {
state.selectedTicketId = Number(btn.dataset.id);
state.selectedSessionId = btn.dataset.session || null;
listEl.querySelectorAll('.ticket-card').forEach((r) => r.classList.remove('selected'));
btn.classList.add('selected');
if (window.TicketsDetailPanel) TicketsDetailPanel.render(state.selectedTicketId, document.getElementById('ticket-detail'));
else if (typeof renderTicketDetail === 'function') renderTicketDetail();
});
});
},
mountSearchToolbar() {
let bar = document.getElementById('tickets-search-bar');
if (!bar) {
const toolbar = document.querySelector('#view-tickets .toolbar');
if (!toolbar) return;
bar = document.createElement('div');
bar.id = 'tickets-search-bar';
bar.className = 'tickets-search-bar';
bar.innerHTML = `
<input type="search" id="tickets-search-input" class="tickets-search-input"
placeholder="Buscar ticket #, domínio, e-mail, sessão, OB-…, IP…" autocomplete="off" />
<span class="tickets-search-hint ticket-meta">Scan rápido · 3 sinais por card</span>`;
toolbar.parentNode.insertBefore(bar, toolbar);
bar.querySelector('#tickets-search-input')?.addEventListener('input', (e) => {
this.searchQuery = e.target.value;
if (state.view === 'tickets') this.renderListOnly();
});
}
},
mountQueueBar() {
let bar = document.getElementById('tickets-queue-bar');
if (!bar) {
mountRoot() {
let root = document.getElementById('tickets-rwd-root');
if (!root) {
const view = document.getElementById('view-tickets');
const toolbar = view?.querySelector('.toolbar');
if (!toolbar) return;
bar = document.createElement('div');
bar.id = 'tickets-queue-bar';
toolbar.parentNode.insertBefore(bar, toolbar.nextSibling);
if (!view) return null;
root = document.createElement('div');
root.id = 'tickets-rwd-root';
view.insertBefore(root, view.firstChild);
}
bar.innerHTML = this.queueChipsHtml();
this.bindQueueBar(bar);
},
updateKpiStrip(ind) {
const strip = document.getElementById('tickets-kpi-strip');
if (!strip) return;
const map = {
open: ind.open, assisting: ind.assisting, escalated: ind.escalated, live: ind.live,
unassigned: ind.unassigned, stale: ind.stale, billing: ind.billing, wazuh: ind.wazuh,
};
strip.querySelectorAll('[data-ticket-kpi]').forEach((el) => {
const key = el.dataset.ticketKpi;
const val = map[key];
if (val == null) return;
const valueEl = el.querySelector('.tickets-kpi-value');
if (valueEl) {
const icon = (key === 'billing' && val) ? '💳 ' : (key === 'wazuh' && val) ? '⚠️ ' : '';
valueEl.textContent = `${icon}${val}`;
}
});
},
patchLiveSignals(listEl, tickets) {
const byId = {};
for (const t of tickets) byId[t.id] = t;
listEl.querySelectorAll('.ticket-card').forEach((card) => {
const t = byId[Number(card.dataset.id)];
if (!t) return;
const wasLive = card.dataset.live === '1';
const isLive = t._live;
if (wasLive !== isLive) {
card.dataset.live = isLive ? '1' : '0';
card.classList.toggle('ticket-card--live', isLive);
const sig = card.querySelector('.ticket-signal--live');
if (sig) {
sig.classList.toggle('is-live', isLive);
sig.classList.toggle('is-offline', !isLive);
sig.lastChild.textContent = isLive ? 'LIVE' : 'offline';
}
}
card.dataset.stale = t._stale ? '1' : '0';
const phase = card.querySelector('.ticket-signal--phase');
if (phase) {
const p = this.phaseSignal(t);
phase.textContent = p.text;
phase.className = `ticket-signal ticket-signal--phase ticket-signal--${p.cls}`;
}
const pathEl = card.querySelector('[data-live-path]');
if (t._live && t._livePath) {
if (pathEl) pathEl.textContent = t._livePath;
else {
const main = card.querySelector('.ticket-card-main');
if (main) {
const span = document.createElement('span');
span.className = 'ticket-card-livepath';
span.dataset.livePath = '';
span.textContent = t._livePath;
main.appendChild(span);
}
}
} else if (pathEl) pathEl.remove();
});
},
renderListHtml(tickets) {
return tickets.length
? `<div class="ticket-card-list">${tickets.map((t) => this.ticketCardHtml(t)).join('')}</div>`
: '<p class="loading">Nenhum ticket neste filtro</p>';
return root;
},
async renderListOnly() {
const listEl = document.getElementById('ticket-list');
if (!listEl || !state.tickets?.length) return;
const enriched = state.tickets.map((t) => this.enrichTicket(this.stripEnrichment(t)));
state.tickets = enriched;
const filtered = this.applyFilters(enriched);
const root = document.getElementById('tickets-rwd-root');
if (!root || !state.tickets?.length) return;
const metrics = this.computeMetrics(state.tickets.map((t) => this.stripEnrichment(t)));
state.tickets = metrics.enriched;
const filtered = this.applyFilters(metrics.enriched);
const fp = this.listFingerprint(filtered);
if (fp === this._listFingerprint && listEl.querySelector('.ticket-card')) {
this.patchLiveSignals(listEl, filtered);
this.updateKpiStrip(this.computeIndicators(enriched));
return;
}
if (fp === this._listFingerprint && root.querySelector('.rwd-table')) return;
this._listFingerprint = fp;
listEl.innerHTML = this.renderListHtml(filtered);
this.bindList(listEl, filtered);
this.updateKpiStrip(this.computeIndicators(enriched));
const alerts = await this.fetchAgenticAlerts();
root.innerHTML = this.renderShell({ ...metrics, enriched: metrics.enriched }, filtered, alerts);
this.bindTable(root, filtered);
this.bindPage(root);
},
async softRefresh() {
if (!this._pageReady || state.view !== 'tickets') return;
try {
await this.loadContext();
const enriched = (state.tickets || []).map((t) => this.enrichTicket(this.stripEnrichment(t)));
state.tickets = enriched;
const ind = this.computeIndicators(enriched);
this.updateKpiStrip(ind);
const listEl = document.getElementById('ticket-list');
if (listEl) {
const filtered = this.applyFilters(enriched);
const fp = this.listFingerprint(filtered);
if (fp === this._listFingerprint && listEl.querySelector('.ticket-card')) {
this.patchLiveSignals(listEl, filtered);
} else {
this._listFingerprint = fp;
const selectedId = state.selectedTicketId;
listEl.innerHTML = this.renderListHtml(filtered);
this.bindList(listEl, filtered);
if (selectedId) {
listEl.querySelector(`.ticket-card[data-id="${selectedId}"]`)?.classList.add('selected');
}
}
}
if (state.selectedTicketId && TicketsDetailPanel?.activeTab === 'live') {
const detailEl = document.getElementById('ticket-detail');
const sid = detailEl?.dataset?.sessionId || state.selectedSessionId;
const trail = detailEl?.querySelector('#ticket-detail-live-trail');
if (sid && trail && window.DeskLive?.renderNavigationTab) {
await DeskLive.renderNavigationTab(sid, trail);
}
}
} catch { /* ignore poll errors */ }
await this.renderListOnly();
} catch { /* poll */ }
},
async renderPage({ listEl, detailEl, tickets }) {
this.mountSearchToolbar();
this.mountQueueBar();
this.syncPageTitle();
await this.loadContext();
const enriched = tickets.map((t) => this.enrichTicket(t));
state.tickets = enriched;
const ind = this.computeIndicators(enriched);
const filtered = this.applyFilters(enriched);
const metrics = this.computeMetrics(tickets);
state.tickets = metrics.enriched;
const filtered = this.applyFilters(metrics.enriched);
this._listFingerprint = this.listFingerprint(filtered);
let strip = document.getElementById('tickets-kpi-strip');
if (!strip) {
strip = document.createElement('div');
strip.id = 'tickets-kpi-strip';
const view = document.getElementById('view-tickets');
const searchBar = document.getElementById('tickets-search-bar');
if (view && searchBar) view.insertBefore(strip, searchBar);
else if (view) {
const toolbar = view.querySelector('.toolbar');
if (toolbar) view.insertBefore(strip, toolbar);
}
}
strip.innerHTML = this.indicatorsHtml(ind);
this.bindKpiStrip(strip);
listEl.innerHTML = this.renderListHtml(filtered);
this.bindList(listEl, filtered);
const alerts = await this.fetchAgenticAlerts();
const root = this.mountRoot();
if (!root) return;
root.innerHTML = this.renderShell(metrics, filtered, alerts);
this.bindTable(root, filtered);
this.bindPage(root);
this._pageReady = true;
if (state.selectedTicketId && window.TicketsDetailPanel) {
await TicketsDetailPanel.render(state.selectedTicketId, detailEl);
} else if (state.selectedTicketId && typeof renderTicketDetail === 'function') {
await renderTicketDetail();
} else if (state.selectedSessionId && typeof renderSessionDetail === 'function') {
await renderSessionDetail();
} else if (detailEl) {
detailEl.innerHTML = '<div class="card detail-panel"><p class="empty">Selecione um ticket para ver detalhes</p></div>';
if (listEl) listEl.innerHTML = '';
if (detailEl && !state.selectedTicketId) {
detailEl.innerHTML = '';
}
},
};

View file

@ -19,18 +19,19 @@ const TOPNAV_VIEW_GROUP = {
};
const CONTEXT_TOOLBARS = {
tickets: { title: 'Tickets', host: '#view-tickets', label: 'Filas' },
events: { title: 'Eventos', host: '#view-events', label: 'Vistas' },
events: { title: 'Eventos', host: '#view-events', label: 'Vistas', toolbarId: 'events-toolbar' },
};
function initTopnavDropdowns() {
/* v0.11 — tabs planas; sem dropdowns */
function resolveContextToolbar(cfg) {
if (cfg.toolbarId) return document.getElementById(cfg.toolbarId);
const view = document.querySelector(cfg.host);
return view?.querySelector('.toolbar') || null;
}
function parkContextToolbars() {
Object.values(CONTEXT_TOOLBARS).forEach(({ host }) => {
const view = document.querySelector(host);
const toolbar = view?.querySelector('.toolbar');
Object.values(CONTEXT_TOOLBARS).forEach((cfg) => {
const view = document.querySelector(cfg.host);
const toolbar = resolveContextToolbar(cfg);
if (toolbar && view && toolbar.parentElement !== view) {
view.insertBefore(toolbar, view.firstChild);
}
@ -62,11 +63,38 @@ function mountMatrixRoleSidebar() {
if (!roleNav) return false;
const nav = document.getElementById('context-nav');
if (!nav) return false;
nav.querySelectorAll('.am-role-list').forEach((el) => {
if (el !== roleNav) el.remove();
});
if (roleNav.parentElement === nav) return true;
roleNav.dataset.parkedFrom = '#access-matrix-content .am-layout';
nav.appendChild(roleNav);
return true;
}
function mountEventsToolbar() {
const cfg = CONTEXT_TOOLBARS.events;
const nav = document.getElementById('context-nav');
const sidebar = document.getElementById('context-sidebar');
const shell = document.querySelector('.shell');
const titleEl = document.getElementById('context-sidebar-title');
const labelEl = document.getElementById('context-sidebar-label');
if (!cfg || !nav || !sidebar) return false;
const toolbar = resolveContextToolbar(cfg);
if (!toolbar) return false;
sidebar.hidden = false;
shell?.classList.remove('shell--no-context');
if (titleEl) titleEl.textContent = cfg.title;
if (labelEl) labelEl.textContent = cfg.label;
toolbar.classList.add('context-toolbar');
if (toolbar.parentElement !== nav) nav.appendChild(toolbar);
return true;
}
function initTopnavDropdowns() {
/* v0.11 — tabs planas; sem dropdowns */
}
function updateContextSidebar(view) {
const shell = document.querySelector('.shell');
const sidebar = document.getElementById('context-sidebar');
@ -78,20 +106,24 @@ function updateContextSidebar(view) {
parkContextToolbars();
nav.innerHTML = '';
if (view === 'admin' && typeof renderAdminTabs === 'function') {
sidebar.hidden = false;
shell.classList.remove('shell--no-context');
if (titleEl) titleEl.textContent = 'Administradores';
if (labelEl) labelEl.textContent = 'Secções';
nav.innerHTML = `<div id="context-admin-tabs">${renderAdminTabs()}</div>`;
if (typeof bindAdminTabs === 'function') bindAdminTabs();
// Spec 032 — Tickets RWD tem header próprio; sidebar contextual duplica título e embola layout.
if (view === 'tickets') {
sidebar.hidden = true;
shell.classList.add('shell--no-context');
return;
}
// Controle de acesso Suporte — layout ACS com rail próprio (sem sidebar contextual).
if (view === 'admin') {
sidebar.hidden = true;
shell.classList.add('shell--no-context');
return;
}
if (view === 'access-matrix') {
sidebar.hidden = false;
shell.classList.remove('shell--no-context');
if (titleEl) titleEl.textContent = 'Matriz de Acesso';
if (titleEl) titleEl.textContent = '';
if (labelEl) labelEl.textContent = 'Funções';
requestAnimationFrame(() => mountMatrixRoleSidebar());
return;
@ -104,8 +136,12 @@ function updateContextSidebar(view) {
return;
}
const viewEl = document.querySelector(cfg.host);
const toolbar = viewEl?.querySelector('.toolbar');
if (view === 'events') {
mountEventsToolbar();
return;
}
const toolbar = resolveContextToolbar(cfg);
if (!toolbar) {
sidebar.hidden = true;
shell.classList.add('shell--no-context');
@ -125,4 +161,5 @@ window.DeskTopnav = {
updateTopnavActive,
updateContextSidebar,
remountMatrixRoles: mountMatrixRoleSidebar,
remountEventsToolbar: mountEventsToolbar,
};

View file

@ -0,0 +1,484 @@
/**
* Gestão de utilizadores painel direito do Controle de acesso
* Criar · Editar · Congelar · Copiar · Eliminar
*/
(function () {
'use strict';
const ROLE_META = [
{ value: 'super_admin', label: 'Super Admin', group: 'Ops', code: 'SU' },
{ value: 'ops_lead', label: 'Chefe Ops', group: 'Ops', code: 'CO' },
{ value: 'technician', label: 'Suporte', group: 'Ops', code: 'TEC' },
{ value: 'noc', label: 'NOC', group: 'Ops', code: 'NOC' },
{ value: 'sales_admin', label: 'Sales Admin', group: 'Comercial', code: 'SAD' },
{ value: 'sales_support', label: 'Sales Support', group: 'Comercial', code: 'SSU' },
{ value: 'finance', label: 'Financeiro', group: 'Negócio', code: 'FIN' },
{ value: 'marketing', label: 'Marketing', group: 'Negócio', code: 'MKT' },
{ value: 'seo', label: 'SEO', group: 'Negócio', code: 'SEO' },
{ value: 'developer', label: 'Developer', group: 'Plataforma', code: 'DEV' },
{ value: 'devops', label: 'DevOps', group: 'Plataforma', code: 'DVO' },
{ value: 'security_analyst', label: 'Segurança / SOC', group: 'Plataforma', code: 'SOC' },
{ value: 'content_editor', label: 'Conteúdo / CMS', group: 'Plataforma', code: 'CMS' },
{ value: 'agentic_operator', label: 'Operador Agentes IA', group: 'Plataforma', code: 'AIO' },
{ value: 'partner', label: 'Partner', group: 'Externo', code: 'PTR' },
];
const ASSIGNABLE = ROLE_META.filter((r) => r.value !== 'super_admin');
const PORTAL_ID = 'um-modal-portal';
let users = [];
let filterQ = '';
let filterStatus = 'all';
let modalMode = null;
let modalUser = null;
let createDefaultRole = 'technician';
let saving = false;
let msg = '';
let currentHost = null;
let currentOpts = null;
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function roleMeta(roleId) {
return ROLE_META.find((r) => r.value === roleId) || { label: roleId, group: '—', code: '?' };
}
function groupForRole(roleId) {
return roleMeta(roleId).group;
}
function roleSelectGrouped(selected, includeSuper = false) {
const list = includeSuper ? ROLE_META : ASSIGNABLE;
const groups = [...new Set(list.map((r) => r.group))];
return groups.map((g) => {
const opts = list.filter((r) => r.group === g).map((r) =>
`<option value="${r.value}"${r.value === selected ? ' selected' : ''}>${esc(r.label)} (${r.code})</option>`
).join('');
return `<optgroup label="${esc(g)}">${opts}</optgroup>`;
}).join('');
}
function filteredUsers(roleLock) {
const q = filterQ.trim().toLowerCase();
return users.filter((u) => {
if (roleLock && u.role !== roleLock && !(roleLock === 'super_admin' && u.username === 'root')) return false;
if (filterStatus === 'active' && !u.active) return false;
if (filterStatus === 'inactive' && u.active) return false;
if (!q) return true;
const hay = [u.username, u.email, u.display_name, roleMeta(u.role).label, roleMeta(u.role).group].join(' ').toLowerCase();
return hay.includes(q);
});
}
async function loadUsers() {
const r = await fetchWithTimeout('/api/v1/auth/users', { headers: authHeaders() });
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
users = data.users || [];
return users;
}
async function apiUser(path, opts = {}) {
const r = await fetchWithTimeout(`/api/v1/auth${path}`, {
...opts,
headers: authHeaders({ 'Content-Type': 'application/json', ...(opts.headers || {}) }),
});
if (!r.ok) {
const t = await r.text();
throw new Error(t.slice(0, 200) || String(r.status));
}
return r.json();
}
function renderModalHtml() {
if (!modalMode) return '';
const isCreate = modalMode === 'create';
const isClone = modalMode === 'clone';
const u = modalUser || {};
const title = isCreate ? 'Criar utilizador' : isClone ? 'Copiar utilizador' : 'Editar utilizador';
const defaultRole = isCreate ? createDefaultRole : (u.role || createDefaultRole);
return `
<div class="um-modal-root" role="dialog" aria-modal="true">
<div class="um-backdrop" data-um-close></div>
<article class="um-modal-card" data-um-card>
<header class="um-modal-head">
<h3>${title}</h3>
<button type="button" class="um-close" data-um-close aria-label="Fechar">×</button>
</header>
<form class="um-form" data-um-form>
${!isCreate ? `<p class="um-meta"><code>${esc(u.username)}</code></p>` : ''}
${isCreate ? `<p class="um-hint um-hint--block">Perfil sugerido: <strong>${esc(roleMeta(createDefaultRole).label)}</strong> — pode escolher qualquer perfil abaixo.</p>` : ''}
${isCreate || isClone ? `
<label class="um-field">
<span>E-mail (login)</span>
<input type="email" name="email" required placeholder="nome@empresa.com" autocomplete="off"/>
</label>
<label class="um-field">
<span>${isClone ? 'Nova senha (opcional)' : 'Senha inicial'}</span>
<input type="password" name="password" ${isCreate ? 'required minlength="6"' : 'minlength="6"'} placeholder="${isClone ? 'Gera automaticamente se vazio' : 'Mín. 6 caracteres'}" autocomplete="new-password"/>
</label>` : ''}
<label class="um-field">
<span>Nome completo</span>
<input type="text" name="display_name" value="${esc(u.display_name || '')}" placeholder="Nome visível"/>
</label>
<label class="um-field">
<span>Perfil · Grupo</span>
<select name="role" data-um-role-select ${u.username === 'root' ? 'disabled' : ''}>
${roleSelectGrouped(defaultRole, u.username === 'root' || u.role === 'super_admin' || isCreate)}
</select>
<small class="um-hint">Grupo: <strong data-um-group-preview>${esc(groupForRole(defaultRole))}</strong></small>
</label>
${!isCreate && !isClone ? `
<label class="um-field">
<span>Nova senha (opcional)</span>
<input type="password" name="password" minlength="6" placeholder="Deixe vazio para manter" autocomplete="new-password"/>
</label>
<label class="um-toggle-row">
<span>Conta activa</span>
<label class="acs-toggle">
<input type="checkbox" name="active" ${u.active ? 'checked' : ''} ${u.username === 'root' ? 'disabled' : ''}/>
<span class="acs-toggle-slider"></span>
</label>
</label>` : `
<label class="um-toggle-row">
<span>Activar conta </span>
<label class="acs-toggle">
<input type="checkbox" name="active" checked/>
<span class="acs-toggle-slider"></span>
</label>
</label>`}
${msg ? `<p class="um-msg um-msg--err">${esc(msg)}</p>` : ''}
<footer class="um-modal-foot">
<button type="button" class="um-btn-ghost" data-um-close>Cancelar</button>
<button type="submit" class="um-btn-primary" ${saving ? 'disabled' : ''}>
${saving ? 'A guardar…' : isCreate ? 'Criar utilizador' : isClone ? 'Copiar' : 'Guardar'}
</button>
</footer>
</form>
</article>
</div>`;
}
function syncModalPortal() {
let portal = document.getElementById(PORTAL_ID);
if (!modalMode) {
if (portal) portal.innerHTML = '';
document.body.classList.remove('um-scroll-lock');
return;
}
if (!portal) {
portal = document.createElement('div');
portal.id = PORTAL_ID;
document.body.appendChild(portal);
}
portal.innerHTML = renderModalHtml();
document.body.classList.toggle('um-scroll-lock', true);
bindModalEvents(portal);
}
function renderTable(list) {
if (!list.length) {
return '<p class="um-empty">Nenhum utilizador nesta função. Use «Criar utilizador» para adicionar.</p>';
}
const rows = list.map((u) => {
const rm = roleMeta(u.role);
const isRoot = u.username === 'root';
return `
<tr class="um-row">
<td>
<strong>${esc(u.display_name || u.username)}</strong>
<span class="um-sub">${esc(u.email || u.username)}</span>
</td>
<td><span class="um-role-badge">${esc(rm.code)}</span></td>
<td>${u.active ? '<span class="um-status um-status--on">Activo</span>' : '<span class="um-status um-status--off">Off</span>'}</td>
<td class="um-actions">
<button type="button" class="um-act" data-um-edit="${esc(u.username)}">Editar</button>
${!isRoot ? `<button type="button" class="um-act" data-um-freeze="${esc(u.username)}">${u.active ? 'Congelar' : 'Activar'}</button>` : ''}
<button type="button" class="um-act" data-um-clone="${esc(u.username)}">Copiar</button>
${!isRoot ? `<button type="button" class="um-act um-act--danger" data-um-delete="${esc(u.username)}">Eliminar</button>` : ''}
</td>
</tr>`;
}).join('');
return `
<div class="um-table-wrap">
<table class="um-table um-table--compact">
<thead>
<tr><th>Utilizador</th><th>Perfil</th><th>Estado</th><th>Acções</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
function renderContent(roleLock, roleMeta, embedded) {
const list = filteredUsers(roleLock);
const code = window.DeskAccessControlPanel?.ROLE_CODES?.[roleLock] || '';
const active = list.filter((u) => u.active).length;
return `
<div class="um-panel${embedded ? ' um-panel--embedded' : ''}" data-um-root>
<header class="um-head um-head--embedded">
<div>
<span class="acs-matrix-users-title">Utilizadores</span>
${embedded ? `
<span class="acs-matrix-users-meta">
<code class="acs-role-code-inline">${esc(code)}</code>
${esc(roleMeta?.label || roleLock || '')}
<span class="acs-matrix-users-count">${list.length} utilizador${list.length === 1 ? '' : 'es'}</span>
</span>` : ''}
</div>
<button type="button" class="um-btn-primary um-btn-primary--sm" data-um-create>+ Criar</button>
</header>
<div class="um-mini-stats">
<span><strong>${list.length}</strong> filtrados</span>
<span><strong>${active}</strong> activos</span>
<span><strong>${users.length}</strong> total</span>
</div>
<div class="um-toolbar um-toolbar--compact">
<input type="search" class="um-search" data-um-search placeholder="Pesquisar…" value="${esc(filterQ)}"/>
<select data-um-filter-status>
<option value="all" ${filterStatus === 'all' ? 'selected' : ''}>Todos</option>
<option value="active" ${filterStatus === 'active' ? 'selected' : ''}>Activos</option>
<option value="inactive" ${filterStatus === 'inactive' ? 'selected' : ''}>Congelados</option>
</select>
${currentOpts?.onRegistration ? '<button type="button" class="um-btn-ghost um-btn-ghost--sm" data-um-reg>Pedidos cadastro</button>' : ''}
</div>
<div data-um-list>${renderTable(list)}</div>
</div>`;
}
function refreshList() {
if (!currentHost) return;
const roleLock = currentOpts?.roleFilter || null;
const listEl = currentHost.querySelector('[data-um-list]');
if (listEl) listEl.innerHTML = renderTable(filteredUsers(roleLock));
bindRowEvents(currentHost);
}
let editCallback = null;
async function handleSubmit(form) {
const roleLock = currentOpts?.roleFilter || null;
const fd = new FormData(form);
saving = true;
msg = '';
syncModalPortal();
try {
if (modalMode === 'create') {
await apiUser('/users', {
method: 'POST',
body: JSON.stringify({
email: fd.get('email'),
password: fd.get('password'),
role: fd.get('role'),
display_name: fd.get('display_name') || null,
active: !!form.querySelector('[name="active"]')?.checked,
}),
});
} else if (modalMode === 'clone') {
const res = await apiUser(`/users/${encodeURIComponent(modalUser.username)}/clone`, {
method: 'POST',
body: JSON.stringify({
email: fd.get('email'),
password: fd.get('password') || null,
display_name: fd.get('display_name') || null,
active: !!form.querySelector('[name="active"]')?.checked,
}),
});
if (res.generated_password) {
window.alert(`Copiado.\nSenha gerada: ${res.generated_password}`);
}
} else {
const payload = {
display_name: fd.get('display_name') || null,
role: fd.get('role'),
active: !!form.querySelector('[name="active"]')?.checked,
};
const pwd = fd.get('password');
if (pwd && String(pwd).length >= 6) payload.password = pwd;
await apiUser(`/users/${encodeURIComponent(modalUser.username)}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
modalMode = null;
modalUser = null;
msg = '';
syncModalPortal();
await loadUsers();
refreshList();
if (editCallback) { const cb = editCallback; editCallback = null; cb(); }
} catch (e) {
msg = e.message;
syncModalPortal();
} finally {
saving = false;
}
}
function closeModal() {
modalMode = null;
modalUser = null;
msg = '';
saving = false;
syncModalPortal();
}
function bindModalEvents(portal) {
portal.querySelector('.um-backdrop')?.addEventListener('click', (e) => {
if (e.target !== e.currentTarget) return;
closeModal();
});
portal.querySelectorAll('[data-um-close]').forEach((el) => {
el.addEventListener('click', (e) => { e.stopPropagation(); closeModal(); });
});
portal.querySelector('[data-um-card]')?.addEventListener('mousedown', (e) => e.stopPropagation());
portal.querySelector('[data-um-form]')?.addEventListener('submit', async (e) => {
e.preventDefault();
await handleSubmit(e.target);
});
portal.querySelector('[data-um-role-select]')?.addEventListener('change', (e) => {
const preview = portal.querySelector('[data-um-group-preview]');
if (preview) preview.textContent = groupForRole(e.target.value);
});
}
function bindRowEvents(host) {
host.querySelectorAll('[data-um-edit]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
modalUser = users.find((u) => u.username === btn.dataset.umEdit);
modalMode = 'edit';
msg = '';
syncModalPortal();
});
});
host.querySelectorAll('[data-um-clone]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
modalUser = users.find((u) => u.username === btn.dataset.umClone);
modalMode = 'clone';
msg = '';
syncModalPortal();
});
});
host.querySelectorAll('[data-um-freeze]').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const u = users.find((x) => x.username === btn.dataset.umFreeze);
if (!u) return;
const label = u.active ? 'Congelar' : 'Activar';
if (!window.confirm(`${label} ${u.username}?`)) return;
try {
await apiUser(`/users/${encodeURIComponent(u.username)}`, {
method: 'PATCH',
body: JSON.stringify({ active: !u.active }),
});
await loadUsers();
refreshList();
} catch (err) {
window.alert(err.message);
}
});
});
host.querySelectorAll('[data-um-delete]').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const username = btn.dataset.umDelete;
if (!window.confirm(`Eliminar ${username}? Irreversível.`)) return;
try {
await apiUser(`/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
await loadUsers();
refreshList();
} catch (err) {
window.alert(err.message);
}
});
});
}
function bindEvents(host) {
host.querySelector('[data-um-create]')?.addEventListener('click', () => {
modalMode = 'create';
modalUser = null;
msg = '';
syncModalPortal();
});
host.querySelector('[data-um-reg]')?.addEventListener('click', () => {
if (typeof currentOpts?.onRegistration === 'function') currentOpts.onRegistration();
});
host.querySelector('[data-um-search]')?.addEventListener('input', (e) => {
filterQ = e.target.value;
clearTimeout(host._umTimer);
host._umTimer = setTimeout(refreshList, 250);
});
host.querySelector('[data-um-filter-status]')?.addEventListener('change', (e) => {
filterStatus = e.target.value;
refreshList();
});
bindRowEvents(host);
}
async function paint(host, opts = {}) {
if (!host) return;
currentHost = host;
currentOpts = opts;
createDefaultRole = opts.roleFilter || 'technician';
if (typeof canManageUsers === 'function' && !canManageUsers()) {
host.innerHTML = '<p class="loading">Sem permissão.</p>';
return;
}
const firstLoad = !users.length;
if (firstLoad) {
host.innerHTML = '<p class="loading">Carregando utilizadores…</p>';
try {
await loadUsers();
} catch (e) {
host.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
return;
}
}
host.innerHTML = renderContent(opts.roleFilter, opts.roleMeta, opts.embedded);
bindEvents(host);
syncModalPortal();
}
function reset() {
users = [];
filterQ = '';
filterStatus = 'all';
modalMode = null;
modalUser = null;
msg = '';
currentHost = null;
currentOpts = null;
closeModal();
}
function openEdit(username, onSaved) {
loadUsers().then(() => {
modalUser = users.find((u) => u.username === username);
if (!modalUser) return;
modalMode = 'edit';
msg = '';
editCallback = onSaved || null;
syncModalPortal();
}).catch((e) => window.alert(e.message));
}
window.DeskUserManagement = { paint, reset, loadUsers, openEdit, ROLE_META };
})();

View file

@ -0,0 +1,939 @@
/**
* UserWizard criar utilizador
* Spec 040 · DS-FE-003 · mockup Roger · POST /api/v1/governance/users/wizard
*/
(function () {
'use strict';
const ROLE_META = [
{ value: 'super_admin', label: 'Super Admin', group: 'Ops', code: 'SU' },
{ value: 'ops_lead', label: 'Chefe Ops', group: 'Ops', code: 'CO' },
{ value: 'technician', label: 'Suporte', group: 'Ops', code: 'TEC' },
{ value: 'noc', label: 'NOC', group: 'Ops', code: 'NOC' },
{ value: 'sales_admin', label: 'Sales Admin', group: 'Comercial', code: 'SAD' },
{ value: 'sales_support', label: 'Sales Support', group: 'Comercial', code: 'SSU' },
{ value: 'finance', label: 'Financeiro', group: 'Negócio', code: 'FIN' },
{ value: 'marketing', label: 'Marketing', group: 'Negócio', code: 'MKT' },
{ value: 'seo', label: 'SEO', group: 'Negócio', code: 'SEO' },
{ value: 'developer', label: 'Developer', group: 'Plataforma', code: 'DEV' },
{ value: 'devops', label: 'DevOps', group: 'Plataforma', code: 'DVO' },
{ value: 'security_analyst', label: 'Segurança / SOC', group: 'Plataforma', code: 'SOC' },
{ value: 'content_editor', label: 'Conteúdo / CMS', group: 'Plataforma', code: 'CMS' },
{ value: 'agentic_operator', label: 'Operador Agentes IA', group: 'Plataforma', code: 'AIO' },
{ value: 'partner', label: 'Partner', group: 'Externo', code: 'PTR' },
];
const MODULES = [
{ id: 'desk', label: 'Desk' },
{ id: 'openpanel', label: 'OpenPanel' },
{ id: 'billing', label: 'Billing' },
{ id: 'api', label: 'API' },
{ id: 'security', label: 'Security' },
{ id: 'ai_agents', label: 'AI Agents' },
];
const LEVELS = [
{ value: 'full', label: 'Total' },
{ value: 'partial', label: 'Parcial' },
{ value: 'read', label: 'Leitura' },
{ value: 'none', label: 'Sem acesso' },
];
const MODULE_TREE = [
{ id: 'desk', label: 'Desk (Help Desk)', actions: ['Criar / editar / remover utilizadores Desk', 'Gerir tickets e filas', 'Ver relatórios operacionais'] },
{ id: 'openpanel', label: 'OpenPanel (Plataforma)', actions: ['Provisionar sites', 'Gerir DNS e SSL'] },
{ id: 'billing', label: 'Faturação & Billing', actions: ['Ver facturas', 'Validar estado billing'] },
{ id: 'ai_agents', label: 'Agentes IA', actions: ['Executar runbooks', 'Aprovar remediação A7'] },
{ id: 'security', label: 'Segurança / SOC', actions: ['Ver incidentes', 'Reset 2FA utilizadores'] },
{ id: 'api', label: 'API & Integrações', actions: ['Gerir tokens API', 'Webhooks inbound'] },
];
const SECONDARY_GROUP_OPTIONS = ['Financeiro', 'Suporte Nível 2', 'Ops', 'Comercial', 'Negócio', 'Plataforma', 'Externo'];
const GROUP_HINTS = {
Ops: 'Operações internas — NOC, suporte e administradores.',
Comercial: 'Equipa comercial, vendas e pós-venda.',
Negócio: 'Financeiro, marketing e conteúdo.',
Plataforma: 'Dev, DevOps, segurança e agentes IA.',
Externo: 'Parceiros e acessos limitados.',
};
const PERM_TABS = [
{ id: 'modules', label: 'Módulos e funções' },
{ id: 'special', label: 'Recursos especiais' },
{ id: 'restrictions', label: 'Restrições' },
{ id: 'data', label: 'Acesso a dados' },
];
const STEPS = ['Dados do utilizador', 'Perfil e grupo', 'Permissões', 'Revisão'];
const STATUS_OPTIONS = [
{ value: 'active', label: 'Ativo' },
{ value: 'pending', label: 'Inativo' },
{ value: 'frozen', label: 'Congelado' },
{ value: 'invited', label: 'Desactivado' },
];
const SVG = {
userPlus: '<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>',
close: '<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
eye: '<svg viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
whatsapp: '<svg viewBox="0 0 24 24"><path d="M21 11.5a8.38 8.38 0 0 1-3.3 6.7 8.5 8.5 0 0 1-12.7-7.1 8.38 8.38 0 0 1 3.3-6.7A8.5 8.5 0 0 1 21 11.5z"/><path d="M8 12h.01M12 12h.01M16 12h.01"/></svg>',
info: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
calendar: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
shield: '<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
bell: '<svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>',
key: '<svg viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>',
arrow: '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" fill="none" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>',
chevron: '<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>',
};
let open = false;
let step = 0;
let saving = false;
let result = null;
let onDone = null;
let defaultRole = 'technician';
let permTab = 'modules';
let permSearch = '';
let expandedModules = new Set(['desk']);
let confirmOpen = false;
let groupHintOpen = false;
const form = {
display_name: '',
email: '',
phone: '',
document: '',
birthdate: '',
password: '',
password_confirm: '',
account_status: 'active',
force_password_change: true,
mfa_required: true,
notifications_enabled: true,
api_access: false,
role: 'technician',
main_group: 'Ops',
secondary_groups: [],
module_permissions: {},
notes: '',
send_invite_email: true,
activate_account: true,
};
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function roleMeta(id) {
return ROLE_META.find((r) => r.value === id) || { label: id, group: '—', code: '?' };
}
function initials(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '—';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function statusLabel(value) {
return STATUS_OPTIONS.find((s) => s.value === value)?.label || value;
}
function statusBadgeClass(value) {
if (value === 'active') return 'lb-badge lb-badge--active';
if (value === 'frozen') return 'lb-badge lb-badge--frozen';
return 'lb-badge lb-badge--warn';
}
function defaultPerms(role) {
const p = {};
MODULES.forEach((m) => { p[m.id] = 'none'; });
if (role === 'super_admin') MODULES.forEach((m) => { p[m.id] = 'full'; });
else if (role === 'ops_lead' || role === 'devops') {
Object.assign(p, { desk: 'full', openpanel: 'partial', security: 'partial', api: 'read' });
} else if (role === 'technician') Object.assign(p, { desk: 'partial', openpanel: 'read' });
else if (role === 'finance') Object.assign(p, { billing: 'full', desk: 'read' });
else if (role === 'agentic_operator') Object.assign(p, { ai_agents: 'full', desk: 'partial' });
return p;
}
function permStats() {
const perms = form.module_permissions || {};
const allowed = MODULES.filter((m) => perms[m.id] && perms[m.id] !== 'none');
const fullCount = allowed.filter((m) => perms[m.id] === 'full').length;
const partialCount = allowed.filter((m) => perms[m.id] === 'partial').length;
const readCount = allowed.filter((m) => perms[m.id] === 'read').length;
const functionsTotal = 32;
const functionsReleased = Math.min(functionsTotal, allowed.length * 4 + fullCount * 2 + partialCount + readCount);
return {
modules: allowed.length,
functions: `${functionsReleased} de ${functionsTotal}`,
special: String(fullCount + partialCount).padStart(2, '0'),
};
}
function resetForm(role) {
form.display_name = '';
form.email = '';
form.phone = '';
form.document = '';
form.birthdate = '';
form.password = '';
form.password_confirm = '';
form.account_status = 'active';
form.force_password_change = true;
form.mfa_required = true;
form.notifications_enabled = true;
form.api_access = false;
form.role = role || defaultRole;
form.main_group = roleMeta(form.role).group;
form.secondary_groups = [];
form.module_permissions = defaultPerms(form.role);
form.notes = '';
form.send_invite_email = true;
form.activate_account = true;
step = 0;
result = null;
permTab = 'modules';
permSearch = '';
expandedModules = new Set(['desk']);
confirmOpen = false;
groupHintOpen = false;
}
function levelLabel(value) {
return LEVELS.find((l) => l.value === value)?.label || value;
}
function profileSummaryHtml() {
const stats = permStats();
return `
<aside class="lw-summary">
<h4 class="lw-summary-title">Permissões por perfil (resumo)</h4>
<div class="lw-summary-stat"><span>Módulos permitidos</span><strong>${stats.modules} módulos</strong></div>
<div class="lw-summary-stat"><span>Funções libertadas</span><strong>${stats.functions}</strong></div>
<div class="lw-summary-stat"><span>Recursos especiais</span><strong>${stats.special} habilitados</strong></div>
<div class="lw-summary-stat"><span>Restrições</span><strong>Nenhuma</strong></div>
<div class="lw-summary-info">
${SVG.info}
<span>Perfil define o que o utilizador pode fazer. Grupo define onde tem acesso.</span>
</div>
</aside>`;
}
function toggleRow(name, title, desc, checked) {
return `
<label class="lw-access-row">
<div class="lw-access-main">
<span class="lw-access-icon">${SVG[name === 'mfa_required' ? 'shield' : name === 'notifications_enabled' ? 'bell' : 'key']}</span>
<span class="lw-toggle-text">
<strong>${esc(title)}</strong>
<span class="lw-toggle-desc">${esc(desc)}</span>
</span>
</div>
<span class="lw-toggle">
<input type="checkbox" name="${name}"${checked ? ' checked' : ''}/>
<span class="lw-toggle-slider"></span>
</span>
</label>`;
}
function summaryHtml() {
const rm = roleMeta(form.role);
const stats = permStats();
const name = form.display_name.trim() || '—';
const email = form.email.trim() || '—';
return `
<aside class="lw-summary">
<h4 class="lw-summary-title">Resumo do utilizador</h4>
<div class="lw-summary-preview">
<div class="lw-summary-avatar">${esc(initials(form.display_name))}</div>
<div>
<div class="lw-summary-name">${esc(name)}</div>
<div class="lw-summary-email">${esc(email)}</div>
<span class="${statusBadgeClass(form.account_status)}">${esc(statusLabel(form.account_status))}</span>
</div>
</div>
<dl class="lw-summary-kv">
<dt>Perfil</dt><dd>${esc(rm.label)}</dd>
<dt>Grupo</dt><dd>${esc(form.main_group)}</dd>
<dt>Estado</dt><dd>${esc(statusLabel(form.account_status))}</dd>
<dt>2FA</dt><dd>${form.mfa_required ? 'Activado' : 'Opcional'}</dd>
<dt>Acesso API</dt><dd>${form.api_access ? 'Permitido' : 'Não permitido'}</dd>
<dt>Notificações</dt><dd>${form.notifications_enabled ? 'Activas' : 'Desactivadas'}</dd>
</dl>
<div class="lw-summary-perms">
<h5>Permissões iniciais (resumo)</h5>
<div class="lw-summary-stat"><span>Módulos permitidos</span><strong>${stats.modules}</strong></div>
<div class="lw-summary-stat"><span>Funções libertadas</span><strong>${stats.functions}</strong></div>
<div class="lw-summary-stat"><span>Recursos especiais</span><strong>${stats.special} habilitados</strong></div>
</div>
<div class="lw-summary-info">
${SVG.info}
<span>Após a criação, o perfil e as permissões podem ser ajustados na gestão de utilizadores.</span>
</div>
</aside>`;
}
function stepBasic() {
const statusChips = STATUS_OPTIONS.map((s) =>
`<button type="button" class="lw-status-chip${form.account_status === s.value ? ' active' : ''}" data-status="${s.value}">${esc(s.label)}</button>`
).join('');
return `
<div class="lb-wizard-form">
<section class="lw-section">
<h3 class="lw-section-title">Dados pessoais</h3>
<div class="lw-field-grid lw-field-grid--2">
<div class="lw-field lw-field--full">
<label class="lw-field__label">Nome completo <span class="lw-req">*</span></label>
<input name="display_name" value="${esc(form.display_name)}" placeholder="Digite o nome completo" required/>
</div>
<div class="lw-field">
<label class="lw-field__label">E-mail (login) <span class="lw-req">*</span></label>
<input type="email" name="email" value="${esc(form.email)}" placeholder="ex.: nome@empresa.com" required autocomplete="off"/>
</div>
<div class="lw-field">
<label class="lw-field__label">Telefone <span style="font-weight:400;color:var(--lb-text-muted)">(opcional)</span></label>
<div class="lw-input-icon">
<input name="phone" value="${esc(form.phone)}" placeholder="(11) 99999-9999"/>
<span class="lw-input-trail">${SVG.whatsapp}</span>
</div>
</div>
<div class="lw-field">
<label class="lw-field__label">Documento <span style="font-weight:400;color:var(--lb-text-muted)">(opcional)</span></label>
<div class="lw-input-icon">
<input name="document" value="${esc(form.document)}" placeholder="CPF ou documento"/>
<span class="lw-input-trail">${SVG.info}</span>
</div>
</div>
<div class="lw-field">
<label class="lw-field__label">Data de nascimento <span style="font-weight:400;color:var(--lb-text-muted)">(opcional)</span></label>
<div class="lw-input-icon">
<input name="birthdate" value="${esc(form.birthdate)}" placeholder="dd/mm/aaaa"/>
<span class="lw-input-trail">${SVG.calendar}</span>
</div>
</div>
</div>
</section>
<section class="lw-section">
<h3 class="lw-section-title">Segurança e acesso</h3>
<div class="lw-field-grid lw-field-grid--2">
<div class="lw-field">
<label class="lw-field__label">Senha inicial <span class="lw-req">*</span></label>
<div class="lw-input-icon">
<input type="password" name="password" minlength="6" required autocomplete="new-password"/>
<button type="button" class="lw-input-action" data-toggle-pw="password" aria-label="Mostrar senha">${SVG.eye}</button>
</div>
</div>
<div class="lw-field">
<label class="lw-field__label">Confirmar senha <span class="lw-req">*</span></label>
<div class="lw-input-icon">
<input type="password" name="password_confirm" minlength="6" required autocomplete="new-password"/>
<button type="button" class="lw-input-action" data-toggle-pw="password_confirm" aria-label="Mostrar senha">${SVG.eye}</button>
</div>
</div>
</div>
<label class="lw-toggle-row" style="margin-top:8px">
<span class="lw-toggle-text">
<strong>Forçar alteração no primeiro acesso</strong>
<span class="lw-toggle-desc">O utilizador terá de definir uma nova senha no primeiro login</span>
</span>
<span class="lw-toggle">
<input type="checkbox" name="force_password_change"${form.force_password_change ? ' checked' : ''}/>
<span class="lw-toggle-slider"></span>
</span>
</label>
<div class="lw-field" style="margin-top:14px">
<span class="lw-field__label">Estado da conta</span>
<div class="lw-status-chips" role="radiogroup" aria-label="Estado da conta">
${statusChips}
<input type="hidden" name="account_status" value="${esc(form.account_status)}"/>
</div>
</div>
</section>
<section class="lw-section">
<h3 class="lw-section-title">Acesso adicional</h3>
${toggleRow('mfa_required', 'Activar autenticação de dois factores (2FA)', 'Recomendado para perfis administrativos', form.mfa_required)}
${toggleRow('notifications_enabled', 'Receber notificações do sistema', 'Alertas operacionais e avisos de segurança', form.notifications_enabled)}
${toggleRow('api_access', 'Permitir acesso via API', 'Gera token de API para integrações externas', form.api_access)}
</section>
</div>
${summaryHtml()}`;
}
function stepProfile() {
const groups = [...new Set(ROLE_META.map((r) => r.group))];
const opts = ROLE_META.filter((r) => r.value !== 'super_admin' || form.role === 'super_admin')
.map((r) => `<option value="${r.value}"${r.value === form.role ? ' selected' : ''}>${esc(r.label)} (${r.code})</option>`).join('');
const addable = SECONDARY_GROUP_OPTIONS.filter((g) => !form.secondary_groups.includes(g));
const tags = form.secondary_groups.map((g) =>
`<span class="lw-tag">${esc(g)}<button type="button" data-remove-group="${esc(g)}" aria-label="Remover">×</button></span>`
).join('') || '<span class="lb-stat-sub">Nenhum grupo adicional</span>';
const groupHint = GROUP_HINTS[form.main_group] || 'Grupo organizacional do utilizador.';
return `
<div class="lb-wizard-form">
<section class="lw-section">
<h3 class="lw-section-title">Perfil e grupo</h3>
<div class="lw-field-grid lw-field-grid--2">
<div class="lw-field">
<label class="lw-field__label">Perfil principal</label>
<select name="role">${opts}</select>
<p class="lw-field-hint">Perfil sugerido com base nas permissões seleccionadas.</p>
</div>
<div class="lw-field">
<label class="lw-field__label">Grupo principal</label>
<select name="main_group">
${groups.map((g) => `<option value="${esc(g)}"${form.main_group === g ? ' selected' : ''}>${esc(g)}</option>`).join('')}
</select>
<button type="button" class="lw-link-btn" data-toggle-group-hint>${groupHintOpen ? 'Ocultar' : 'Ver'} descrição do grupo</button>
${groupHintOpen ? `<p class="lw-field-hint">${esc(groupHint)}</p>` : ''}
</div>
</div>
<div class="lw-field" style="margin-top:12px">
<label class="lw-field__label">Grupos adicionais</label>
<select data-add-group ${addable.length ? '' : 'disabled'}>
<option value="">Seleccionar grupo</option>
${addable.map((g) => `<option value="${esc(g)}">${esc(g)}</option>`).join('')}
</select>
<div class="lw-tags" data-group-tags>${tags}</div>
</div>
<div class="lw-summary-info" style="margin-top:16px">
${SVG.info}
<span><strong>Perfil</strong> define o que o utilizador pode fazer. <strong>Grupo</strong> define onde tem acesso.</span>
</div>
</section>
</div>
${profileSummaryHtml()}`;
}
function filteredModules() {
const q = permSearch.trim().toLowerCase();
if (!q) return MODULE_TREE;
return MODULE_TREE.filter((m) =>
m.label.toLowerCase().includes(q) || m.actions.some((a) => a.toLowerCase().includes(q))
);
}
function moduleLevelSelect(moduleId) {
const lv = form.module_permissions[moduleId] || 'none';
const opts = LEVELS.map((l) =>
`<option value="${l.value}"${lv === l.value ? ' selected' : ''}>${l.label}</option>`).join('');
return `<select name="perm_${moduleId}" class="lw-module-level" data-module="${moduleId}">${opts}</select>`;
}
function stepPermissions() {
const tabs = PERM_TABS.map((t) =>
`<button type="button" class="lw-perm-tab${permTab === t.id ? ' active' : ''}" data-perm-tab="${t.id}">${esc(t.label)}</button>`
).join('');
let tabBody = '';
if (permTab === 'modules') {
const modules = filteredModules();
tabBody = `
<div class="lw-perm-toolbar">
<input type="search" placeholder="Pesquisar módulos ou funções…" value="${esc(permSearch)}" data-perm-search/>
<button type="button" class="lb-btn-ghost" data-expand-all>Expandir todos</button>
</div>
<div class="lw-module-list">
${modules.map((m) => {
const isOpen = expandedModules.has(m.id);
const actions = m.actions.map((label, i) => `
<div class="lw-action-row">
<span>${esc(label)}</span>
${i === 0 ? moduleLevelSelect(m.id) : `<span class="lb-stat-sub">${esc(levelLabel(form.module_permissions[m.id] || 'none'))}</span>`}
</div>`).join('');
return `
<div class="lw-module${isOpen ? ' is-open' : ''}" data-module-id="${m.id}">
<button type="button" class="lw-module-head" data-toggle-module="${m.id}">
<span class="lw-module-chevron">${SVG.chevron}</span>
<span class="lw-module-title">${esc(m.label)}</span>
<span class="lw-module-count">${m.actions.length} funções</span>
</button>
<div class="lw-module-body">${actions}</div>
</div>`;
}).join('')}
</div>`;
} else if (permTab === 'special') {
tabBody = `<div class="lw-tab-placeholder">Recursos especiais — ${permStats().special} habilitados pelo perfil <strong>${esc(roleMeta(form.role).label)}</strong>.</div>`;
} else if (permTab === 'restrictions') {
tabBody = `<div class="lw-tab-placeholder">Nenhuma restrição adicional configurada para este utilizador.</div>`;
} else {
tabBody = `<div class="lw-tab-placeholder">Acesso a dados sensíveis herda do perfil e grupo principal.</div>`;
}
return `
<div class="lb-wizard-form">
<section class="lw-section">
<h3 class="lw-section-title">Permissões</h3>
<p class="lb-stat-sub" style="margin:0 0 12px">Ajuste fino das permissões antes de criar a conta.</p>
<div class="lw-perm-tabs">${tabs}</div>
${tabBody}
</section>
</div>
${profileSummaryHtml()}`;
}
function stepReview() {
const secGroups = form.secondary_groups.length ? form.secondary_groups.join(', ') : '—';
const stats = permStats();
return `
<div class="lb-wizard-form lb-wizard-form--wide">
<section class="lw-section">
<h3 class="lw-section-title">Revisão</h3>
<p class="lb-stat-sub" style="margin:0 0 14px">Confirme todos os dados antes de criar o utilizador.</p>
<div class="lw-review-grid">
<div class="lw-review-col">
<h4>Dados do utilizador</h4>
<dl class="lw-review-kv">
<dt>Nome</dt><dd>${esc(form.display_name)}</dd>
<dt>E-mail</dt><dd>${esc(form.email)}</dd>
<dt>Telefone</dt><dd>${esc(form.phone || '')}</dd>
<dt>Estado da conta</dt><dd>${esc(statusLabel(form.account_status))}</dd>
<dt>2FA</dt><dd>${form.mfa_required ? 'Activado' : 'Opcional'}</dd>
</dl>
</div>
<div class="lw-review-col">
<h4>Perfil e grupo</h4>
<dl class="lw-review-kv">
<dt>Perfil principal</dt><dd>${esc(roleMeta(form.role).label)}</dd>
<dt>Grupo principal</dt><dd>${esc(form.main_group)}</dd>
<dt>Grupos adicionais</dt><dd>${esc(secGroups)}</dd>
</dl>
<h4 style="margin-top:14px">Permissões</h4>
<dl class="lw-review-kv">
<dt>Módulos</dt><dd>${stats.modules} módulos</dd>
<dt>Funções</dt><dd>${stats.functions}</dd>
</dl>
</div>
<div class="lw-review-col">
<h4>Acessos adicionais</h4>
<dl class="lw-review-kv">
<dt>2FA</dt><dd>${form.mfa_required ? 'Activado' : 'Não'}</dd>
<dt>Acesso API</dt><dd>${form.api_access ? 'Permitido' : 'Não permitido'}</dd>
<dt>Notificações</dt><dd>${form.notifications_enabled ? 'Activas' : 'Desactivadas'}</dd>
</dl>
<div class="lw-field" style="margin-top:12px">
<label class="lw-field__label">Observações (opcional)</label>
<textarea name="notes" maxlength="200" rows="3" placeholder="Notas internas sobre este utilizador…">${esc(form.notes)}</textarea>
<span class="lw-field-hint">${form.notes.length}/200 caracteres</span>
</div>
<label style="flex-direction:row;gap:8px;align-items:center;display:flex;font-size:0.78rem;margin-top:10px">
<input type="checkbox" name="send_invite_email"${form.send_invite_email ? ' checked' : ''}/> Enviar e-mail convite
</label>
</div>
</div>
</section>
</div>`;
}
function stepSuccess() {
const email = result?.user?.email || form.email;
const invite = result?.invite_link || '';
return `
<div class="lb-wizard-success">
<div class="lw-success-icon"></div>
<h2>Utilizador criado com sucesso!</h2>
<p class="lb-stat-sub">${form.send_invite_email ? `E-mail de convite enviado para ${esc(email)}` : `Conta criada: ${esc(email)}`}</p>
<p class="lb-stat-sub">ID interno · ${esc(result?.internal_id || '')}</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:20px">
<button type="button" class="lb-btn-primary" data-wz-view-user>Ver detalhes do utilizador</button>
<button type="button" class="lb-btn-ghost" data-wz-another>Criar outro utilizador</button>
<button type="button" class="lb-btn-ghost" data-wz-goto-mgmt>Voltar para a lista</button>
${invite ? `<button type="button" class="lb-btn-ghost" data-wz-copy-invite>Copiar link convite</button>` : ''}
</div>
${form.send_invite_email ? `
<div class="lw-email-preview">
<div class="lw-email-preview__head">ligbox</div>
<div class="lw-email-preview__body">
<p>Olá <strong>${esc(form.display_name)}</strong>,</p>
<p>Foi criada a sua conta no Ligbox Ops Desk. Clique abaixo para definir a sua senha no primeiro acesso.</p>
<p style="margin-top:14px"><span class="lb-btn-primary" style="display:inline-block;text-decoration:none">Definir minha senha</span></p>
<p class="lb-stat-sub" style="margin-top:12px">Link válido por 24 horas · ${esc(email)}</p>
</div>
</div>` : ''}
</div>`;
}
function confirmModalHtml() {
return `
<div class="lw-confirm-overlay" data-wz-confirm-overlay>
<div class="lw-confirm-card" role="alertdialog">
<h3>Confirmar criação do utilizador</h3>
<p class="lb-stat-sub" style="margin:0 0 10px">O que acontece ao clicar «Criar utilizador»:</p>
<ul>
<li>Conta criada na base Desk</li>
<li>${form.send_invite_email ? 'E-mail de convite enviado' : 'Convite por e-mail não enviado'}</li>
<li>Utilizador define senha no 1.º acesso${form.force_password_change ? ' (obrigatório)' : ''}</li>
<li>Permissões RBAC aplicadas conforme revisão</li>
<li>Registo em audit log</li>
</ul>
<div class="lw-confirm-actions">
<button type="button" class="lb-btn-ghost" data-wz-confirm-cancel>Cancelar</button>
<button type="button" class="lb-btn-primary" data-wz-confirm-ok ${saving ? 'disabled' : ''}>${saving ? 'A criar…' : 'Criar utilizador'}</button>
</div>
</div>
</div>`;
}
function renderSteps() {
if (step >= 4) return '';
return STEPS.map((label, i) => {
const cls = i === step ? 'active' : i < step ? 'done' : '';
return `<div class="lb-wizard-step ${cls}"><span class="lb-wizard-step-num">${i + 1}</span>${esc(label)}</div>`;
}).join('');
}
function renderBody() {
if (step === 0) return stepBasic();
if (step === 1) return stepProfile();
if (step === 2) return stepPermissions();
if (step === 3) return stepReview();
return stepSuccess();
}
function syncDom() {
const root = document.getElementById('lb-user-wizard-root');
if (!open) {
if (root) root.innerHTML = '';
document.body.classList.remove('um-scroll-lock');
return;
}
document.body.classList.add('um-scroll-lock');
const el = root || (() => {
const d = document.createElement('div');
d.id = 'lb-user-wizard-root';
document.body.appendChild(d);
return d;
})();
const isSuccess = step >= 4;
const isWide = step === 3 || isSuccess;
const nextLabel = step === 3 ? 'Criar utilizador' : 'Continuar';
el.innerHTML = `
<div class="lb-wizard-root" role="dialog" aria-modal="true" aria-labelledby="lw-title">
<div class="lb-wizard-card">
<header class="lb-wizard-head">
<div class="lb-wizard-head__main">
<div class="lb-wizard-head__icon">${SVG.userPlus}</div>
<div>
<h2 id="lw-title" class="lb-wizard-head__title">Criar utilizador</h2>
<p class="lb-wizard-head__desc">Crie uma nova conta de utilizador e defina perfil, grupo e permissões iniciais.</p>
</div>
</div>
<div class="lb-wizard-head__right">
${!isSuccess ? `<nav class="lb-wizard-steps" aria-label="Passos">${renderSteps()}</nav>` : ''}
<button type="button" class="lb-wizard-close" data-wz-close aria-label="Fechar">${SVG.close}</button>
</div>
</header>
<div class="lb-wizard-body${isSuccess ? ' lb-wizard-body--success' : ''}${isWide && !isSuccess ? ' lb-wizard-body--wide' : ''}">${renderBody()}</div>
${!isSuccess ? `
<footer class="lb-wizard-foot">
<button type="button" class="lb-btn-ghost" data-wz-cancel>Cancelar</button>
<div style="display:flex;gap:8px">
${step > 0 ? `<button type="button" class="lb-btn-ghost" data-wz-back>Voltar</button>` : ''}
<button type="button" class="lb-btn-primary" data-wz-next ${saving ? 'disabled' : ''}>
${step === 3 && saving ? 'A criar…' : nextLabel}${step < 3 ? SVG.arrow : ''}
</button>
</div>
</footer>` : ''}
</div>
${confirmOpen ? confirmModalHtml() : ''}
</div>`;
bindWizard(el);
}
function readForm(container) {
const g = (n) => container.querySelector(`[name="${n}"]`);
if (step === 0) {
form.display_name = g('display_name')?.value?.trim() || '';
form.email = g('email')?.value?.trim().toLowerCase() || '';
form.phone = g('phone')?.value?.trim() || '';
form.document = g('document')?.value?.trim() || '';
form.birthdate = g('birthdate')?.value?.trim() || '';
form.password = g('password')?.value || '';
form.password_confirm = g('password_confirm')?.value || '';
form.account_status = g('account_status')?.value || 'active';
form.force_password_change = !!container.querySelector('[name="force_password_change"]')?.checked;
form.mfa_required = !!container.querySelector('[name="mfa_required"]')?.checked;
form.notifications_enabled = !!container.querySelector('[name="notifications_enabled"]')?.checked;
form.api_access = !!container.querySelector('[name="api_access"]')?.checked;
if (!form.display_name) throw new Error('Nome completo é obrigatório');
if (!form.email) throw new Error('E-mail é obrigatório');
if (form.password !== form.password_confirm) throw new Error('Senhas não coincidem');
} else if (step === 1) {
form.role = g('role')?.value || form.role;
form.main_group = g('main_group')?.value || roleMeta(form.role).group;
form.module_permissions = defaultPerms(form.role);
} else if (step === 2) {
MODULES.forEach((m) => {
form.module_permissions[m.id] = g(`perm_${m.id}`)?.value || 'none';
});
} else if (step === 3) {
form.notes = g('notes')?.value?.trim() || '';
form.send_invite_email = !!container.querySelector('[name="send_invite_email"]')?.checked;
}
}
function parseApiError(text) {
try {
const data = JSON.parse(text);
if (Array.isArray(data.detail)) {
return data.detail.map((d) => d.msg || d.message || JSON.stringify(d)).join('\n');
}
if (typeof data.detail === 'string') return data.detail;
return data.message || text;
} catch (_) {
return text;
}
}
async function submitWizard() {
saving = true;
syncDom();
try {
const perms = {};
MODULES.forEach((m) => { perms[m.id] = form.module_permissions[m.id] || 'none'; });
const extraNotes = [form.document && `Doc: ${form.document}`, form.birthdate && `Nasc.: ${form.birthdate}`].filter(Boolean).join(' · ');
const notesRaw = [form.notes, extraNotes].filter(Boolean).join('\n') || null;
const notes = notesRaw ? notesRaw.slice(0, 200) : null;
if (form.password.length < 6) throw new Error('Senha inicial: mínimo 6 caracteres');
const r = await fetchWithTimeout('/api/v1/governance/users/wizard', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
display_name: form.display_name,
email: form.email,
phone: form.phone || null,
password: form.password,
account_status: form.account_status,
force_password_change: form.force_password_change,
mfa_required: form.mfa_required,
notifications_enabled: form.notifications_enabled,
api_access: form.api_access,
role: form.role,
main_group: form.main_group,
secondary_groups: form.secondary_groups,
module_permissions: perms,
notes,
send_invite_email: form.send_invite_email,
activate_account: form.account_status === 'active',
}),
});
if (!r.ok) throw new Error(parseApiError(await r.text()));
result = await r.json();
step = 4;
if (typeof onDone === 'function') onDone(result);
} catch (e) {
window.alert(e.message);
} finally {
saving = false;
syncDom();
}
}
function bindWizard(root) {
root.querySelector('[data-wz-close]')?.addEventListener('click', close);
root.querySelector('[data-wz-cancel]')?.addEventListener('click', close);
root.querySelector('.lb-wizard-root')?.addEventListener('click', (e) => {
if (e.target.classList.contains('lb-wizard-root')) close();
});
root.querySelectorAll('[data-toggle-pw]').forEach((btn) => {
btn.addEventListener('click', () => {
const field = root.querySelector(`[name="${btn.dataset.togglePw}"]`);
if (!field) return;
field.type = field.type === 'password' ? 'text' : 'password';
});
});
root.querySelectorAll('[data-status]').forEach((chip) => {
chip.addEventListener('click', () => {
form.account_status = chip.dataset.status;
syncDom();
});
});
root.querySelector('[data-wz-back]')?.addEventListener('click', () => {
if (step > 0) { step -= 1; syncDom(); }
});
root.querySelector('[data-wz-next]')?.addEventListener('click', async () => {
const body = root.querySelector('.lb-wizard-body');
try {
readForm(body);
} catch (e) {
window.alert(e.message);
return;
}
if (step === 3) {
confirmOpen = true;
syncDom();
return;
}
step += 1;
syncDom();
});
root.querySelector('[data-wz-confirm-cancel]')?.addEventListener('click', () => {
confirmOpen = false;
syncDom();
});
root.querySelector('[data-wz-confirm-overlay]')?.addEventListener('click', (e) => {
if (e.target.classList.contains('lw-confirm-overlay')) {
confirmOpen = false;
syncDom();
}
});
root.querySelector('[data-wz-confirm-ok]')?.addEventListener('click', async () => {
confirmOpen = false;
await submitWizard();
});
root.querySelector('[data-toggle-group-hint]')?.addEventListener('click', () => {
groupHintOpen = !groupHintOpen;
syncDom();
});
root.querySelector('[data-add-group]')?.addEventListener('change', (e) => {
const val = e.target.value;
if (val && !form.secondary_groups.includes(val)) {
form.secondary_groups.push(val);
syncDom();
}
});
root.querySelectorAll('[data-remove-group]').forEach((btn) => {
btn.addEventListener('click', () => {
form.secondary_groups = form.secondary_groups.filter((g) => g !== btn.dataset.removeGroup);
syncDom();
});
});
root.querySelectorAll('[data-perm-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
permTab = btn.dataset.permTab;
syncDom();
});
});
root.querySelector('[data-perm-search]')?.addEventListener('input', (e) => {
permSearch = e.target.value;
clearTimeout(root._permTimer);
root._permTimer = setTimeout(() => syncDom(), 180);
});
root.querySelector('[data-expand-all]')?.addEventListener('click', () => {
MODULE_TREE.forEach((m) => expandedModules.add(m.id));
syncDom();
});
root.querySelectorAll('[data-toggle-module]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.toggleModule;
if (expandedModules.has(id)) expandedModules.delete(id);
else expandedModules.add(id);
syncDom();
});
});
root.querySelectorAll('.lw-module-level').forEach((sel) => {
sel.addEventListener('change', (e) => {
form.module_permissions[e.target.dataset.module] = e.target.value;
syncDom();
});
});
root.querySelector('[name="notes"]')?.addEventListener('input', (e) => {
form.notes = e.target.value.slice(0, 200);
const hint = root.querySelector('.lw-field-hint');
if (hint) hint.textContent = `${form.notes.length}/200 caracteres`;
});
root.querySelector('[name="role"]')?.addEventListener('change', (e) => {
form.role = e.target.value;
form.main_group = roleMeta(form.role).group;
form.module_permissions = defaultPerms(form.role);
syncDom();
});
['display_name', 'email', 'phone'].forEach((field) => {
root.querySelector(`[name="${field}"]`)?.addEventListener('input', (e) => {
form[field] = e.target.value;
const summary = root.querySelector('.lw-summary');
if (summary && step === 0) {
const nameEl = summary.querySelector('.lw-summary-name');
const emailEl = summary.querySelector('.lw-summary-email');
const avatarEl = summary.querySelector('.lw-summary-avatar');
if (nameEl) nameEl.textContent = form.display_name.trim() || '—';
if (emailEl) emailEl.textContent = form.email.trim() || '—';
if (avatarEl) avatarEl.textContent = initials(form.display_name);
}
});
});
root.querySelectorAll('.lw-toggle input[type="checkbox"]').forEach((el) => {
el.addEventListener('change', () => {
if (step !== 0) return;
const body = root.querySelector('.lb-wizard-body');
form.force_password_change = !!body.querySelector('[name="force_password_change"]')?.checked;
form.mfa_required = !!body.querySelector('[name="mfa_required"]')?.checked;
form.notifications_enabled = !!body.querySelector('[name="notifications_enabled"]')?.checked;
form.api_access = !!body.querySelector('[name="api_access"]')?.checked;
const old = root.querySelector('.lw-summary');
if (old) old.outerHTML = summaryHtml();
});
});
root.querySelector('[data-wz-another]')?.addEventListener('click', () => {
resetForm(defaultRole);
syncDom();
});
root.querySelector('[data-wz-goto-mgmt]')?.addEventListener('click', () => {
close();
if (typeof setView === 'function') {
const ds = window.getDeskState?.();
if (ds) ds.matrixTab = 'access-control';
setView('access-matrix');
}
});
root.querySelector('[data-wz-view-user]')?.addEventListener('click', () => {
const email = result?.user?.username || result?.user?.email;
close();
if (email && window.DeskAccessControlHub?.selectUser) {
window.DeskAccessControlHub.selectUser(email);
if (typeof onDone === 'function') onDone(result);
}
});
root.querySelector('[data-wz-copy-invite]')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(result?.invite_link || '');
window.alert('Link copiado');
} catch (_) {
window.prompt('Copiar link:', result?.invite_link);
}
});
}
function openWizard(opts = {}) {
defaultRole = opts.defaultRole || 'technician';
onDone = opts.onDone || null;
resetForm(defaultRole);
open = true;
syncDom();
}
function close() {
open = false;
syncDom();
}
window.DeskUserWizard = { open: openWizard, close };
})();

View file

@ -7,11 +7,13 @@
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/assets/styles.css?v=20260626prod1"/>
<link rel="stylesheet" href="/assets/desk-modern.css?v=20260626prod1"/>
<link rel="stylesheet" href="/assets/tickets-workspace.css?v=20260619tickets2"/>
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260625pollfix"/>
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260620am7"/>
<link rel="stylesheet" href="/assets/styles.css?v=20260629infra2"/>
<link rel="stylesheet" href="/assets/desk-modern.css?v=20260629acs5"/>
<link rel="stylesheet" href="/assets/tickets-workspace.css?v=20260628kpi2"/>
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260702spec30"/>
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260629qfx5"/>
<link rel="stylesheet" href="/assets/access-control-support.css?v=20260629um2"/>
<link rel="stylesheet" href="/assets/ligbox-ds.css?v=20260630wizard3"/>
</head>
<body>
<svg width="0" height="0" style="position:absolute;visibility:hidden" aria-hidden="true" focusable="false">
@ -229,9 +231,8 @@
<div class="nav-zone__links">
<button type="button" data-view="overview" data-module="overview" id="nav-overview" data-desk-nav class="nav-link">Overview</button>
<button type="button" data-view="account" data-module="core" id="nav-account" data-desk-nav class="nav-link">Minha conta</button>
<button type="button" data-view="messages" data-module="messages" id="nav-messages" hidden data-desk-nav class="nav-link">Mensagens</button>
<button type="button" data-view="admin" data-module="admin-users" id="nav-admin" hidden data-desk-nav class="nav-link">Administradores</button>
<button type="button" data-view="access-matrix" data-module="access-matrix" id="nav-access-matrix" hidden data-desk-nav class="nav-link">Matriz</button>
<button type="button" data-view="messages" data-module="messages" id="nav-messages" hidden data-desk-nav class="nav-link">Central Operacional</button>
<button type="button" data-view="access-matrix" data-module="access-matrix" id="nav-access-matrix" hidden data-desk-nav class="nav-link">Matriz de Acesso</button>
<button type="button" data-view="modules" data-module="modules-admin" id="nav-modules" hidden data-desk-nav class="nav-link">Módulos</button>
</div>
</div>
@ -252,7 +253,7 @@
<p class="context-sidebar__title" id="context-sidebar-title"></p>
</div>
<nav class="context-nav" id="context-nav" aria-label="Navegação do processo"></nav>
<div class="context-sidebar__foot">VM122 · ligbox-ops · v0.12.2</div>
<div class="context-sidebar__foot">VM122 · ligbox-ops · v0.13.0</div>
</aside>
<div class="workspace-main">
@ -287,6 +288,7 @@
</section>
<section id="view-tickets" class="view">
<div id="tickets-rwd-root"></div>
<div class="toolbar">
<button type="button" class="filter-btn active" data-filter="all">Todos</button>
<button type="button" class="filter-btn" data-filter="active">Activos</button>
@ -365,7 +367,20 @@
</div>
</div>
<div id="sidebar-user" hidden aria-hidden="true"></div>
<div id="team-drawer" class="team-drawer hidden" aria-hidden="true">
<div id="ticket-drawer" class="ticket-drawer hidden" aria-hidden="true">
<div class="ticket-drawer-backdrop" data-close-ticket-drawer tabindex="-1"></div>
<div class="ticket-drawer-panel" role="dialog" aria-modal="true" aria-labelledby="ticket-drawer-title">
<header class="ticket-drawer-header">
<div>
<p class="ticket-drawer-eyebrow">Assistência ASM · Spec 010</p>
<h2 id="ticket-drawer-title">Ticket</h2>
</div>
<button type="button" class="btn btn-ghost btn-sm" data-close-ticket-drawer aria-label="Fechar">Fechar ✕</button>
</header>
<div id="ticket-drawer-body" class="ticket-drawer-body"></div>
</div>
</div>
<div id="team-drawer" class="team-drawer acs-drawer hidden" aria-hidden="true">
<div class="team-drawer-backdrop" data-close-team-drawer></div>
<aside class="team-drawer-panel" role="dialog" aria-modal="true" aria-labelledby="team-drawer-title">
<div class="team-drawer-header">
@ -464,16 +479,24 @@
</div>
<script src="/assets/auth.js?v=20260626prod1"></script>
<script src="/assets/modules.js?v=20260619tickets2"></script>
<script src="/assets/modules.js?v=20260626prod2"></script>
<script src="/assets/billing-ui.js?v=20260619tickets2"></script>
<script src="/assets/desk-live-stub.js?v=20260619tickets2"></script>
<script src="/assets/tickets-workspace.js?v=20260619tickets2"></script>
<script src="/assets/tickets-detail-panel.js?v=20260619tickets2"></script>
<script src="/assets/servicos.js?v=20260626prod1"></script>
<script src="/assets/agentic-ops.js?v=20260625pollfix"></script>
<script src="/assets/access-matrix.js?v=20260620am7"></script>
<script src="/assets/tickets-sla.js?v=20260628fix1"></script>
<script src="/assets/tickets-workspace.js?v=20260628kpi2"></script>
<script src="/assets/tickets-detail-panel.js?v=20260628fix1"></script>
<script src="/assets/servicos.js?v=20260630serv1"></script>
<script src="/assets/agentic-ops.js?v=20260702spec30"></script>
<script src="/assets/access-matrix.js?v=20260629qfx5"></script>
<script src="/assets/dns-viewer.js?v=20260625handoff1"></script>
<script src="/assets/topnav.js?v=20260626prod1"></script>
<script src="/assets/app.js?v=20260626prod1"></script>
<script src="/assets/topnav.js?v=20260625mo1"></script>
<script src="/assets/infra-stack-meta.js?v=20260630relay1"></script>
<script src="/assets/app.staging.js?v=20260630relay1"></script>
<script src="/assets/executive-map-panel.js?v=20260629qfx5"></script>
<script src="/assets/user-management-panel.js?v=20260629v013"></script>
<script src="/assets/user-wizard.js?v=20260630wizard3"></script>
<script src="/assets/access-control-hub.js?v=20260630wizard2"></script>
<script src="/assets/access-control-panel.js?v=20260629v013"></script>
<script src="/assets/operational-feed.js?v=20260629v013"></script>
</body>
</html>

View file

@ -11,6 +11,7 @@
<link rel="stylesheet" href="/assets/desk-modern.css?v=20260626staging1"/>
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260623iconagentes"/>
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260623matriz"/>
<link rel="stylesheet" href="/assets/ligbox-ds.css?v=20260630wizard1"/>
</head>
<body>
<svg width="0" height="0" style="position:absolute;visibility:hidden" aria-hidden="true" focusable="false">
@ -228,7 +229,7 @@
<div class="nav-zone__links">
<button type="button" data-view="overview" data-module="overview" id="nav-overview" data-desk-nav class="nav-link">Overview</button>
<button type="button" data-view="account" data-module="core" id="nav-account" data-desk-nav class="nav-link">Minha conta</button>
<button type="button" data-view="messages" data-module="messages" id="nav-messages" hidden data-desk-nav class="nav-link">Mensagens</button>
<button type="button" data-view="messages" data-module="messages" id="nav-messages" hidden data-desk-nav class="nav-link">Central Operacional</button>
<button type="button" data-view="admin" data-module="admin-users" id="nav-admin" hidden data-desk-nav class="nav-link">Administradores</button>
<button type="button" data-view="access-matrix" data-module="access-matrix" id="nav-access-matrix" hidden data-desk-nav class="nav-link">Matriz</button>
<button type="button" data-view="modules" data-module="modules-admin" id="nav-modules" hidden data-desk-nav class="nav-link">Módulos</button>
@ -251,7 +252,7 @@
<p class="context-sidebar__title" id="context-sidebar-title"></p>
</div>
<nav class="context-nav" id="context-nav" aria-label="Navegação do processo"></nav>
<div class="context-sidebar__foot">VM122 staging · v0.12.2-staging</div>
<div class="context-sidebar__foot">VM122 staging · v0.13.0 · Spec 040/041</div>
</aside>
<div class="workspace-main">
@ -433,6 +434,12 @@
<script src="/assets/agentic-ops.js?v=20260623iconagentes"></script>
<script src="/assets/access-matrix.js?v=20260623matriz"></script>
<script src="/assets/topnav.js?v=20260626staging1"></script>
<script src="/assets/app.staging.js?v=20260626staging1"></script>
<script src="/assets/app.staging.js?v=20260629v013"></script>
<script src="/assets/user-wizard.js?v=20260630wizard1"></script>
<script src="/assets/access-control-hub.js?v=20260629v013"></script>
<script src="/assets/access-control-panel.js?v=20260629v013"></script>
<script src="/assets/user-management-panel.js?v=20260629v013"></script>
<script src="/assets/operational-feed.js?v=20260629v013"></script>
<script src="/assets/infra-stack-meta.js?v=20260629v013"></script>
</body>
</html>

View file

@ -9,9 +9,9 @@
| Campo | Valor |
|-------|-------|
| **Status** | `idle` |
| **Última actualização** | 2026-07-01 |
| **Agente / sessão** | |
| **Status** | `done` (Spec 030 em produção; scripts promote actualizados) |
| **Última actualização** | 2026-07-02 |
| **Agente / sessão** | Fix deploy VM122 + promote script |
| **Prioridade** | — |
---
@ -24,7 +24,11 @@ Agentic Ops UI (Mission Board)
## Feito (última sessão)
- _Nada nesta sessão._
- [x] Spec 030 `agentic-ops.js` / `.css` promovidos VM122 produção (1288 / 1313 linhas)
- [x] Cache bust `?v=20260702spec30` no `index.html`
- [x] `promote-staging-to-prod.sh` — inclui Agentic + verificação linhas
- [x] `deploy-staging-only.sh` — mesmo pacote Agentic para staging
- [x] `deploy/desk-staging-modern/README.md` — regra Desk + Agentic no mesmo deploy
---
@ -44,8 +48,17 @@ Agentic Ops UI (Mission Board)
## Smoke tests
```bash
# Definir quando spec entrar em implementação
# Ver VMs em docs/vms/ ou spec.md
# Produção — Spec 030 presente
ssh root@10.10.10.122 \
'docker exec ligbox-ops-platform_frontend_1 wc -l /usr/share/nginx/html/assets/agentic-ops.js'
# Esperado: ~1288
curl -sk https://desk.ligbox.com.br/assets/agentic-ops.js | grep -c "Squad Command"
# Esperado: 1
# Promote completo (VM130 spec-hub)
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform/deploy/desk-staging-modern
./promote-staging-to-prod.sh
```
---

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,420 @@
# Spec 039 — Catálogo Mestre de Autorização Ligbox OPS
**Criado:** 2026-06-29
**Solicitado por:** Roger
**Status:** Aprovado Roger 2026-06-29 — catálogo completo (TODAS superfícies)
**Prioridade:** P0 (governança — pré-requisito da UI Matriz + Controle de acesso)
**Estende:** Spec **003** (RBAC base), **027** (matriz por função), **015** (módulos Desk), **019** (Console)
**Sistemas:** VM122 Desk · VM123 Console/Finance · VM112 Onboard · VM104 Wazuh · Infra (Proxmox, Traefik, pfSense)
---
## 1. Problema (demanda Roger)
Hoje sabemos que **root / `super_admin`** faz tudo (criar, autorizar, gerir, trocar senha), mas **não está documentado nem exposto na UI** o mapa completo de:
- O que **cada função** (`SU`, `CO`, `TEC`, `NOC`, …) pode fazer
- **Porquê** (regra de negócio / risco)
- **Onde** (Desk, Console, FOSS, Odoo, OpenPanel, VM112, APIs internas)
- **A nível de ação** (não só “módulo ON/OFF”): ex. deletar instância OpenPanel, atualizar processo, purge domínio, aprovar runbook A7
Sem este catálogo, a **Matriz de Acesso** e o **Controle de acesso** mostram toggles e módulos, mas **não fecham o mapa** de atribuições por posição.
**Objetivo desta spec:** ser a **fonte única de verdade** para enumerar operações × funções × ambientes, alimentar a Matriz UI (Spec 027-UI) e implementação RBAC (`permissions.py`, bindings Odoo-style, provisionamento VM123).
---
## 2. Relação com specs existentes
| Documento | Nível | O que falta para Matriz completa |
|-----------|-------|----------------------------------|
| [Spec 027](../027-desk-rbac-function-matrix/spec.md) | Função × módulo × ambiente (✅🔒🔗) | Granularidade por **ação** dentro de cada módulo/produto |
| [Spec 027 UI](../027-desk-rbac-function-matrix/ui-access-matrix.md) | Wireframes Matriz | Dados estruturados (YAML) por ação |
| [vm123-product-roles](../027-desk-rbac-function-matrix/contracts/vm123-product-roles.md) | FOSS/Odoo/OpenPanel grupos | Lista exaustiva endpoints FOSS + OpenPanel CE |
| `platform_role_catalog.py` | Bindings por função | Sincronizar com catálogo 039 |
| `permissions.py` | ~30 helpers `can_*` | Um helper por `action_id` do catálogo |
| Controle de acesso (UI) | Toggles informativos | Persistência + ligação a `action_id` |
**Regra:** Spec 039 **não substitui** 027 — **detalha** cada célula da matriz 027 em linhas de ação auditáveis.
---
## 3. Taxonomia de autorização
### 3.1 Camadas
```text
FUNÇÃO (desk_role) ex.: super_admin, ops_lead → código UI: SU, CO, TEC
└── MÓDULO / PRODUTO ex.: admin-users, foss.client, openpanel.site
└── AÇÃO ex.: user.create, domain.purge, instance.delete
└── RECURSO ex.: desk_user, vm112_domain, openpanel_vhost
```
### 3.2 Níveis de efeito (`access_level`)
| Código | Significado | UI Matriz |
|--------|-------------|-----------|
| `full` | CRUD / executar sem aprovação | ✅ |
| `approve` | Executar após aprovação humana | 🟡 |
| `read` | Só leitura (pode mascarar PII) | 🔒 |
| `link` | Deep-link / abrir consola externa | 🔗 |
| `api` | Só via API Desk (M2M ou token) | ⚙️ |
| `system` | Conta sistema / agente | 🤖 |
| `none` | Proibido | ❌ |
### 3.3 Superfícies (`surface`)
| ID | Descrição | Host |
|----|-----------|------|
| `desk` | Ligbox Ops Desk (VM122 UI + API) | desk.ligbox.com.br |
| `console` | Ligbox Ops Console (Spec 019) | console.ligbox.com.br |
| `vm112` | Wizard + Carbonio + API onboard | onboard.ligbox.com.br |
| `vm123_foss` | FOSSBilling Admin API | financeiro.ligbox.com.br |
| `vm123_odoo` | Odoo 16 XML-RPC | financeiro.ligbox.com.br/odoo |
| `vm123_openpanel` | OpenPanel / OpenAdmin / bridge | openpanel.ligbox.com.br |
| `infra` | Proxmox, Traefik, pfSense, SSH hosts | LAN |
| `agents` | Agentes A0A7 | VM122 agentic |
### 3.4 Identificador de ação (`action_id`)
Formato: `{surface}.{domain}.{verb}` — exemplos:
- `desk.auth.user.create`
- `desk.auth.user.freeze`
- `vm112.domain.purge`
- `vm123_openpanel.instance.delete`
- `vm123_foss.invoice.void`
- `console.case.runbook.execute`
---
## 4. Catálogo de funções (códigos UI)
| Código | `desk_role` | Categoria | Mandato resumido |
|--------|-------------|-----------|------------------|
| **RO** | `root` (utilizador) | Sistema | Conta física dono — bypass humano; não é role atribuível |
| **SU** | `super_admin` | Ops | Tudo no Desk + purge + users + módulos + agentes |
| **CO** | `ops_lead` | Ops | Operação diária, audit, domínios, tickets, aprovações ops |
| **TEC** | `technician` | Ops | Tickets atribuídos, assist/takeover, migração e-mail |
| **NOC** | `noc` | Ops | Monitorização read-only, Wazuh, dados mascarados |
| **SAD** | `sales_admin` | Comercial | Pipeline, billing validation, FOSS/Odoo manager |
| **SSU** | `sales_support` | Comercial | CRM, pedidos, clientes — sem validar billing |
| **FIN** | `finance` | Negócio | FOSS/Odoo fiscal, faturas, inadimplência |
| **MKT** | `marketing` | Negócio | Campanhas, produtos FOSS, leads |
| **SEO** | `seo` | Negócio | DNS, sites OpenPanel, performance |
| **DEV** | `developer` | Plataforma | Código, GitHub, APIs, deploy Desk |
| **DVO** | `devops` | Plataforma | Infra, Proxmox, OpenAdmin, SSH |
| **SOC** | `security_analyst` | Plataforma | Incidentes, Wazuh rules, resposta |
| **CMS** | `content_editor` | Plataforma | Sites clientes OpenPanel |
| **AIO** | `agentic_operator` | Plataforma | Aprovar runbooks A7, findings |
| **SVC** | `api_service` | Sistema | M2M webhooks, provisionamento |
| **AGT** | `agent_system` | Sistema | Agentes IA autónomos |
| **PTR** | `partner` | Comercial | Revendedor — clientes próprios, OpenPanel Reseller, FOSS scoped |
### 4.1 Funções RBAC custom (UI)
Funções criadas via `POST /rbac/roles` **MUST** herdar de um único template:
| Template permitido | `desk_role` base | Uso |
|--------------------|------------------|-----|
| **CO** | `ops_lead` | Coordenação, aprovações ops, purge (sem gestão users) |
| **TEC** | `technician` | Suporte restrito — tickets assigned, assist |
**Proibido:** herdar de SU, FIN, DVO ou escolha livre de permissões avulsas no MVP.
---
## 5. Inventário de ações — Controle de identidade (Desk VM122)
### 5.1 Gestão de utilizadores (`desk.auth.*`)
| action_id | Descrição | SU | CO | Demais | API / UI |
|-----------|-----------|:--:|:--:|:------:|----------|
| `desk.auth.user.list` | Listar utilizadores Desk | full | none | none | GET `/v1/auth/users` |
| `desk.auth.user.create` | Criar utilizador (directo) | full | none | none | *gap — hoje só via registo+aprovação* |
| `desk.auth.user.approve_registration` | Aprovar pedido cadastro | full | **full** | none | POST `/v1/auth/registration-requests/{id}/approve` |
| `desk.auth.user.reject_registration` | Rejeitar pedido | full | **full** | none | POST `.../reject` |
| `desk.auth.user.edit` | Editar nome, role, display | full | none | none | PATCH `/v1/auth/users/{username}` |
| `desk.auth.user.freeze` | Desactivar conta (`active=false`) | full | none | **SSU: none** | PATCH `active` |
| `desk.auth.user.password.reset` | Definir nova senha (admin) | full | none | none | PATCH `password` |
| `desk.auth.user.2fa.reset` | Reset TOTP + backup codes | full | none | none | POST `.../reset-2fa` |
| `desk.auth.user.delete` | Remover utilizador | none | none | none | *não implementado — usar freeze* |
| `desk.auth.user.groups.assign` | Ligar grupos/bindings VM123 | full | none | none | Fase 3 `vm123/identity` |
| `desk.auth.role.create` | Criar função custom RBAC | full | none | none | POST `/rbac/roles` |
| `desk.auth.role.clone` | Clonar função | full | none | none | POST `/rbac/roles/{id}/clone` |
| `desk.auth.role.freeze` | Pausar função | full | none | none | PATCH `/rbac/roles/{id}/status` |
| `desk.auth.modules.toggle` | Activar/desactivar módulos Desk | full | none | none | PATCH `/v1/modules/{id}` |
**Porquê:** credenciais humanas Ligbox = superfície de ataque máxima. **Excepção Roger:** CO aprova cadastros; freeze/reset senha permanece **SU only**; **SSU nunca congela**.
### 5.2 Conta própria (`desk.account.*`)
| action_id | Descrição | Quem |
|-----------|-----------|------|
| `desk.account.password.change` | Trocar própria senha + 2FA | Qualquer autenticado |
| `desk.account.2fa.setup` | Configurar TOTP | Qualquer autenticado |
| `desk.account.console_handoff` | Token para Console | Staff com acesso Console |
---
## 6. Inventário — Módulos Desk (por `module_id`)
Cada módulo (Spec 015 `registry.py`) decompõe-se em **acções UI + API**. Resumo por módulo:
### 6.1 `core` — Dashboard, Tickets, Conta
| action_id | Descrição | SU | CO | TEC | NOC | SAD | SSU |
|-----------|-----------|:--:|:--:|:---:|:---:|:---:|:---:|
| `desk.ticket.list` | Ver tickets | full | full | full | read* | full | full |
| `desk.ticket.read` | Detalhe ticket | full | full | full | read* | full | full |
| `desk.ticket.patch` | Actualizar estado/notas | full | full | assigned | none | full | full |
| `desk.ticket.assign` | Atribuir responsável | full | full | self | none | full | full |
| `desk.ticket.close` | Fechar ticket | full | full | assigned | none | full | full |
| `desk.dashboard.kpi` | KPIs resumo | full | full | full | read | full | full |
\* NOC: dados sensíveis mascarados (Spec 003).
### 6.2 `overview-home` — Serviços IaaS (Spec 018)
| action_id | Descrição | SU | CO | TEC | DVO | FIN |
|-----------|-----------|:--:|:--:|:---:|:---:|:---:|
| `desk.services.client.list` | Listar clientes/domínios | full | full | full | read | read |
| `desk.services.catalog.read` | Catálogo cPanel/serviços | full | full | full | read | read |
| `desk.services.process.update` | Actualizar processo onboard | full | full | read | api | none |
| `desk.purge.auth.generate` | Gerar código purge (root pwd) | full | none | none | none | none |
### 6.3 `vm112-domains` + purge (Spec 017, 032)
| action_id | Descrição | SU | CO | TEC | DVO |
|-----------|-----------|:--:|:--:|:---:|:---:|
| `vm112.domain.list` | Listar domínios orquestrados | full | full | read | read |
| `vm112.domain.read` | Detalhe domínio | full | full | read | read |
| `vm112.domain.purge` | Purge completo domínio | full | full | none | approve |
| `vm112.purge.job.recover` | Recuperar job purge falhado | full | full | none | full |
### 6.4 `assist` — Wizard takeover (Spec 010)
| action_id | Descrição | SU | CO | TEC |
|-----------|-----------|:--:|:--:|:---:|
| `desk.assist.session.list` | Sessões activas wizard | full | full | full |
| `desk.assist.takeover` | Assumir sessão cliente | full | full | assigned |
| `desk.assist.handoff` | Passar sessão | full | full | full |
| `desk.assist.action.execute` | Acções assist aprovadas | full | full | assigned |
### 6.5 `billing-recurrence` (Spec 023)
| action_id | Descrição | SU | CO | FIN | SAD | SSU |
|-----------|-----------|:--:|:--:|:---:|:---:|:---:|
| `desk.billing.account.read` | Ficha conta cliente | full | full | full | full | read |
| `desk.billing.state.validate` | Validar billing_state | full | full | full | full | none |
| `desk.billing.link.foss` | Deep-link FOSS | full | full | full | full | link |
### 6.6 `email-migration` (Spec 013, 019)
| action_id | Descrição | SU | CO | TEC | DVO |
|-----------|-----------|:--:|:--:|:---:|:---:|
| `desk.migration.job.create` | Criar job imapsync | full | full | full | read |
| `desk.migration.job.cancel` | Cancelar job | full | full | assigned | none |
| `desk.migration.dns.gate` | Gate DNS pré-migração | full | full | full | read |
### 6.7 `infra` / `infra2-soc` (Spec 033)
| action_id | Descrição | SU | CO | DVO | SOC | DEV |
|-----------|-----------|:--:|:--:|:---:|:---:|:---:|
| `desk.infra.stack.read` | Stack health VMs | full | full | full | full | read |
| `desk.infra.stack.probe` | Executar probe serviço | full | full | full | read | api |
| `desk.infra.deploy` | Deploy API/frontend | full | none | full | none | full |
### 6.8 `agentic-ops` (Spec 029, 030)
| action_id | Descrição | SU | CO | AIO | SOC | DEV |
|-----------|-----------|:--:|:--:|:---:|:---:|:---:|
| `desk.agent.finding.read` | Ver findings | full | full | full | full | full |
| `desk.agent.runbook.approve` | Aprovar remediação A7 | full | full | full | scope | none |
| `desk.agent.binding.toggle` | UI/Focus/Approve agente×função | full | none | none | none | none |
### 6.9 `events`, `leads`, `audit`, `dns`
Ver matriz Spec 027 §3.2 — cada endpoint `main.py` mapeia para `action_id` (anexo A em `contracts/action-catalog.yaml`).
---
## 7. Inventário — VM123 produtos (granular)
### 7.1 FOSSBilling (`vm123_foss.*`)
Módulos API: `client`, `order`, `invoice`, `product`, `service`, `staff`, `support`, `extension`, hosting→OpenPanel.
| action_id | sales_admin | sales_support | finance | marketing | developer |
|-----------|:-----------:|:-------------:|:-------:|:---------:|:---------:|
| `vm123_foss.client.create` | full | full | full | none | api |
| `vm123_foss.client.delete` | full | none | full | none | none |
| `vm123_foss.order.create` | full | full | read | none | api |
| `vm123_foss.invoice.create` | full | read | full | none | none |
| `vm123_foss.invoice.void` | full | none | full | none | none |
| `vm123_foss.product.edit` | full | read | read | full | api |
| `vm123_foss.staff.manage` | none | none | read | none | api |
| `vm123_foss.hosting.provision` | full | full | read | none | api |
Detalhe endpoints: [vm123-product-roles.md §2](../027-desk-rbac-function-matrix/contracts/vm123-product-roles.md).
### 7.2 Odoo 16 (`vm123_odoo.*`)
| action_id | sales_admin | sales_support | finance |
|-----------|:-----------:|:-------------:|:-------:|
| `vm123_odoo.partner.read` | full | full | full |
| `vm123_odoo.partner.write` | full | full | full |
| `vm123_odoo.sale.order.create` | full | full | read |
| `vm123_odoo.invoice.post` | full | none | full |
| `vm123_odoo.account.move.validate` | none | none | full |
Grupos: `group_sale_manager`, `group_sale_salesman`, `account.group_account_manager`.
### 7.3 OpenPanel (`vm123_openpanel.*`)
| action_id | Descrição | SU | **CO** | SAD | SSU | SEO | CMS | DVO |
|-----------|-----------|:--:|:--:|:---:|:---:|:---:|:---:|:---:|
| `vm123_openpanel.site.create` | Novo site/vhost | full | full | full | full | full | full | full |
| `vm123_openpanel.site.delete` | **Remover instância** | full | **full** | none | none | none | none | **none** |
| `vm123_openpanel.ssl.manage` | Certificados LE | full | full | link | link | full | full | full |
| `vm123_openpanel.db.create` | Base de dados | full | full | link | link | full | full | full |
| `vm123_openpanel.cron.manage` | Cron jobs | full | full | none | none | read | full | full |
| `vm123_openpanel.backup.restore` | Restaurar backup | full | full | none | none | none | read | full |
| `vm123_openpanel.openadmin.access` | Painel OpenAdmin :2087 | full | full | link | none | link | none | full |
| `vm123_openpanel.autologin.client` | Login cliente (bridge) | full | full | full | full | full | full | link |
**Gap actual:** Desk não expõe toggles por `action_id` OpenPanel — só deep-links e provisionamento M2M.
---
## 8. Inventário — Console (Spec 019)
**Decisão Roger:** Console na **mesma Matriz** do Desk (superfície `console`, aba dedicada, RBAC herda role Desk via handoff).
| action_id | Descrição | SU | CO | TEC | SOC | AIO |
|-----------|-----------|:--:|:--:|:---:|:---:|:---:|
| `console.case.create` | Abrir chamado CH-* | full | full | full | full | read |
| `console.case.assign` | Assumir chamado | full | full | full | full | none |
| `console.case.timeline.read` | Timeline correlacionada | full | full | full | full | full |
| `console.discover.search` | Discover (estilo Wazuh) | full | full | read | full | read |
| `console.runbook.execute` | Executar runbook | full | full | none | scope | approve |
| `console.wizard.assist.view` | Passo actual wizard | full | full | full | read | read |
| `console.link.wazuh` | Deep-link VM104 | full | full | read | full | read |
Console autentica via **handoff** Desk (`/v1/auth/console-handoff`) — RBAC herda role Desk.
---
## 9. Inventário — Partner / Revendedor (`partner` · PTR)
| action_id | Descrição | PTR | Notas |
|-----------|-----------|:---:|-------|
| `desk.partner.dashboard.read` | Dashboard revendedor (clientes próprios) | full | Módulo `partner-portal` |
| `vm123_foss.client.create` | Criar cliente FOSS (scoped) | full | Só tenants do partner |
| `vm123_foss.order.create` | Pedidos FOSS | full | |
| `vm123_foss.invoice.read` | Ver faturas clientes | read | Sem void |
| `vm123_openpanel.site.create` | Provisionar hosting cliente | full | Via bridge |
| `vm123_openpanel.autologin.client` | Painel cliente | full | |
| `vm123_openpanel.site.delete` | Deletar instância | none | Só SU/CO Ligbox |
| `desk.auth.*` | Qualquer gestão Desk | none | |
| `console.*` | Console ops | none | |
**OpenPanel nativo:** role `reseller` (Spec 028). **FOSS:** grupo `ligbox-partner` (criar no Admin).
---
## 10. Inventário — VM112 Wizard
| action_id | Descrição | SU | CO | TEC | DEV |
|-----------|-----------|:--:|:--:|:---:|:---:|
| `vm112.wizard.session.read` | Estado sessão onboard | full | full | assigned | read |
| `vm112.carbonio.mailbox.create` | Criar caixa mail | api | api | api | api |
| `vm112.carbonio.block.release` | Libertar ACCOUNT_EXISTS | full | full | none | api |
| `vm112.dns.verify` | Verificar DNS wizard | full | full | read | read |
| `vm112.api.webhook.emit` | Emitir evento (sistema) | system | system | system | system |
---
## 11. Matriz resumida — Quem faz o quê (visão Roger)
| Área | Quem manda | Porquê |
|------|------------|--------|
| Criar/editar/freeze utilizadores Desk | **SU** | Credenciais internas |
| **Aprovar cadastros** | **SU**, **CO** | Roger — CO autónomo |
| **Congelar users** | **SU only****SSU nunca** | Segregação comercial |
| Purge domínio / dados cliente | **SU**, **CO** (DVO recover job) | Irreversível — Spec 032 |
| Validar billing / faturação | **SU**, **CO**, **FIN**, **SAD** | Segregação comercial vs financeira |
| FOSS pedidos e clientes | **SAD**, **SSU**, **PTR** (scoped) | Linha de frente |
| FOSS faturas / void | **FIN**, **SU** | Risco fiscal |
| **OpenPanel delete instance** | **SU**, **CO only** | Downtime — Roger 2026-06-29 |
| OpenPanel conteúdo sites | **CMS**, **SEO**, **MKT** | Operação editorial |
| Tickets / assist | **TEC** (assigned), **CO**, **SU** | Menor privilégio |
| Infra / deploy | **DVO**, **DEV**, **SU** | Separação código vs infra |
| Agentes A7 remediação | **AIO**, **CO**, **SU** | Human-in-the-loop |
| Módulos Desk ON/OFF | **SU** | Feature flags globais |
| **Console** | Mesma matriz Desk | Handoff — Roger |
| **RBAC custom** | Herda **CO** ou **TEC** | Templates only |
---
## 12. Implementação — fechar o mapa na UI
### Fase A — Catálogo (esta spec) ✅
- [x] Taxonomia `action_id`
- [x] Inventário por superfície (Desk + VM123 + Console + VM112 + Partner)
- [x] Decisões Roger validadas (§13)
### Fase B — `contracts/action-catalog.yaml` ✅ v1.1
- Lista machine-readable: `action_id`, `label`, `surface`, `roles{}`, `api_route`, `spec_ref`
- Gerar toggles Controle de acesso + export CSV Matriz
### Fase C — API RBAC
- `GET /rbac/actions` — catálogo completo
- `PATCH /rbac/roles/{id}/actions` — persistir overrides (só SU)
- `permissions.py`: `can_action(role, action_id)` único entry point
### Fase D — UI
- Aba **Controle de acesso**: toggles ligados a YAML (não só informativos)
- Abas Matriz: filtrar por `surface` (Desk | FOSS | Odoo | OpenPanel | Console)
- Sidebar função: mostrar **contagem de acções** full/read/none
---
## 13. Decisões Roger (2026-06-29) — FECHADAS
| # | Pergunta | Decisão |
|---|----------|---------|
| 1 | `sales_support` congela utilizadores? | **Não** — só SU |
| 2 | `ops_lead` aprova cadastros sem SU? | **Sim** — CO autónomo |
| 3 | Deletar instância OpenPanel | **SU + CO only** (não DVO) |
| 4 | Console na mesma Matriz? | **Sim** — mesma matriz Desk |
| 5 | RBAC custom herda de? | **CO ou TEC only** |
| 6 | Partner/revendedor | **Nesta spec** — função `partner` (PTR) |
| 7 | Prioridade granularidade | **TODOS** — Desk + FOSS + Odoo + OpenPanel + Console |
**Implementação código (parcial):** `can_approve_registration`, `can_openpanel_delete` → SU+CO; rotas registo actualizadas.
---
## 14. Critérios de aceite
- **FR-039-001**: Cada `action_id` MUST ter exactamente uma linha no catálogo YAML.
- **FR-039-002**: Cada função humana MUST ter coluna em todas as tabelas §59.
- **FR-039-003**: UI Controle de acesso MUST reflectir catálogo (não lista hardcoded).
- **FR-039-004**: Alteração de permissão MUST gerar audit log (`rbac_audit`).
- **FR-039-005**: RO/root MUST NOT aparecer como role atribuível — só nota §4.
- **FR-039-006**: Função `partner` MUST estar no catálogo com scope tenant isolado.
- **FR-039-007**: RBAC custom MUST declarar `inherits_from: ops_lead | technician`.
---
## Anexos
- `contracts/action-catalog.yaml` — inventário estruturado (geração automática)
- `../027-desk-rbac-function-matrix/spec.md` — matriz macro
- `../../projects/ops-desk/api/app/platform_role_catalog.py` — bindings código
- `../../projects/ops-desk/api/app/permissions.py` — guards actuais

View file

@ -0,0 +1,125 @@
# Contrato API — Governance (Spec 040)
**Base URL:** `https://desk.ligbox.com.br/api/v1/governance`
**Auth:** `Authorization: Bearer {jwt}`
**Permissão:** `can_manage_users` (role `super_admin`)
---
## GET /modules
Lista módulos de access capabilities, níveis e grupos.
**Response 200:**
```json
{
"modules": ["desk", "openpanel", "billing", "api", "security", "ai_agents"],
"levels": ["none", "read", "partial", "full"],
"groups": ["Comercial", "Externo", "Negócio", "Ops", "Plataforma"]
}
```
---
## GET /users/stats
**Response 200:**
```json
{
"total": 8,
"active": 6,
"frozen": 2,
"super_admin": 1
}
```
---
## GET /users/{username}/meta
**Response 200:**
```json
{
"meta": {
"username": "user@empresa.com",
"internal_id": "LB-A1B2C3D4",
"main_group": "Ops",
"secondary_groups": [],
"account_status": "active",
"module_permissions": { "desk": "partial" },
"invite_token": null
}
}
```
---
## POST /users/wizard
**Request body:**
```json
{
"display_name": "Ana Silva",
"email": "ana@empresa.com",
"phone": "+351900000000",
"password": "senha-inicial",
"account_status": "active",
"force_password_change": true,
"mfa_required": false,
"notifications_enabled": true,
"api_access": false,
"role": "technician",
"main_group": "Ops",
"secondary_groups": ["Comercial"],
"module_permissions": {
"desk": "partial",
"openpanel": "read",
"billing": "none",
"api": "none",
"security": "none",
"ai_agents": "none"
},
"notes": "Observações opcionais",
"send_invite_email": true,
"activate_account": true
}
```
**Response 201:**
```json
{
"user": { "username": "ana@empresa.com", "role": "technician", "active": true },
"meta": { "internal_id": "LB-…" },
"audit": { "action": "user.created", "summary": "Created by Roger" },
"internal_id": "LB-…",
"invite_link": "https://desk.ligbox.com.br/register.html?invite=…",
"message": "Utilizador criado"
}
```
---
## POST /users/{username}/freeze
Toggle activo/congelado + audit.
**Response 200:** `{ "user": {…}, "audit": {…} }`
---
## POST /users/{username}/reset-password
**Response 200:** `{ "ok": true, "generated_password": "…", "audit": {…} }`
---
## GET /audit
**Query:** `target_type`, `target_id`, `limit` (max 200)
**Response 200:** `{ "events": [ { "action", "summary", "actor_username", "created_at" } ] }`

View file

@ -0,0 +1,217 @@
# Spec 040 — Desk Design System v0.13 (UI Operacional)
**Criado:** 2026-06-29
**Solicitado por:** Roger
**Status:** ✅ UI Access Control Hub **aprovado Roger** 2026-06-25 · `0.13.1-ac-hub-ui-aprovado-roger`
**Prioridade:** P0
**Versão Desk:** `0.13.1-ac-hub-ui-aprovado-roger`
**Rollback DS v0.13:** `projects/ops-desk/frontend/staging-snapshot/v0.12.2-pre-ds-20260625/`
**Rollback UI aprovado:** `projects/ops-desk/frontend/staging-snapshot/v0.13.1-ac-hub-ui-aprovado-20260625/`
**Aprovação Obsidian:** [[APROVADO-UI-ROGER-20260625]]
**Estende:** Spec **027** (Matriz RBAC), **039** (catálogo autorização), **015** (módulos Desk)
**Não inclui:** Central Operacional / Operational Feed — ver **[Spec 041](../041-desk-operational-feed/spec.md)**
---
## 1. Problema
O Desk v0.12.2 tinha:
- Modal simples para criar utilizador (1 ecrã)
- Controle de acesso em split compacto (tabela inline)
- Aba «Mensagens» = apenas pedidos de cadastro
Roger definiu **Design System enterprise** (UI principles, visual language, navigation, action patterns, user wizard) e mockups para:
1. **UserWizard** — criar utilizador (5 passos, fullscreen)
2. **Access Control Hub** — gestão completa na aba Controle de acesso (Matriz)
3. Vocabulário: *Operational feed*, *Access capabilities*, *Desactivar conta*
A Spec **041** cobre isoladamente a aba Central Operacional (ex-Mensagens).
---
## 2. Escopo desta spec (040)
| Incluído | Excluído (→ 041) |
|----------|------------------|
| UserWizard fullscreen | Feed omnicanal |
| Access Control Hub | `/api/v1/ops-inbox/*` |
| Governance API | Canais WhatsApp, email inbound |
| Audit log utilizadores | KPIs inbox |
| Design tokens CSS (`ligbox-ds.css`) | |
---
## 3. Arquitetura UI
```text
Matriz de Acesso (#view-access-matrix)
└── Aba «Controle de acesso» (tab access-control)
└── DeskAccessControlHub (DS-FE-004)
├── Sub-aba «Gestão de utilizadores»
│ ├── StatsCard row (KPIs)
│ ├── Entity cards (lista utilizadores — NÃO tabela primária)
│ ├── Right detail panel (Detalhes | Access capabilities | Actividades)
│ └── + Criar utilizador → DeskUserWizard (DS-FE-003)
└── Sub-aba «Capacidades da função»
└── DeskAccessControlPanel.paintCapabilitiesOnly (Spec 027/039)
DeskUserWizard (DS-FE-003) — fullscreen modal
├── Step 1 Basic data
├── Step 2 Profile and group
├── Step 3 Access capabilities (module matrix)
├── Step 4 Review
└── Step 5 Success (+ audit + invite link)
```
---
## 4. Registo de ficheiros (referência futura)
Código interno: **`DS-{tipo}-{nnn}`** — usar em commits, PRs e tickets.
### 4.1 Frontend — Design System
| Código | Ficheiro | Global JS | Função |
|--------|----------|-----------|--------|
| **DS-FE-001** | `frontend/assets/ligbox-ds.css` | — | Tokens CSS (`--lb-*`), cards, wizard, modals, layout ops |
| **DS-FE-002** | `frontend/assets/ligbox-ds.css` | — | Cache bust: `?v=20260629v013` |
### 4.2 Frontend — Módulos
| Código | Ficheiro | Global JS | Função |
|--------|----------|-----------|--------|
| **DS-FE-003** | `frontend/assets/user-wizard.js` | `window.DeskUserWizard` | Wizard fullscreen criar utilizador (≥3 passos) |
| **DS-FE-004** | `frontend/assets/access-control-hub.js` | `window.DeskAccessControlHub` | Hub img 4 — cards, KPIs, painel direito |
| **DS-FE-005** | `frontend/assets/access-control-panel.js` | `window.DeskAccessControlPanel` | Delega hub; sub-aba capacidades (Spec 039) |
| **DS-FE-006** | `frontend/assets/user-management-panel.js` | `window.DeskUserManagement` | Edit modal, clone; `openEdit()` para hub |
### 4.3 Frontend — Integração
| Código | Ficheiro | Alteração |
|--------|----------|-----------|
| **DS-FE-010** | `frontend/index.html` | Scripts DS-FE-003/004/005/006, `ligbox-ds.css`, nav «Central Operacional», footer v0.13.0 |
| **DS-FE-011** | `frontend/assets/app.staging.js` | Títulos Spec 040/041; `renderMessages()` delega Spec 041 |
| **DS-FE-012** | `frontend/assets/access-matrix.js` | Host `#am-access-control-host``DeskAccessControlPanel.paint` |
### 4.4 API — Backend
| Código | Ficheiro | Router prefix | Função |
|--------|----------|---------------|--------|
| **DS-API-001** | `api/app/desk_governance_store.py` | — | Schema SQLite: audit, user_meta, module permissions |
| **DS-API-002** | `api/app/governance_routes.py` | `/api/v1/governance` | Wizard, freeze, reset password, audit, stats |
| **DS-API-003** | `api/app/main.py` | — | Registo routers; init schema; version `0.13.0-design-system` |
### 4.5 Versionamento e rollback
| Código | Ficheiro | Função |
|--------|----------|--------|
| **DS-META-001** | `projects/ops-desk/VERSION` | `0.13.0-design-system` |
| **DS-META-002** | `frontend/staging-snapshot/v0.12.2-pre-ds-20260625/` | Snapshot pré-build + `ROLLBACK.md` |
### 4.6 Skills (Cursor — design system)
| Código | Ficheiro | Função |
|--------|----------|--------|
| **DS-SK-001** | `~/.cursor/skills/ligbox/product.skill.md` | Identidade produto |
| **DS-SK-002** | `~/.cursor/skills/ligbox/ux.skill.md` | UX + visual + navigation |
| **DS-SK-003** | `~/.cursor/skills/ligbox/permissions.skill.md` | Hierarquia RBAC UI |
| **DS-SK-004** | `~/.cursor/skills/ligbox/onboarding.skill.md` | User creation flow |
| **DS-SK-005** | `~/.cursor/skills/ligbox/messaging.skill.md` | → aponta Spec 041 |
| **DS-SK-006** | `~/.cursor/skills/ligbox/audit.skill.md` | Audit log pattern |
---
## 5. API Governance (resumo)
Contrato completo: [contracts/governance-api.md](contracts/governance-api.md)
| Método | Endpoint | Código acção | Descrição |
|--------|----------|--------------|-----------|
| GET | `/api/v1/governance/modules` | `desk.governance.modules.list` | Módulos + níveis + grupos |
| GET | `/api/v1/governance/users/stats` | `desk.governance.users.stats` | KPIs gestão utilizadores |
| GET | `/api/v1/governance/users/{username}/meta` | `desk.governance.user.meta` | Meta + internal_id + permissions |
| POST | `/api/v1/governance/users/wizard` | `desk.auth.user.create` | Criar via wizard (audit + invite) |
| POST | `/api/v1/governance/users/{username}/freeze` | `desk.auth.user.freeze` | Congelar/activar + audit |
| POST | `/api/v1/governance/users/{username}/reset-password` | `desk.auth.user.password.reset` | Reset admin + audit |
| GET | `/api/v1/governance/audit` | `desk.governance.audit.list` | Audit por target |
**Auth:** Bearer JWT · `can_manage_users` (super_admin).
---
## 6. Modelo de dados (SQLite)
### `desk_governance_audit`
| Coluna | Tipo | Notas |
|--------|------|-------|
| actor_username | TEXT | Quem executou |
| action | TEXT | ex. `user.created`, `user.frozen` |
| target_type | TEXT | ex. `user` |
| target_id | TEXT | username |
| summary | TEXT | «Created by Super Admin» |
### `desk_user_meta`
| Coluna | Tipo | Notas |
|--------|------|-------|
| internal_id | TEXT | `LB-XXXXXXXX` |
| main_group | TEXT | Ops, Comercial, … |
| secondary_groups_json | TEXT | JSON array |
| module_permissions_json | TEXT | Desk/OpenPanel/… × none/read/partial/full |
| invite_token | TEXT | Link convite |
---
## 7. User Wizard — fluxo
Ver skill **DS-SK-004** e mockups Roger (imgs 12).
Passos UI: Basic data → Profile/group → Access capabilities → Review → Success.
Submit `POST /api/v1/governance/users/wizard` executa:
1. INSERT `desk_users`
2. INSERT `desk_user_meta`
3. INSERT `desk_governance_audit` (`user.created`)
4. E-mail convite (opcional)
5. Resposta: `user`, `meta`, `internal_id`, `invite_link`
---
## 8. Regras UX (obrigatórias)
- **Create:** sempre wizard, mínimo 3 passos, fullscreen
- **Edit:** side panel / modal médio (`DeskUserManagement.openEdit`)
- **Desactivar conta:** small modal + audit (nunca «Delete» na UI)
- **Cards > tables** para lista primária de utilizadores
- Paleta: `#F6F3EE` / `#5B1632` / cards `#FFFFFF`
---
## 9. Relação com outras specs
| Spec | Relação |
|------|---------|
| 027 | Matriz RBAC; aba Controle de acesso dentro da Matriz |
| 039 | Catálogo acções; toggles capacidades da função |
| 041 | Central Operacional (aba ex-Mensagens) — **spec separada** |
| 004 | Pedidos cadastro — `renderRegistrationRequestsLegacy()` preservado |
---
## 10. Deploy
```bash
# VM122 produção
docker cp → ligbox-ops-platform_frontend_1:/usr/share/nginx/html/
docker cp → ligbox-ops-platform_api_1:/app/app/
docker restart ligbox-ops-platform_api_1
```
Validação:
- `GET https://desk.ligbox.com.br/api/health``"version":"0.13.0-design-system"`
- Matriz → Controle de acesso → + Criar utilizador (wizard)

View file

@ -0,0 +1,12 @@
# Spec 040 — Tasks
- [x] T001 Snapshot v0.12.2-pre-ds-20260625
- [x] T002 DS-API-001 desk_governance_store.py
- [x] T003 DS-API-002 governance_routes.py
- [x] T004 DS-FE-001 ligbox-ds.css
- [x] T005 DS-FE-003 user-wizard.js
- [x] T006 DS-FE-004 access-control-hub.js
- [x] T007 Deploy VM122 v0.13.0
- [ ] T008 RBAC action_ids governance no catálogo 039
- [ ] T009 Testes API governance (pytest)
- [ ] T010 Link pedidos cadastro no Access Control Hub

View file

@ -0,0 +1,164 @@
# Contrato API — Operational Feed (Spec 041)
**Base URL:** `https://desk.ligbox.com.br/api/v1/ops-inbox`
**Auth:** `Authorization: Bearer {jwt}`
**Permissão (fase A):** `can_manage_users`
**Implementação:** `OF-API-002` · `api/app/ops_inbox_routes.py`
**Store:** `OF-API-001` · `api/app/ops_inbox_store.py`
---
## OF-EP-001 — GET /stats
KPIs e contadores por canal.
**Response 200:**
```json
{
"events_today": 6,
"pending": 6,
"critical": 1,
"awaiting_you": 5,
"sla_avg_pct": 96,
"channels": [
{ "id": "all", "label": "Todos os canais", "count": 6 },
{ "id": "whatsapp", "label": "WhatsApp API", "count": 1 },
{ "id": "email", "label": "Email", "count": 1 }
]
}
```
---
## OF-EP-002 — GET /events
**Query parameters:**
| Param | Tipo | Default | Descrição |
|-------|------|---------|-----------|
| channel | string | `all` | Filtrar canal |
| priority | string | — | `normal`, `high`, `critical` |
| status | string | — | `open`, `pending`, `resolved` |
| q | string | — | Busca title/preview/contact |
| limit | int | 128 | Max 500 |
**Response 200:**
```json
{
"events": [
{
"id": "evt-wa-001",
"channel": "whatsapp",
"event_type": "message",
"priority": "high",
"title": "Cliente: Empresa Alpha — …",
"preview": "Bom dia, após o login…",
"tags": ["Cliente", "Acesso"],
"assignee": "Editor",
"status": "open",
"contact_name": "Empresa Alpha",
"contact_company": "Empresa Alpha Ltda",
"contact_cnpj": "12.345.678/0001-90",
"contact_client_id": "CLI-8842",
"sla_minutes": 15,
"sla_remaining_sec": 750,
"created_at": "2026-06-29T…"
}
],
"total": 1
}
```
---
## OF-EP-003 — GET /events/{event_id}
**Response 200:**
```json
{
"event": {
"id": "evt-wa-001",
"channel": "whatsapp",
"messages": [
{
"id": 1,
"author_type": "user",
"author_label": "Empresa Alpha",
"body": "Bom dia, não consigo acessar…",
"created_at": "…"
}
]
}
}
```
**404:** event not found
---
## OF-EP-004 — POST /events/{event_id}/messages
**Request:**
```json
{
"body": "Texto da resposta ou nota",
"note_type": "reply"
}
```
`note_type`: `reply` | `internal_note`
**Response 200:**
```json
{
"message": {
"id": 4,
"event_id": "evt-wa-001",
"author_type": "operator",
"author_label": "Roger",
"body": "…",
"created_at": "…"
}
}
```
---
## OF-EP-005 — PATCH /events/{event_id}
**Request (campos opcionais):**
```json
{
"status": "resolved",
"assignee": "NOC",
"priority": "high"
}
```
**Response 200:** `{ "event": { … } }`
---
## Códigos de erro
| HTTP | Condição |
|------|----------|
| 401 | Token inválido |
| 403 | Sem permissão |
| 404 | Evento não encontrado |
---
## Webhooks (fase B — não implementados)
| Código planeado | Método | Path |
|-----------------|--------|------|
| OF-EP-101 | POST | `/api/v1/ops-inbox/webhooks/email` |
| OF-EP-102 | POST | `/api/v1/ops-inbox/webhooks/whatsapp` |
| OF-EP-103 | POST | `/api/v1/ops-inbox/webhooks/telegram` |

View file

@ -0,0 +1,13 @@
# Spec 041 — Tasks
- [x] T001 OF-API-001 ops_inbox_store.py + seed
- [x] T002 OF-API-002 ops_inbox_routes.py
- [x] T003 OF-FE-001 operational-feed.js
- [x] T004 Substituir renderMessages() → DeskOperationalFeed
- [x] T005 Nav «Central Operacional»
- [x] T006 Deploy VM122
- [ ] T007 OF-EP-101 webhook email inbound
- [ ] T008 OF-EP-102 WhatsApp API
- [ ] T009 RBAC dedicado (não só super_admin)
- [ ] T010 SLA real (calcular vs mock 96%)
- [ ] T011 Unificar agent threads → ops_inbox