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:
parent
0e1ce31dfa
commit
b03bb2c37c
58 changed files with 15253 additions and 1113 deletions
53
.cursor/rules/desk-deploy-release.mdc
Normal file
53
.cursor/rules/desk-deploy-release.mdc
Normal 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.
|
||||||
81
deploy/desk-staging-modern/README.md
Normal file
81
deploy/desk-staging-modern/README.md
Normal 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/06–01/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/`
|
||||||
111
deploy/desk-staging-modern/deploy-desk-full.sh
Executable file
111
deploy/desk-staging-modern/deploy-desk-full.sh
Executable 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)"
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
# Desk modern UI — deploy APENAS staging (desk-staging.ligbox.com.br)
|
# 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)
|
# 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]
|
# Uso: ./deploy-staging-only.sh [VM122_HOST]
|
||||||
# Spec / processo: docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md
|
# 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"
|
CONTAINER="ligbox-ops-platform-staging_frontend-staging_1"
|
||||||
ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)"
|
ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)"
|
||||||
HTML="${ROOT}/assets"
|
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)"
|
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 \
|
sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
|
||||||
"${ROOT}/index.staging.html" \
|
"${ROOT}/index.staging.html" \
|
||||||
"${HTML}/topnav.js" \
|
"${HTML}/topnav.js" \
|
||||||
|
|
@ -24,23 +30,36 @@ sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
|
||||||
"${HTML}/styles.css" \
|
"${HTML}/styles.css" \
|
||||||
"${HTML}/servicos.js" \
|
"${HTML}/servicos.js" \
|
||||||
"${HTML}/auth.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
|
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s <<EOF
|
||||||
set -e
|
set -e
|
||||||
C=${CONTAINER}
|
C=${CONTAINER}
|
||||||
docker cp /tmp/desk-staging-deploy/index.staging.html \$C:/usr/share/nginx/html/index.html
|
docker cp ${REMOTE}/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 ${REMOTE}/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 ${REMOTE}/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 ${REMOTE}/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 ${REMOTE}/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 ${REMOTE}/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}/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 ==="
|
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/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/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
|
EOF
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Promove Desk modern UI (staging validado) → produção desk.ligbox.com.br
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -9,11 +16,13 @@ PASS="${DESK_SSH_PASS:-805353}"
|
||||||
CONTAINER="ligbox-ops-platform_frontend_1"
|
CONTAINER="ligbox-ops-platform_frontend_1"
|
||||||
ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)"
|
ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)"
|
||||||
HTML="${ROOT}/assets"
|
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}"
|
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 \
|
sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
|
||||||
"${ROOT}/index.html" \
|
"${ROOT}/index.html" \
|
||||||
|
|
@ -23,24 +32,51 @@ sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \
|
||||||
"${HTML}/styles.css" \
|
"${HTML}/styles.css" \
|
||||||
"${HTML}/servicos.js" \
|
"${HTML}/servicos.js" \
|
||||||
"${HTML}/auth.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
|
sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s <<EOF
|
||||||
set -e
|
set -e
|
||||||
C=${CONTAINER}
|
C=${CONTAINER}
|
||||||
docker cp /tmp/desk-prod-deploy/index.html \$C:/usr/share/nginx/html/index.html
|
docker cp ${REMOTE}/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 ${REMOTE}/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 ${REMOTE}/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 ${REMOTE}/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 ${REMOTE}/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 ${REMOTE}/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}/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 ==="
|
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/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
|
EOF
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Produção actualizada: https://desk.ligbox.com.br/?desk=1"
|
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"
|
||||||
|
|
|
||||||
87
deploy/desk-staging-modern/smoke-desk.sh
Executable file
87
deploy/desk-staging-modern/smoke-desk.sh
Executable 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
|
||||||
23
deploy/vm112-wizard/deploy-wizard-vm112-smoke.sh
Executable file
23
deploy/vm112-wizard/deploy-wizard-vm112-smoke.sh
Executable 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"
|
||||||
52
deploy/vm112-wizard/ensure-carbonio-list-domains.py
Normal file
52
deploy/vm112-wizard/ensure-carbonio-list-domains.py
Normal 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()
|
||||||
|
|
@ -127,8 +127,29 @@ Ou rebuild: `docker-compose -f docker-compose.agentic-staging.yml build frontend
|
||||||
| Backup rollback | `index.legacy.html` · `app.legacy.js` |
|
| Backup rollback | `index.legacy.html` · `app.legacy.js` |
|
||||||
| Script promote | `deploy/desk-staging-modern/promote-staging-to-prod.sh` |
|
| Script promote | `deploy/desk-staging-modern/promote-staging-to-prod.sh` |
|
||||||
| Script rollback | `deploy/desk-staging-modern/rollback-prod-to-legacy.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.
|
**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).
|
**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/06–01/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` só `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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,16 @@ ligbox-ops-platform/
|
||||||
|
|
||||||
## Mapa rápido
|
## Mapa rápido
|
||||||
|
|
||||||
| VM/CT | IP | SSH WAN | Papel | Deploy no repo |
|
| VM/CT | IP | SSH WAN | Senha SSH root | Papel | Deploy no repo |
|
||||||
|-------|-----|---------|-------|----------------|
|
|-------|-----|---------|----------------|-------|----------------|
|
||||||
| **112** | 10.10.10.112 | :2512 | Wizard onboard + Carbonio mail | `projects/wizard/` |
|
| **112** | 10.10.10.112 | :2512 | **`@betinplace`** | Wizard onboard + Carbonio mail | `deploy/vm112-wizard/` |
|
||||||
| **122** | 10.10.10.122 | :2522 | Ops Desk API + worker + UI MVP | `projects/ops-desk/` |
|
| **122** | 10.10.10.122 | :2522 | `805353` | Ops Desk API + worker + UI MVP | `projects/ops-desk/` |
|
||||||
| **123** | 10.10.10.123 | :2523 | FOSSBilling + Odoo + OpenPanel + Console UI | `projects/finance/` |
|
| **123** | 10.10.10.123 | :2523 | `805353` | FOSSBilling + Odoo + OpenPanel + Console UI | `projects/finance/` |
|
||||||
| **104** | 10.10.10.104 | :2504 | Wazuh SIEM | integração Spec 002, 019 |
|
| **104** | 10.10.10.104 | :2504 | `805353` | Wazuh SIEM | integração Spec 002, 019 |
|
||||||
| **114** | 10.10.10.114 | — | Traefik (CT) | `docs/network/TRAEFIK_*` |
|
| **114** | 10.10.10.114 | — | — | Traefik (CT) | `docs/network/TRAEFIK_*` |
|
||||||
| **116** | 10.10.10.116 | :2516 | RustDesk relay + portal remoto | — |
|
| **116** | 10.10.10.116 | :2516 | `805353` | RustDesk relay + portal remoto | — |
|
||||||
| **124** | 10.10.10.124 | :2524 | **Nextcloud Hub** — storage mail (Spec 034) | `deploy/vm116-nextcloud/` |
|
| **124** | 10.10.10.124 | :2524 | `805353` | **Nextcloud Hub** — storage mail (Spec 034) | `deploy/vm116-nextcloud/` |
|
||||||
| **130** | 10.10.10.130 | :2530 | **Spec Hub** Git + Obsidian + Portal | CT130 local |
|
| **130** | 10.10.10.130 | :2530 | `805353` | **Spec Hub** Git + Obsidian + Portal | CT130 local |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| **IP LAN** | `10.10.10.112` |
|
| **IP LAN** | `10.10.10.112` |
|
||||||
| **SSH WAN** | `95.216.14.146:2512` |
|
| **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 |
|
| **Hostname** | vm112-mail-ibytera |
|
||||||
| **URLs** | `onboard.ligbox.com.br` · API `:8090` |
|
| **URLs** | `onboard.ligbox.com.br` · API `:8090` |
|
||||||
|
|
||||||
|
|
|
||||||
364
projects/ops-desk/api/app/action_catalog.py
Normal file
364
projects/ops-desk/api/app/action_catalog.py
Normal 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"
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
@ -41,6 +43,22 @@ class UserUpdateRequest(BaseModel):
|
||||||
display_name: str | None = None
|
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):
|
class ChangePasswordRequest(BaseModel):
|
||||||
current_password: str = Field(min_length=1)
|
current_password: str = Field(min_length=1)
|
||||||
new_password: str = Field(min_length=8)
|
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}
|
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}")
|
@router.patch("/users/{username}")
|
||||||
def update_user(
|
def update_user(
|
||||||
username: str,
|
username: str,
|
||||||
|
|
@ -193,9 +284,7 @@ def update_user(
|
||||||
):
|
):
|
||||||
if not can_manage_users(user.role):
|
if not can_manage_users(user.role):
|
||||||
raise HTTPException(403, "insufficient permissions")
|
raise HTTPException(403, "insufficient permissions")
|
||||||
target = username.strip()
|
target = _normalize_username(username)
|
||||||
if target.lower() != "root":
|
|
||||||
target = target.lower()
|
|
||||||
updates: list[str] = []
|
updates: list[str] = []
|
||||||
params: list[object] = []
|
params: list[object] = []
|
||||||
if body.role is not None:
|
if body.role is not None:
|
||||||
|
|
@ -226,20 +315,87 @@ def update_user(
|
||||||
conn.commit()
|
conn.commit()
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise HTTPException(404, "user not found")
|
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(
|
row = conn.execute(
|
||||||
"""
|
"SELECT username FROM desk_users WHERE username = ?",
|
||||||
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 = ?
|
|
||||||
""",
|
|
||||||
(target,),
|
(target,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
item = auth.user_public_dict(row)
|
if not row:
|
||||||
item["totp_enabled"] = bool(row["totp_enabled"])
|
raise HTTPException(404, "user not found")
|
||||||
item["backup_codes_remaining"] = int(row["backup_codes_remaining"] or 0)
|
conn.execute("DELETE FROM desk_backup_codes WHERE username = ?", (target,))
|
||||||
return {"user": item}
|
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")
|
@router.post("/users/{username}/reset-2fa")
|
||||||
|
|
@ -249,9 +405,7 @@ def reset_user_2fa(
|
||||||
):
|
):
|
||||||
if not can_manage_users(user.role):
|
if not can_manage_users(user.role):
|
||||||
raise HTTPException(403, "insufficient permissions")
|
raise HTTPException(403, "insufficient permissions")
|
||||||
target = username.strip()
|
target = _normalize_username(username)
|
||||||
if target.lower() != "root":
|
|
||||||
target = target.lower()
|
|
||||||
if target == "root":
|
if target == "root":
|
||||||
raise HTTPException(400, "não é possível resetar 2FA do root por aqui")
|
raise HTTPException(400, "não é possível resetar 2FA do root por aqui")
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
|
||||||
263
projects/ops-desk/api/app/desk_governance_store.py
Normal file
263
projects/ops-desk/api/app/desk_governance_store.py
Normal 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 {}
|
||||||
123
projects/ops-desk/api/app/email_relay.py
Normal file
123
projects/ops-desk/api/app/email_relay.py
Normal 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(),
|
||||||
|
}
|
||||||
45
projects/ops-desk/api/app/email_relay_routes.py
Normal file
45
projects/ops-desk/api/app/email_relay_routes.py
Normal 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)
|
||||||
336
projects/ops-desk/api/app/governance_routes.py
Normal file
336
projects/ops-desk/api/app/governance_routes.py
Normal 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}
|
||||||
|
|
@ -28,9 +28,12 @@ from app.migration.router import router as migration_router
|
||||||
from app.billing_routes import router as billing_router
|
from app.billing_routes import router as billing_router
|
||||||
from app.security_routes import router as security_router
|
from app.security_routes import router as security_router
|
||||||
from app.infra_stack_routes import router as infra_stack_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.vm123.routes import router as vm123_router
|
||||||
from app.agents.routes import router as agents_router
|
from app.agents.routes import router as agents_router
|
||||||
from app.rbac_routes import router as rbac_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_sandbox_routes import router as domain_console_sandbox_router
|
||||||
from app.domain_console_routes import router as domain_console_router
|
from app.domain_console_routes import router as domain_console_router
|
||||||
from app.agents.store import init_agent_schema
|
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()]
|
_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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=_cors_origins or ["*"],
|
allow_origins=_cors_origins or ["*"],
|
||||||
|
|
@ -152,9 +155,12 @@ app.include_router(carbonio_release_router)
|
||||||
app.include_router(migration_router)
|
app.include_router(migration_router)
|
||||||
app.include_router(billing_router)
|
app.include_router(billing_router)
|
||||||
app.include_router(infra_stack_router)
|
app.include_router(infra_stack_router)
|
||||||
|
app.include_router(email_relay_router)
|
||||||
app.include_router(vm123_router)
|
app.include_router(vm123_router)
|
||||||
app.include_router(agents_router)
|
app.include_router(agents_router)
|
||||||
app.include_router(rbac_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_sandbox_router)
|
||||||
app.include_router(domain_console_router)
|
app.include_router(domain_console_router)
|
||||||
|
|
||||||
|
|
@ -213,6 +219,14 @@ def init_db():
|
||||||
from app import agent_bindings
|
from app import agent_bindings
|
||||||
|
|
||||||
agent_bindings.init_schema(conn)
|
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
|
from app.domain_console_sandbox_store import init_schema as init_dcs_schema
|
||||||
|
|
||||||
init_dcs_schema(conn)
|
init_dcs_schema(conn)
|
||||||
|
|
|
||||||
119
projects/ops-desk/api/app/ops_inbox_routes.py
Normal file
119
projects/ops-desk/api/app/ops_inbox_routes.py
Normal 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}
|
||||||
338
projects/ops-desk/api/app/ops_inbox_store.py
Normal file
338
projects/ops-desk/api/app/ops_inbox_store.py
Normal 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)
|
||||||
|
|
@ -19,6 +19,7 @@ BUSINESS_ROLES = frozenset(
|
||||||
"security_analyst",
|
"security_analyst",
|
||||||
"content_editor",
|
"content_editor",
|
||||||
"agentic_operator",
|
"agentic_operator",
|
||||||
|
"partner",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -51,6 +52,7 @@ ROLE_LABELS: dict[str, str] = {
|
||||||
"security_analyst": "Segurança / SOC",
|
"security_analyst": "Segurança / SOC",
|
||||||
"content_editor": "Conteúdo / CMS",
|
"content_editor": "Conteúdo / CMS",
|
||||||
"agentic_operator": "Operador Agentes IA",
|
"agentic_operator": "Operador Agentes IA",
|
||||||
|
"partner": "Partner",
|
||||||
"api_service": "API Service",
|
"api_service": "API Service",
|
||||||
"agent_system": "Agent System",
|
"agent_system": "Agent System",
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +232,11 @@ def can_manage_users(role: str) -> bool:
|
||||||
return role == "super_admin"
|
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:
|
def can_manage_vm112_domains(role: str) -> bool:
|
||||||
"""Admin Desk — domínios orquestrados VM112 (Spec 017)."""
|
"""Admin Desk — domínios orquestrados VM112 (Spec 017)."""
|
||||||
return role in ("super_admin", "ops_lead", "devops")
|
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:
|
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:
|
def roles_meta() -> dict:
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,8 @@ def matrix_export(conn) -> dict[str, Any]:
|
||||||
"tabs": [
|
"tabs": [
|
||||||
{"id": "overview", "label": "Visão da função", "type": "overview",
|
{"id": "overview", "label": "Visão da função", "type": "overview",
|
||||||
"hint": "Desk + software + APIs da função seleccionada"},
|
"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",
|
{"id": "vm122", "label": "Matriz Desk", "type": "desk_modules",
|
||||||
"hint": "VM122 — módulos internos da plataforma"},
|
"hint": "VM122 — módulos internos da plataforma"},
|
||||||
{"id": "software", "label": "Software & Infra", "type": "software",
|
{"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"},
|
"hint": "Bindings Odoo-style — grupos, roles, permissões"},
|
||||||
{"id": "agents", "label": "Agentes IA", "type": "agents",
|
{"id": "agents", "label": "Agentes IA", "type": "agents",
|
||||||
"hint": "A0–A7 — orquestração e aprovações"},
|
"hint": "A0–A7 — 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": [
|
"scope_layers": [
|
||||||
{"id": "vm122", "label": "Desk VM122", "host": "10.10.10.122", "desc": "Módulos internos"},
|
{"id": "vm122", "label": "Desk VM122", "host": "10.10.10.122", "desc": "Módulos internos"},
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import sqlite3
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
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
|
from app.rbac_matrix import matrix_export
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["rbac"])
|
router = APIRouter(prefix="/api/v1", tags=["rbac"])
|
||||||
|
|
@ -77,6 +77,45 @@ class GovernanceCapPatch(BaseModel):
|
||||||
enabled: bool
|
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")
|
@router.patch("/rbac/agent-bindings")
|
||||||
def patch_agent_binding(
|
def patch_agent_binding(
|
||||||
body: AgentBindingPatch,
|
body: AgentBindingPatch,
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app import auth, desk_tickets, mail_notify, registration_store
|
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 import ntfy_notify
|
||||||
from app.totp_util import otpauth_uri
|
from app.totp_util import otpauth_uri
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/auth", tags=["registration"])
|
router = APIRouter(prefix="/api/v1/auth", tags=["registration"])
|
||||||
|
|
||||||
ASSIGNABLE_ROLES = frozenset({"ops_lead", "technician", "noc"})
|
REGISTRATION_ASSIGNABLE_ROLES = ASSIGNABLE_ROLES
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
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")
|
@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:
|
with auth.db() as conn:
|
||||||
items = registration_store.list_requests(conn)
|
items = registration_store.list_requests(conn)
|
||||||
pending = sum(1 for i in items if i["status"] == "pending")
|
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(
|
def approve_registration(
|
||||||
request_id: int,
|
request_id: int,
|
||||||
body: ApproveRequest,
|
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:
|
if body.role not in REGISTRATION_ASSIGNABLE_ROLES:
|
||||||
raise HTTPException(400, f"role must be one of: {', '.join(sorted(ASSIGNABLE_ROLES))}")
|
raise HTTPException(
|
||||||
|
400, f"role must be one of: {', '.join(sorted(REGISTRATION_ASSIGNABLE_ROLES))}"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with auth.db() as conn:
|
with auth.db() as conn:
|
||||||
row = registration_store.approve_request(conn, request_id, body.role, user.username)
|
row = registration_store.approve_request(conn, request_id, body.role, user.username)
|
||||||
|
|
@ -107,7 +115,7 @@ def approve_registration(
|
||||||
def reject_registration(
|
def reject_registration(
|
||||||
request_id: int,
|
request_id: int,
|
||||||
body: RejectRequest,
|
body: RejectRequest,
|
||||||
user: auth.DeskUser = Depends(auth.require_roles("super_admin")),
|
user: auth.DeskUser = Depends(_require_registration_approver),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
with auth.db() as conn:
|
with auth.db() as conn:
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,35 @@ def _now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
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(
|
def _probe_http(
|
||||||
*,
|
*,
|
||||||
url: str,
|
url: str,
|
||||||
timeout: float = 8.0,
|
timeout: float = 8.0,
|
||||||
verify: bool = True,
|
verify: bool = True,
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
expect_status: tuple[int, ...] = (200),
|
expect_status: int | tuple[int, ...] | list[int] = (200,),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
|
codes = _normalize_expect_status(expect_status)
|
||||||
with httpx.Client(timeout=timeout, verify=verify, follow_redirects=True) as client:
|
with httpx.Client(timeout=timeout, verify=verify, follow_redirects=True) as client:
|
||||||
res = client.get(url, headers=headers or {})
|
res = client.get(url, headers=headers or {})
|
||||||
ok = res.status_code in expect_status
|
ok = res.status_code in codes
|
||||||
return {
|
return {
|
||||||
"ok": ok,
|
"ok": ok,
|
||||||
"status": "online" if ok else "check",
|
"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)}
|
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]]:
|
def build_stack_catalog() -> list[dict[str, Any]]:
|
||||||
"""Catálogo estático — apps, APIs e software do stack Ligbox."""
|
"""Catálogo estático — apps, APIs e software do stack Ligbox."""
|
||||||
redis_url = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
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",
|
"url": f"{VM112_API}/api/admin/domains",
|
||||||
"probe": lambda: _probe_http(
|
"probe": lambda: _probe_http(
|
||||||
url=f"{VM112_API}/api/onboarding/health",
|
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",
|
"url": "/api/v1/infra/purge-auth-domains",
|
||||||
"probe": lambda: {"ok": True, "status": "online", "detail": "módulo local"},
|
"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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
62
projects/ops-desk/api/tests/test_action_catalog_039.py
Normal file
62
projects/ops-desk/api/tests/test_action_catalog_039.py
Normal 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()
|
||||||
84
projects/ops-desk/api/tests/test_stack_health_033.py
Normal file
84
projects/ops-desk/api/tests/test_stack_health_033.py
Normal 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()
|
||||||
676
projects/ops-desk/frontend/assets/access-control-hub.js
Normal file
676
projects/ops-desk/frontend/assets/access-control-hub.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
169
projects/ops-desk/frontend/assets/access-control-panel.js
Normal file
169
projects/ops-desk/frontend/assets/access-control-panel.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.(),
|
||||||
|
};
|
||||||
|
})();
|
||||||
1185
projects/ops-desk/frontend/assets/access-control-support.css
Normal file
1185
projects/ops-desk/frontend/assets/access-control-support.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -48,6 +48,13 @@
|
||||||
border-bottom: 1px solid var(--am-border);
|
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 {
|
#access-matrix-content .am-page-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
|
|
@ -128,6 +135,8 @@
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
max-height: calc(100vh - 200px);
|
max-height: calc(100vh - 200px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: auto;
|
||||||
padding: 0.85rem;
|
padding: 0.85rem;
|
||||||
background: linear-gradient(180deg, #fffdf9 0%, #faf6f0 100%);
|
background: linear-gradient(180deg, #fffdf9 0%, #faf6f0 100%);
|
||||||
border: 1px solid var(--am-border);
|
border: 1px solid var(--am-border);
|
||||||
|
|
@ -148,15 +157,54 @@
|
||||||
font-weight: 600;
|
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 {
|
#access-matrix-content .am-role-btn {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 0 4px 4px 0;
|
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: inherit;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -953,3 +1001,818 @@
|
||||||
grid-template-columns: 1fr;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,199 @@
|
||||||
softwareFilter: 'all',
|
softwareFilter: 'all',
|
||||||
saving: false,
|
saving: false,
|
||||||
saveError: null,
|
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) {
|
async function patchApi(path, body) {
|
||||||
const r = await fetchWithTimeout(`/api/v1${path}`, {
|
const r = await fetchWithTimeout(`/api/v1${path}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|
@ -121,7 +312,7 @@
|
||||||
if (!groups[cat]) groups[cat] = [];
|
if (!groups[cat]) groups[cat] = [];
|
||||||
groups[cat].push(role);
|
groups[cat].push(role);
|
||||||
});
|
});
|
||||||
const order = ['ops', 'commercial', 'business', 'platform', 'system'];
|
const order = ['ops', 'commercial', 'business', 'platform', 'custom', 'system'];
|
||||||
return order
|
return order
|
||||||
.filter((c) => groups[c]?.length)
|
.filter((c) => groups[c]?.length)
|
||||||
.map((c) => ({ id: c, label: categoryLabels[c] || c, roles: groups[c] }));
|
.map((c) => ({ id: c, label: categoryLabels[c] || c, roles: groups[c] }));
|
||||||
|
|
@ -138,12 +329,20 @@
|
||||||
function renderRoleSidebar() {
|
function renderRoleSidebar() {
|
||||||
const groups = rolesByCategory(state.data.catalog, state.data.category_labels || {});
|
const groups = rolesByCategory(state.data.catalog, state.data.category_labels || {});
|
||||||
return groups.map((g) => `
|
return groups.map((g) => `
|
||||||
<div class="am-role-group">
|
<div class="am-role-group nav-zone">
|
||||||
<h4>${esc(g.label)}</h4>
|
<span class="nav-zone__label">${esc(g.label)}</span>
|
||||||
${g.roles.map((r) => `
|
<div class="nav-zone__links">
|
||||||
<button type="button" class="am-role-btn${state.selectedRole === r.id ? ' active' : ''}"
|
${g.roles.map((r) => {
|
||||||
data-am-role="${esc(r.id)}">${esc(r.label)}</button>
|
const st = r.status === 'frozen' ? ' <span class="am-role-paused">pausado</span>' : '';
|
||||||
`).join('')}
|
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('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,8 +392,43 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDeskMatrix() {
|
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 `
|
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')}`;
|
${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,9 +576,54 @@
|
||||||
function renderBindings() {
|
function renderBindings() {
|
||||||
const role = state.data.catalog.roles[state.selectedRole];
|
const role = state.data.catalog.roles[state.selectedRole];
|
||||||
if (!role) return '<p class="am-empty">Função não encontrada</p>';
|
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 `
|
return `
|
||||||
|
${editor}
|
||||||
<p class="am-section-lead">Bindings estilo Odoo — grupos, roles e permissões provisionados por função.</p>
|
<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) {
|
function roleLabel(roleId) {
|
||||||
|
|
@ -547,6 +826,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMainPanel() {
|
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];
|
const role = state.data.catalog.roles[state.selectedRole];
|
||||||
let body = '';
|
let body = '';
|
||||||
if (state.tab === 'overview') body = renderRoleOverview();
|
if (state.tab === 'overview') body = renderRoleOverview();
|
||||||
|
|
@ -554,6 +845,7 @@
|
||||||
else if (state.tab === 'software') body = renderSoftwareMatrix();
|
else if (state.tab === 'software') body = renderSoftwareMatrix();
|
||||||
else if (state.tab === 'bindings') body = renderBindings();
|
else if (state.tab === 'bindings') body = renderBindings();
|
||||||
else if (state.tab === 'agents') body = renderAgents();
|
else if (state.tab === 'agents') body = renderAgents();
|
||||||
|
else if (state.tab === 'audit') body = renderAudit();
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="am-panel">
|
<div class="am-panel">
|
||||||
|
|
@ -571,21 +863,30 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTabs() {
|
function renderTabs() {
|
||||||
return (state.data.tabs || []).map((t) =>
|
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>`
|
const sep = t.separated ? '<span class="am-tab-sep" aria-hidden="true"></span>' : '';
|
||||||
).join('');
|
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) {
|
function bindEvents(root) {
|
||||||
root.querySelectorAll('[data-am-role]').forEach((btn) => {
|
root.querySelectorAll('[data-am-role]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
state.selectedRole = btn.dataset.amRole;
|
state.selectedRole = btn.dataset.amRole;
|
||||||
|
state.moduleDraft = null;
|
||||||
|
state.bindingDraft = null;
|
||||||
|
window.DeskExecutiveMap?.resetFilters?.();
|
||||||
paint(root);
|
paint(root);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
root.querySelectorAll('[data-am-tab]').forEach((btn) => {
|
root.querySelectorAll('[data-am-tab]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
state.tab = btn.dataset.amTab;
|
state.tab = btn.dataset.amTab;
|
||||||
|
if (typeof window.getDeskState === 'function') {
|
||||||
|
const ds = window.getDeskState();
|
||||||
|
if (ds) ds.matrixTab = state.tab;
|
||||||
|
}
|
||||||
paint(root);
|
paint(root);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -609,18 +910,53 @@
|
||||||
toggleCap(capId, roleId);
|
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) {
|
function paint(root) {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="am-wrap">
|
<div class="am-wrap">
|
||||||
<div class="am-header">
|
<div class="am-header am-header--toolbar">
|
||||||
<div>
|
<span class="am-preview-tag">${state.data?.crud_enabled ? 'CRUD · audit ON' : state.data?.editable ? 'Edição · audit ON' : 'Consulta'}${state.saving ? ' · …' : ''}</span>
|
||||||
<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>
|
</div>
|
||||||
|
${renderToolbar()}
|
||||||
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
|
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
|
||||||
<nav class="am-subnav">${renderTabs()}</nav>
|
<nav class="am-subnav">${renderTabs()}</nav>
|
||||||
<div class="am-layout">
|
<div class="am-layout">
|
||||||
|
|
@ -629,17 +965,41 @@
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
bindEvents(root);
|
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() {
|
async function renderAccessMatrix() {
|
||||||
const root = document.getElementById('access-matrix-content');
|
const root = document.getElementById('access-matrix-content');
|
||||||
if (!root) return;
|
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 {
|
try {
|
||||||
state.data = await api('/rbac/matrix');
|
state.data = await api('/rbac/matrix');
|
||||||
if (!state.selectedRole && state.data.role_columns?.length) {
|
if (!state.selectedRole && state.data.role_columns?.length) {
|
||||||
state.selectedRole = state.data.role_columns[0];
|
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);
|
paint(root);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
root.innerHTML = `<p class="loading">Matriz indisponível: ${esc(e.message)}</p>`;
|
root.innerHTML = `<p class="loading">Matriz indisponível: ${esc(e.message)}</p>`;
|
||||||
|
|
@ -647,5 +1007,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
window.renderAccessMatrix = renderAccessMatrix;
|
window.renderAccessMatrix = renderAccessMatrix;
|
||||||
window.DeskAccessMatrix = { renderAccessMatrix };
|
window.DeskAccessMatrix = {
|
||||||
|
renderAccessMatrix,
|
||||||
|
getState: () => state,
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
function applyRoleNav() {
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
@ -374,7 +398,7 @@ function setView(name) {
|
||||||
document.getElementById('page-title').textContent = titles[name] || 'Ligbox Ops';
|
document.getElementById('page-title').textContent = titles[name] || 'Ligbox Ops';
|
||||||
const subEl = document.getElementById('page-subtitle');
|
const subEl = document.getElementById('page-subtitle');
|
||||||
if (subEl) subEl.textContent = subtitles[name] || subtitles.dashboard;
|
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);
|
b.classList.toggle('active', b.dataset.view === name);
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-desk-nav]').forEach((b) => {
|
document.querySelectorAll('[data-desk-nav]').forEach((b) => {
|
||||||
|
|
@ -3161,6 +3185,13 @@ async function renderAdmin() {
|
||||||
el.innerHTML = '<p class="loading">Sem permissão</p>';
|
el.innerHTML = '<p class="loading">Sem permissão</p>';
|
||||||
return;
|
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>';
|
el.innerHTML = '<p class="loading">Carregando equipe…</p>';
|
||||||
try {
|
try {
|
||||||
const [usersData, regData] = await Promise.all([
|
const [usersData, regData] = await Promise.all([
|
||||||
|
|
@ -3197,6 +3228,7 @@ async function renderAdmin() {
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
|
${adminTabsInContent()}
|
||||||
<div class="team-admin">
|
<div class="team-admin">
|
||||||
<header class="team-admin-head">
|
<header class="team-admin-head">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3262,6 +3294,7 @@ async function renderAdmin() {
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
bindAdminTabs();
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
state.adminFilter = {
|
state.adminFilter = {
|
||||||
q: document.getElementById('team-filter-q')?.value || '',
|
q: document.getElementById('team-filter-q')?.value || '',
|
||||||
|
|
|
||||||
|
|
@ -104,11 +104,11 @@ function resolveBootView() {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}, timeoutMs) {
|
||||||
const res = await fetchWithTimeout(`${API}${path}`, {
|
const res = await fetchWithTimeout(`${API}${path}`, {
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }),
|
headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }),
|
||||||
...options,
|
...options,
|
||||||
});
|
}, timeoutMs);
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
logout();
|
logout();
|
||||||
throw new Error('sessão expirada');
|
throw new Error('sessão expirada');
|
||||||
|
|
@ -169,6 +169,7 @@ let state = {
|
||||||
adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' },
|
adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' },
|
||||||
adminSelected: null,
|
adminSelected: null,
|
||||||
adminPanel: 'team',
|
adminPanel: 'team',
|
||||||
|
matrixTab: null,
|
||||||
features: { access_matrix_ui: false },
|
features: { access_matrix_ui: false },
|
||||||
socWindow: '24h',
|
socWindow: '24h',
|
||||||
socLastEventId: null,
|
socLastEventId: null,
|
||||||
|
|
@ -281,7 +282,6 @@ function applyRoleNav() {
|
||||||
}
|
}
|
||||||
if (canManageUsers()) {
|
if (canManageUsers()) {
|
||||||
document.getElementById('nav-messages')?.removeAttribute('hidden');
|
document.getElementById('nav-messages')?.removeAttribute('hidden');
|
||||||
document.getElementById('nav-admin')?.removeAttribute('hidden');
|
|
||||||
}
|
}
|
||||||
if (user.role === 'super_admin') {
|
if (user.role === 'super_admin') {
|
||||||
document.getElementById('nav-modules')?.removeAttribute('hidden');
|
document.getElementById('nav-modules')?.removeAttribute('hidden');
|
||||||
|
|
@ -330,26 +330,46 @@ function setView(name) {
|
||||||
if (name === 'access-matrix' && !state.features?.access_matrix_ui) {
|
if (name === 'access-matrix' && !state.features?.access_matrix_ui) {
|
||||||
name = resolveDefaultView();
|
name = resolveDefaultView();
|
||||||
} else if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
|
} else if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
|
||||||
|
if (!(name === 'admin' && typeof canManageUsers === 'function' && canManageUsers())) {
|
||||||
name = resolveDefaultView();
|
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') {
|
if (state.view === 'account' && name !== 'account') {
|
||||||
state.accountLoaded = false;
|
state.accountLoaded = false;
|
||||||
}
|
}
|
||||||
|
window.DeskUserWizard?.close?.();
|
||||||
|
document.body.classList.remove('um-scroll-lock');
|
||||||
state.view = name;
|
state.view = name;
|
||||||
if (name === 'admin') state.adminPanel = 'team';
|
|
||||||
const titles = {
|
const titles = {
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
overview: 'Audit Overview',
|
overview: 'Audit Overview',
|
||||||
'overview-home': 'Serviços IaaS',
|
'overview-home': 'Serviços IaaS',
|
||||||
tickets: 'Tickets',
|
tickets: 'Sessão Tickets',
|
||||||
events: 'Eventos webhook',
|
events: 'Eventos webhook',
|
||||||
tenants: 'Tenants',
|
tenants: 'Tenants',
|
||||||
infra: 'INFRA CODE',
|
infra: 'INFRA CODE',
|
||||||
infra2: 'SOC — Infra 2',
|
infra2: 'SOC — Infra 2',
|
||||||
'agentic-ops': 'Agentic Ops',
|
'agentic-ops': 'Agentic Ops',
|
||||||
messages: 'Mensagens — pedidos de cadastro',
|
messages: 'Central Operacional',
|
||||||
admin: 'Administradores',
|
admin: 'Controle de acesso — Spec 040',
|
||||||
'access-matrix': 'Matriz de Acessos',
|
'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',
|
account: 'Minha conta',
|
||||||
leads: 'Leads abandonados',
|
leads: 'Leads abandonados',
|
||||||
modules: 'Módulos',
|
modules: 'Módulos',
|
||||||
|
|
@ -364,9 +384,13 @@ function setView(name) {
|
||||||
infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130',
|
infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130',
|
||||||
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
|
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)',
|
'agentic-ops': 'Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)',
|
||||||
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
messages: 'Spec 041 · Operational feed — inbox unificada (mock até integrações)',
|
||||||
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
admin: 'Spec 040 · UserWizard + Access Control Hub · audit ON',
|
||||||
'access-matrix': 'Preview read-only — Spec 027 · funções × VM112/122/123 (estilo Odoo groups)',
|
'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',
|
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
leads: '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',
|
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?.updateTopnavActive?.(name);
|
||||||
window.DeskTopnav?.updateContextSidebar?.(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));
|
Object.entries(views).forEach(([k, el]) => el?.classList.toggle('active', k === name));
|
||||||
reschedulePoll();
|
reschedulePoll();
|
||||||
refresh();
|
refresh();
|
||||||
|
|
@ -412,10 +439,12 @@ window.DeskNavigate = {
|
||||||
|
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let socRenderInFlight = false;
|
let socRenderInFlight = false;
|
||||||
|
let infraRenderInFlight = false;
|
||||||
function reschedulePoll() {
|
function reschedulePoll() {
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
let ms = 30000;
|
let ms = 30000;
|
||||||
if (state.view === 'infra2') ms = 15000;
|
if (state.view === 'infra2') ms = 15000;
|
||||||
|
if (state.view === 'infra') ms = 60000;
|
||||||
// VM112 /admin/domains ~10–15s — poll menos agressivo (Spec 018 Serviços IaaS)
|
// VM112 /admin/domains ~10–15s — poll menos agressivo (Spec 018 Serviços IaaS)
|
||||||
if (state.view === 'overview-home') ms = 90000;
|
if (state.view === 'overview-home') ms = 90000;
|
||||||
pollTimer = setInterval(() => refresh({ poll: true }), ms);
|
pollTimer = setInterval(() => refresh({ poll: true }), ms);
|
||||||
|
|
@ -930,6 +959,7 @@ function bindAssistActions(container, sessionId) {
|
||||||
try {
|
try {
|
||||||
await runAssistAction(btn.dataset.assist, sessionId);
|
await runAssistAction(btn.dataset.assist, sessionId);
|
||||||
await renderTickets();
|
await renderTickets();
|
||||||
|
await refreshTicketDetailView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || 'Falha na ação de assistência');
|
alert(e.message || 'Falha na ação de assistência');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -945,6 +975,7 @@ function bindAssistActions(container, sessionId) {
|
||||||
try {
|
try {
|
||||||
await api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/actions/${encodeURIComponent(actionId)}`, { method: 'POST' });
|
await api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/actions/${encodeURIComponent(actionId)}`, { method: 'POST' });
|
||||||
await renderTickets();
|
await renderTickets();
|
||||||
|
await refreshTicketDetailView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || 'Falha na ação');
|
alert(e.message || 'Falha na ação');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -2038,6 +2069,7 @@ function buildOverviewHomeTrail(events, domainsFlat, filter, windowHours) {
|
||||||
async function renderOverviewHome(options = {}) {
|
async function renderOverviewHome(options = {}) {
|
||||||
const el = document.getElementById('overview-home-content');
|
const el = document.getElementById('overview-home-content');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
try {
|
||||||
if (window.DeskServices?.renderPage) {
|
if (window.DeskServices?.renderPage) {
|
||||||
await window.DeskServices.renderPage(el, options);
|
await window.DeskServices.renderPage(el, options);
|
||||||
return;
|
return;
|
||||||
|
|
@ -2047,6 +2079,11 @@ async function renderOverviewHome(options = {}) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.innerHTML = '<p class="loading">Módulo Serviços não carregado.</p>';
|
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 = {}) {
|
async function renderLeads(options = {}) {
|
||||||
|
|
@ -2189,13 +2226,26 @@ async function renderSessionDetail() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderTicketDetail() {
|
async function refreshTicketDetailView() {
|
||||||
const detailEl = document.getElementById('ticket-detail');
|
if (!state.selectedTicketId || !window.TicketsDetailPanel) return;
|
||||||
if (!state.selectedTicketId) return;
|
const body = document.getElementById('ticket-drawer-body');
|
||||||
if (window.TicketsDetailPanel) {
|
if (TicketsDetailPanel.isOpen?.() && body) {
|
||||||
await TicketsDetailPanel.render(state.selectedTicketId, detailEl);
|
await TicketsDetailPanel.render(state.selectedTicketId, body);
|
||||||
return;
|
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>';
|
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando…</p></div>';
|
||||||
try {
|
try {
|
||||||
const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`);
|
const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`);
|
||||||
|
|
@ -2282,6 +2332,7 @@ async function updateTicketStatus(status) {
|
||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
});
|
});
|
||||||
await renderTickets();
|
await renderTickets();
|
||||||
|
await refreshTicketDetailView();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderEvents(options = {}) {
|
async function renderEvents(options = {}) {
|
||||||
|
|
@ -2374,6 +2425,9 @@ function syncEventsToolbar() {
|
||||||
sub.textContent = subs[state.eventsTab] || 'Operações Ligbox — onboarding, tickets e monitoramento';
|
sub.textContent = subs[state.eventsTab] || 'Operações Ligbox — onboarding, tickets e monitoramento';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (state.view === 'events') {
|
||||||
|
window.DeskTopnav?.remountEventsToolbar?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function carbonioBlockStatusBadge(status) {
|
function carbonioBlockStatusBadge(status) {
|
||||||
|
|
@ -3015,7 +3069,11 @@ async function saveUser(username, payload, msgEl) {
|
||||||
msgEl.className = 'admin-msg ok';
|
msgEl.className = 'admin-msg ok';
|
||||||
}
|
}
|
||||||
closeTeamDrawer();
|
closeTeamDrawer();
|
||||||
|
if (state.view === 'access-matrix') {
|
||||||
|
await window.renderAccessMatrix?.();
|
||||||
|
} else {
|
||||||
await renderAdmin();
|
await renderAdmin();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (msgEl) {
|
if (msgEl) {
|
||||||
msgEl.textContent = e.message;
|
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 { q, role, status, mfa } = state.adminFilter;
|
||||||
|
const roleLock = opts.roleLock;
|
||||||
const query = (q || '').trim().toLowerCase();
|
const query = (q || '').trim().toLowerCase();
|
||||||
return users.filter((u) => {
|
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 === 'active' && !u.active) return false;
|
||||||
if (status === 'inactive' && u.active) return false;
|
if (status === 'inactive' && u.active) return false;
|
||||||
if (mfa === 'on' && !u.totp_enabled) return false;
|
if (mfa === 'on' && !u.totp_enabled) return false;
|
||||||
|
|
@ -3074,7 +3158,7 @@ function openTeamDrawer(username) {
|
||||||
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="team-drawer-profile">
|
<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>
|
<div>
|
||||||
<p class="team-drawer-name">${esc(user.display_name || user.username)}</p>
|
<p class="team-drawer-name">${esc(user.display_name || user.username)}</p>
|
||||||
<p class="ticket-meta">${esc(email)}</p>
|
<p class="ticket-meta">${esc(email)}</p>
|
||||||
|
|
@ -3087,30 +3171,34 @@ function openTeamDrawer(username) {
|
||||||
<dt>Segurança</dt><dd>${mfaBadgeHtml(user)}</dd>
|
<dt>Segurança</dt><dd>${mfaBadgeHtml(user)}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<form id="team-drawer-form" class="team-drawer-form" autocomplete="off">
|
<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"/>
|
<input type="text" id="team-drawer-display" value="${esc(user.display_name || '')}" placeholder="Nome"/>
|
||||||
</label>
|
</div>
|
||||||
<label>Perfil
|
<div class="acs-field">
|
||||||
|
<label>Perfil de acesso</label>
|
||||||
${roleSelectHtml(user.username, user.role, !isRoot)}
|
${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>
|
||||||
<label>Estado da conta
|
</div>
|
||||||
<select id="team-drawer-active" ${isRoot ? 'disabled' : ''}>
|
<div class="acs-field">
|
||||||
<option value="1" ${user.active ? 'selected' : ''}>ativo</option>
|
<label for="team-drawer-password">Nova senha</label>
|
||||||
<option value="0" ${!user.active ? 'selected' : ''}>inativo</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Nova senha
|
|
||||||
<input type="password" id="team-drawer-password" placeholder="opcional (mín. 6 caracteres)" minlength="6"/>
|
<input type="password" id="team-drawer-password" placeholder="opcional (mín. 6 caracteres)" minlength="6"/>
|
||||||
</label>
|
</div>
|
||||||
${user.totp_enabled ? `
|
${user.totp_enabled ? `
|
||||||
<div class="team-drawer-danger">
|
<div class="team-drawer-danger">
|
||||||
<p class="ticket-meta">2FA ativo — o usuário pode recuperar no login ou você pode resetar aqui.</p>
|
<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>
|
<button type="button" class="btn btn-ghost btn-sm" id="team-reset-2fa">Resetar 2FA</button>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
<p id="team-drawer-msg" class="admin-msg" hidden></p>
|
<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="submit" class="btn btn-primary">Salvar alterações</button>
|
||||||
<button type="button" class="btn btn-ghost" data-close-team-drawer>Cancelar</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>`;
|
</form>`;
|
||||||
|
|
||||||
|
|
@ -3125,7 +3213,7 @@ function openTeamDrawer(username) {
|
||||||
const payload = {
|
const payload = {
|
||||||
display_name: body.querySelector('#team-drawer-display')?.value?.trim() || null,
|
display_name: body.querySelector('#team-drawer-display')?.value?.trim() || null,
|
||||||
role: body.querySelector('.admin-role')?.value,
|
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;
|
const pwd = body.querySelector('#team-drawer-password')?.value;
|
||||||
if (pwd && pwd.length >= 6) payload.password = pwd;
|
if (pwd && pwd.length >= 6) payload.password = pwd;
|
||||||
|
|
@ -3155,8 +3243,16 @@ function openTeamDrawer(username) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAdmin() {
|
async function renderAccessControlInto(el, opts = {}) {
|
||||||
const el = document.getElementById('admin-content');
|
if (!el) return;
|
||||||
|
const {
|
||||||
|
embedded = false,
|
||||||
|
matrixMode = false,
|
||||||
|
roleLock = null,
|
||||||
|
hideHero = false,
|
||||||
|
onRegistration = null,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
if (!canManageUsers()) {
|
if (!canManageUsers()) {
|
||||||
el.innerHTML = '<p class="loading">Sem permissão</p>';
|
el.innerHTML = '<p class="loading">Sem permissão</p>';
|
||||||
return;
|
return;
|
||||||
|
|
@ -3165,111 +3261,168 @@ async function renderAdmin() {
|
||||||
try {
|
try {
|
||||||
const [usersData, regData] = await Promise.all([
|
const [usersData, regData] = await Promise.all([
|
||||||
api('/v1/auth/users'),
|
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 || [];
|
state.adminUsers = usersData.users || [];
|
||||||
const users = state.adminUsers;
|
const users = state.adminUsers;
|
||||||
const filtered = filterAdminUsers(users);
|
const filterOpts = roleLock ? { roleLock } : {};
|
||||||
const activeCount = users.filter((u) => u.active).length;
|
const filtered = filterAdminUsers(users, filterOpts);
|
||||||
const mfaCount = users.filter((u) => u.totp_enabled).length;
|
const scopedUsers = roleLock ? users.filter((u) => u.role === roleLock || (roleLock === 'super_admin' && u.username === 'root')) : users;
|
||||||
const inactiveCount = users.length - activeCount;
|
const activeCount = scopedUsers.filter((u) => u.active).length;
|
||||||
const pending = regData.pending_count || 0;
|
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 { 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) => `
|
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>
|
<td>
|
||||||
<div class="team-user-cell">
|
<div class="acs-user-cell">
|
||||||
<div class="team-avatar" aria-hidden="true">${esc(userInitials(u.display_name, u.username))}</div>
|
<div class="acs-avatar" aria-hidden="true">${esc(userInitials(u.display_name, u.username))}</div>
|
||||||
<div>
|
<div>
|
||||||
<strong class="team-user-name">${esc(u.display_name || u.username)}</strong>
|
<strong class="acs-user-name">${esc(u.display_name || u.username)}</strong>
|
||||||
<span class="team-user-email">${esc(u.email || u.username)}</span>
|
<span class="acs-user-sub">${esc(u.email || u.username)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>${roleBadgeHtml(u.role)}</td>
|
<td>${roleBadgeHtml(u.role)}</td>
|
||||||
<td>${mfaBadgeHtml(u)}</td>
|
<td>${mfaBadgeHtml(u)}</td>
|
||||||
<td class="team-muted">${fmtRelative(u.last_login_at)}</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">
|
<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>
|
</td>
|
||||||
</tr>`).join('');
|
</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 = `
|
el.innerHTML = `
|
||||||
<div class="team-admin">
|
<div class="acs-page${embedded ? ' acs-page--embedded' : ''}${matrixMode ? ' acs-page--matrix' : ''}">
|
||||||
<header class="team-admin-head">
|
${hideHero ? '' : `
|
||||||
<div>
|
<header class="acs-hero">
|
||||||
<h2 class="team-admin-title">Equipe Ligbox</h2>
|
<h1>Controle de acesso Suporte</h1>
|
||||||
<p class="ticket-meta">Gestão de acessos ao Support Desk</p>
|
<p class="acs-breadcrumb">Desk · Configurações · Gestão de acesso · Controle de acesso Suporte</p>
|
||||||
</div>
|
</header>`}
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="team-goto-messages">
|
<div class="acs-body">
|
||||||
Pedidos de cadastro${pending ? ` <span class="badge review">${pending}</span>` : ''}
|
<div class="acs-layout">
|
||||||
</button>
|
<div class="acs-main">
|
||||||
</header>
|
${matrixMode && roleLock ? `
|
||||||
|
<div class="acs-matrix-users-head">
|
||||||
<div class="team-kpi-grid">
|
<span class="acs-matrix-users-title">Usuários da função</span>
|
||||||
<div class="team-kpi card"><span class="team-kpi-val">${users.length}</span><span class="team-kpi-label">membros</span></div>
|
<span class="acs-matrix-users-meta">
|
||||||
<div class="team-kpi card"><span class="team-kpi-val">${activeCount}</span><span class="team-kpi-label">ativos</span></div>
|
<code class="acs-role-code-inline">${esc(roleCode)}</code>
|
||||||
<div class="team-kpi card"><span class="team-kpi-val">${mfaCount}</span><span class="team-kpi-label">com 2FA</span></div>
|
${esc(roleLabel(roleLock))}
|
||||||
<div class="team-kpi card"><span class="team-kpi-val">${inactiveCount}</span><span class="team-kpi-label">inativos</span></div>
|
<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>
|
||||||
|
|
||||||
<div class="team-toolbar card">
|
<div class="acs-card acs-toolbar">
|
||||||
<label class="team-search">
|
<div class="acs-search">
|
||||||
<span class="sr-only">Buscar</span>
|
<input type="search" id="team-filter-q" placeholder="Pesquisar nome, e-mail ou perfil…" value="${esc(q)}" aria-label="Pesquisar usuários"/>
|
||||||
<input type="search" id="team-filter-q" placeholder="Buscar nome, e-mail ou perfil…" value="${esc(q)}"/>
|
</div>
|
||||||
</label>
|
<label class="acs-filter">Perfil
|
||||||
<label>Perfil
|
<select id="team-filter-role" ${roleLock ? 'disabled' : ''}>
|
||||||
<select id="team-filter-role">
|
<option value="all" ${role === 'all' && !roleLock ? 'selected' : ''}>Todos</option>
|
||||||
<option value="all" ${role === 'all' ? 'selected' : ''}>Todos</option>
|
${ROLE_OPTIONS.map((r) => `<option value="${r.value}" ${(roleLock ? roleLock === r.value : role === r.value) ? 'selected' : ''}>${r.label}</option>`).join('')}
|
||||||
${ROLE_OPTIONS.map((r) => `<option value="${r.value}" ${role === r.value ? 'selected' : ''}>${r.label}</option>`).join('')}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Estado
|
<label class="acs-filter">Estado
|
||||||
<select id="team-filter-status">
|
<select id="team-filter-status">
|
||||||
<option value="all" ${status === 'all' ? 'selected' : ''}>Todos</option>
|
<option value="all" ${status === 'all' ? 'selected' : ''}>Todos</option>
|
||||||
<option value="active" ${status === 'active' ? 'selected' : ''}>Ativos</option>
|
<option value="active" ${status === 'active' ? 'selected' : ''}>Ativados</option>
|
||||||
<option value="inactive" ${status === 'inactive' ? 'selected' : ''}>Inativos</option>
|
<option value="inactive" ${status === 'inactive' ? 'selected' : ''}>Desativados</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>2FA
|
<label class="acs-filter">2FA
|
||||||
<select id="team-filter-mfa">
|
<select id="team-filter-mfa">
|
||||||
<option value="all" ${mfa === 'all' ? 'selected' : ''}>Todos</option>
|
<option value="all" ${mfa === 'all' ? 'selected' : ''}>Todos</option>
|
||||||
<option value="on" ${mfa === 'on' ? 'selected' : ''}>Com 2FA</option>
|
<option value="on" ${mfa === 'on' ? 'selected' : ''}>Com 2FA</option>
|
||||||
<option value="off" ${mfa === 'off' ? 'selected' : ''}>Sem 2FA</option>
|
<option value="off" ${mfa === 'off' ? 'selected' : ''}>Sem 2FA</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div class="card team-table-wrap">
|
<div class="acs-card acs-table-card">
|
||||||
<table class="data-table team-table">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Membro</th>
|
<th>Usuário</th>
|
||||||
<th>Perfil</th>
|
<th>Perfil</th>
|
||||||
<th>Segurança</th>
|
<th>Segurança</th>
|
||||||
<th>Último login</th>
|
<th>Último login</th>
|
||||||
<th>Estado</th>
|
<th>Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</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 = () => {
|
const applyFilters = () => {
|
||||||
state.adminFilter = {
|
state.adminFilter = {
|
||||||
q: document.getElementById('team-filter-q')?.value || '',
|
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',
|
status: document.getElementById('team-filter-status')?.value || 'all',
|
||||||
mfa: document.getElementById('team-filter-mfa')?.value || 'all',
|
mfa: document.getElementById('team-filter-mfa')?.value || 'all',
|
||||||
};
|
};
|
||||||
renderAdmin();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('team-filter-q')?.addEventListener('input', () => {
|
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) => {
|
['team-filter-role', 'team-filter-status', 'team-filter-mfa'].forEach((id) => {
|
||||||
document.getElementById(id)?.addEventListener('change', applyFilters);
|
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) => {
|
el.querySelectorAll('.team-edit-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openTeamDrawer(btn.dataset.user);
|
openUser(btn.dataset.user);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
el.querySelectorAll('.team-row').forEach((row) => {
|
el.querySelectorAll('.team-row').forEach((row) => {
|
||||||
row.addEventListener('click', (e) => {
|
row.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('button')) return;
|
if (e.target.closest('button')) return;
|
||||||
openTeamDrawer(row.dataset.user);
|
openUser(row.dataset.user);
|
||||||
});
|
});
|
||||||
row.addEventListener('keydown', (e) => {
|
row.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
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) {
|
if (state.adminSelected && (state.view === 'admin' || state.view === 'access-matrix')) {
|
||||||
openTeamDrawer(state.adminSelected);
|
openUser(state.adminSelected);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
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() {
|
async function renderModules() {
|
||||||
const el = document.getElementById('modules-content');
|
const el = document.getElementById('modules-content');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -3367,6 +3572,19 @@ async function renderModules() {
|
||||||
const REG_ROLE_LABELS = ROLE_LABELS;
|
const REG_ROLE_LABELS = ROLE_LABELS;
|
||||||
|
|
||||||
async function renderMessages() {
|
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');
|
const el = document.getElementById('messages-content');
|
||||||
if (!canManageUsers()) {
|
if (!canManageUsers()) {
|
||||||
el.innerHTML = '<p class="loading">Sem permissão</p>';
|
el.innerHTML = '<p class="loading">Sem permissão</p>';
|
||||||
|
|
@ -4248,17 +4466,84 @@ function procCardHtml(opts) {
|
||||||
).join('');
|
).join('');
|
||||||
return `
|
return `
|
||||||
<article class="proc-card proc-card--${accent}" data-stack-id="${esc(id)}">
|
<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-top">
|
||||||
<header class="proc-card-head">
|
|
||||||
<span class="proc-card-icon" aria-hidden="true">${icon}</span>
|
<span class="proc-card-icon" aria-hidden="true">${icon}</span>
|
||||||
<span class="proc-card-spec">${esc(spec)}</span>
|
<div class="proc-card-heading">
|
||||||
</header>
|
|
||||||
<h3 class="proc-card-title">${esc(title)}</h3>
|
<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>
|
<p class="proc-card-desc">${desc}</p>
|
||||||
<footer class="proc-card-foot">${acts}</footer>
|
<footer class="proc-card-foot">${acts}</footer>
|
||||||
</article>`;
|
</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 & 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 & 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 & 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() {
|
function closeInfraProcessModal() {
|
||||||
const modal = document.getElementById('infra-process-modal');
|
const modal = document.getElementById('infra-process-modal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
|
@ -4303,6 +4588,12 @@ function stackServiceActions(svcId) {
|
||||||
if (svcId === 'vm122-purge-auth') {
|
if (svcId === 'vm122-purge-auth') {
|
||||||
return [{ label: 'Gerir códigos', action: 'purge-manage', primary: true }];
|
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 }];
|
const actions = [{ label: 'Detalhes', action: 'detail', primary: true }];
|
||||||
if (svcId === 'vm122-webhook-soc') actions.push({ label: 'Testar', action: 'test-webhook', primary: false });
|
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 });
|
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-webhook') runWebhookIntegrationTest('infra');
|
||||||
else if (action === 'test-openpanel') runOpenPanelApiTest();
|
else if (action === 'test-openpanel') runOpenPanelApiTest();
|
||||||
else if (action === 'purge-manage') openInfraProcessDetail('purge');
|
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);
|
const hit = findStackService(svcId);
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
if (svcId === 'integrations-json') openInfraProcessDetail('integrations');
|
if (svcId === 'integrations-json') openInfraProcessDetail('integrations');
|
||||||
|
|
@ -4336,20 +4674,22 @@ function openStackServiceDetail(svcId) {
|
||||||
}
|
}
|
||||||
const { vm, svc } = hit;
|
const { vm, svc } = hit;
|
||||||
const specLabel = svc.spec && svc.spec !== '—' ? `Spec ${svc.spec}` : svc.kind || 'stack';
|
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(
|
openInfraProcessModal(
|
||||||
svc.title,
|
svc.title,
|
||||||
`${vm.vm_label} · ${specLabel}`,
|
`${vm.vm_label} · ${specLabel} · VM${vm.vm}`,
|
||||||
`${infraKvHtml([
|
infraServiceDetailHtml(vm, svc).replace(
|
||||||
['VM', `${vm.vm} · ${esc(vm.ip)}`],
|
'</div>\n <div class="infra-detail-actions">',
|
||||||
['Tipo', esc(svc.kind || '—')],
|
`${extra}</div>\n <div class="infra-detail-actions">`,
|
||||||
['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>`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4432,8 +4772,11 @@ function openInfraProcessDetail(procId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderInfra(options = {}) {
|
async function renderInfra(options = {}) {
|
||||||
const { poll = false } = options;
|
const { poll = false, force = false } = options;
|
||||||
const el = document.getElementById('infra-content');
|
const el = document.getElementById('infra-content');
|
||||||
|
if (!el) return;
|
||||||
|
if (infraRenderInFlight && !force) return;
|
||||||
|
infraRenderInFlight = true;
|
||||||
if (!viewHasContent(el, '.infra-vm-section')) {
|
if (!viewHasContent(el, '.infra-vm-section')) {
|
||||||
el.innerHTML = '<p class="loading">Verificando stack…</p>';
|
el.innerHTML = '<p class="loading">Verificando stack…</p>';
|
||||||
} else if (poll) {
|
} else if (poll) {
|
||||||
|
|
@ -4441,9 +4784,9 @@ async function renderInfra(options = {}) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [stack, integrations, health, vm123Health, purgeMeta] = await Promise.all([
|
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').catch(() => null),
|
||||||
api('/v1/integrations/health').catch(() => ({})),
|
api('/v1/integrations/health', {}, 20000).catch(() => ({})),
|
||||||
api('/v1/vm123/health').catch(() => null),
|
api('/v1/vm123/health').catch(() => null),
|
||||||
api('/v1/infra/purge-auth-domains').catch(() => ({ domains: [], can_generate: false })),
|
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 }));
|
document.getElementById('btn-infra-refresh-stack')?.addEventListener('click', () => renderInfra({ force: true }));
|
||||||
bindStackCardActions(el);
|
bindStackCardActions(el);
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
|
infraRenderInFlight = false;
|
||||||
el?.classList.remove('view--refreshing');
|
el?.classList.remove('view--refreshing');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4638,6 +4985,19 @@ async function refresh(options = {}) {
|
||||||
if (state.view === 'account') await renderAccount();
|
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) => {
|
document.querySelectorAll('.nav button').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => setView(btn.dataset.view));
|
btn.addEventListener('click', () => setView(btn.dataset.view));
|
||||||
});
|
});
|
||||||
|
|
@ -4727,6 +5087,7 @@ document.getElementById('nav-console')?.addEventListener('click', (ev) => {
|
||||||
bindInfraProcessModal();
|
bindInfraProcessModal();
|
||||||
bindTeamDrawerClose();
|
bindTeamDrawerClose();
|
||||||
bindSocTestModal();
|
bindSocTestModal();
|
||||||
|
bindDeskNav();
|
||||||
setView(resolveBootView());
|
setView(resolveBootView());
|
||||||
|
|
||||||
ensureValidSession().then((valid) => {
|
ensureValidSession().then((valid) => {
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@
|
||||||
.app-chrome__inner {
|
.app-chrome__inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
padding: 0.45rem 1.25rem;
|
padding: 0.45rem 0.85rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,28 +65,35 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nav — mockup Roger: grupos com largura natural, separadores finos, sem stretch/ellipsis */
|
||||||
.app-chrome__nav {
|
.app-chrome__nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0.15rem 0.25rem;
|
padding: 0.15rem 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
overflow-y: hidden;
|
||||||
mask-image: linear-gradient(90deg, transparent, #000 12px, #000 calc(100% - 12px), transparent);
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(92, 46, 46, 0.2) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chrome__nav::-webkit-scrollbar {
|
.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 {
|
.nav-zone {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.22rem;
|
gap: 0.22rem;
|
||||||
flex-shrink: 0;
|
flex: 0 0 auto;
|
||||||
padding: 0.2rem 0.35rem 0.15rem;
|
padding: 0.2rem 0.28rem 0.15rem;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
@ -121,9 +128,9 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #5f5852;
|
color: #5f5852;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 520;
|
font-weight: 520;
|
||||||
padding: 0.42rem 0.75rem;
|
padding: 0.4rem 0.62rem;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -158,7 +165,8 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
padding: 0.15rem 0.25rem;
|
flex: 0 0 auto;
|
||||||
|
padding: 0.15rem 0.28rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-zone--console .nav-zone__label {
|
.nav-zone--console .nav-zone__label {
|
||||||
|
|
@ -169,11 +177,14 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: #0f4c81;
|
color: #0f4c81;
|
||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
|
font-size: 0.78rem;
|
||||||
border: 1px solid rgba(14, 165, 233, 0.28);
|
border: 1px solid rgba(14, 165, 233, 0.28);
|
||||||
background: #f0f9ff;
|
background: #f0f9ff;
|
||||||
margin-top: 0.12rem;
|
margin-top: 0.12rem;
|
||||||
|
padding: 0.4rem 0.62rem;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link--console:hover {
|
.nav-link--console:hover {
|
||||||
|
|
@ -187,17 +198,17 @@
|
||||||
width: 1px;
|
width: 1px;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
margin: 0 0.1rem;
|
margin: 0 0.08rem;
|
||||||
background: linear-gradient(180deg, transparent, rgba(92, 46, 46, 0.12), transparent);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(180deg, transparent, rgba(92, 46, 46, 0.12), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chrome__meta {
|
.app-chrome__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.4rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-left: 0.35rem;
|
padding-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chrome__meta .header-user {
|
.app-chrome__meta .header-user {
|
||||||
|
|
@ -248,6 +259,7 @@
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 550;
|
font-weight: 550;
|
||||||
|
padding: 0.32rem 0.55rem;
|
||||||
border-color: rgba(92, 46, 46, 0.1);
|
border-color: rgba(92, 46, 46, 0.1);
|
||||||
color: #5f5852;
|
color: #5f5852;
|
||||||
}
|
}
|
||||||
|
|
@ -257,19 +269,86 @@
|
||||||
color: var(--accent);
|
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 {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 0 1fr;
|
grid-template-columns: 1fr;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell--v2:not(.shell--no-context) .workspace {
|
.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 {
|
.workspace-main {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
min-width: 0;
|
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 {
|
.shell--v2 .main {
|
||||||
|
|
@ -280,25 +359,28 @@
|
||||||
background: rgba(255, 253, 249, 0.92);
|
background: rgba(255, 253, 249, 0.92);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border-right: 1px solid rgba(92, 46, 46, 0.08);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: calc(100vh - 58px);
|
min-height: calc(100vh - 58px);
|
||||||
|
align-self: start;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-sidebar__head {
|
.context-sidebar__head {
|
||||||
padding: 0 0.35rem 0.75rem;
|
padding: 0 0.35rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 0.75rem;
|
margin: -0.2rem 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-sidebar__label {
|
.context-sidebar__label {
|
||||||
margin: 0;
|
margin: 0 0 0.12rem;
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-sidebar__title {
|
.context-sidebar__title {
|
||||||
|
|
@ -352,22 +434,78 @@
|
||||||
.context-nav .am-role-list {
|
.context-nav .am-role-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.35rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-nav .am-role-group {
|
.context-nav .am-role-group.nav-zone {
|
||||||
display: flex;
|
flex: 0 0 auto;
|
||||||
flex-direction: column;
|
padding: 0.2rem 0.28rem 0.15rem;
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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%;
|
width: 100%;
|
||||||
text-align: left;
|
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 {
|
.context-sidebar__foot {
|
||||||
|
|
@ -383,6 +521,10 @@
|
||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.shell--v2 .page-header h2 {
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
|
@ -396,6 +538,34 @@
|
||||||
grid-template-columns: 1fr;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.app-chrome__inner {
|
.app-chrome__inner {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -405,7 +575,6 @@
|
||||||
.app-chrome__nav {
|
.app-chrome__nav {
|
||||||
order: 3;
|
order: 3;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
mask-image: none;
|
|
||||||
}
|
}
|
||||||
.app-chrome__meta .header-user__text {
|
.app-chrome__meta .header-user__text {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
||||||
727
projects/ops-desk/frontend/assets/executive-map-panel.js
Normal file
727
projects/ops-desk/frontend/assets/executive-map-panel.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
198
projects/ops-desk/frontend/assets/infra-stack-meta.js
Normal file
198
projects/ops-desk/frontend/assets/infra-stack-meta.js
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
2187
projects/ops-desk/frontend/assets/ligbox-ds.css
Normal file
2187
projects/ops-desk/frontend/assets/ligbox-ds.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -17,7 +17,9 @@ const DeskModules = {
|
||||||
},
|
},
|
||||||
|
|
||||||
isViewEnabled(view) {
|
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;
|
if (!btn || btn.hasAttribute('hidden')) return false;
|
||||||
const modId = btn.dataset.module;
|
const modId = btn.dataset.module;
|
||||||
if (!modId) return true;
|
if (!modId) return true;
|
||||||
|
|
|
||||||
317
projects/ops-desk/frontend/assets/operational-feed.js
Normal file
317
projects/ops-desk/frontend/assets/operational-feed.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
|
|
@ -1207,15 +1207,11 @@ const DeskServices = (() => {
|
||||||
|
|
||||||
if (!hasPage) {
|
if (!hasPage) {
|
||||||
const hydrated = hydrateFromCache();
|
const hydrated = hydrateFromCache();
|
||||||
if (hydrated) {
|
|
||||||
renderPageShell(container, {
|
renderPageShell(container, {
|
||||||
staleHint: hydrated.fresh
|
staleHint: hydrated
|
||||||
? 'A actualizar lista VM112…'
|
? (hydrated.fresh ? 'A actualizar lista VM112…' : 'Lista em cache — a actualizar 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 {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,59 @@ const TicketsDetailPanel = {
|
||||||
lastTicketId: null,
|
lastTicketId: null,
|
||||||
mirrorTimer: null,
|
mirrorTimer: null,
|
||||||
mirrorSessionId: 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() {
|
stopMirrorPoll() {
|
||||||
if (this.mirrorTimer) {
|
if (this.mirrorTimer) {
|
||||||
|
|
@ -230,42 +283,61 @@ const TicketsDetailPanel = {
|
||||||
</nav>`;
|
</nav>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
resumoHtml(t, { sessionId, assistMeta, carbonioBlock, timeline, timing }) {
|
resumoHtml(t, { sessionId, assistMeta, carbonioBlock }) {
|
||||||
const closeStatuses = ['open', 'escalated', 'assisting', 'resolved'];
|
const closeStatuses = ['open', 'escalated', 'assisting', 'resolved'];
|
||||||
|
const funnelKv = typeof ticketFunnelKvHtml === 'function'
|
||||||
|
? ticketFunnelKvHtml(t)
|
||||||
|
: `<dt>Evento</dt><dd>${esc(t.event || '—')}</dd>`;
|
||||||
return `
|
return `
|
||||||
<div class="ticket-detail-pane" data-ticket-pane="resumo"${this.activeTab !== 'resumo' ? ' hidden' : ''}>
|
<div class="ticket-detail-pane" data-ticket-pane="resumo"${this.activeTab !== 'resumo' ? ' hidden' : ''}>
|
||||||
<dl class="kv">
|
<div class="ticket-detail-layout">
|
||||||
<dt>Origem</dt><dd>${sourceBadge(t.source)}</dd>
|
<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>Domínio / Agente</dt><dd>${esc(t.domain || t.agent || '—')}</dd>
|
||||||
<dt>Email</dt><dd>${esc(t.email || '—')}</dd>
|
<dt>E-mail</dt><dd>${esc(t.email || '—')}</dd>
|
||||||
${typeof ticketFunnelKvHtml === 'function' ? ticketFunnelKvHtml(t) : `<dt>Evento</dt><dd>${esc(t.event || '—')}</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 & Operação</h4>
|
||||||
|
<dl class="kv kv--compact kv--indented">
|
||||||
|
<dt>Origem</dt><dd>${sourceBadge(t.source)}</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.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.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.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.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.severity != null ? `<dt>Severidade</dt><dd>${severityBadge(t.severity)}</dd>` : ''}
|
||||||
${t.rule_id ? `<dt>Regra</dt><dd>${esc(t.rule_id)}</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.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>` : ''}
|
${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>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>` : ''}
|
|
||||||
<dt>Verificado</dt><dd>${t.account_verified ? 'Sim' : 'Não'}</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>Revisão</dt><dd>${t.needs_review ? 'Necessária' : 'Não'}</dd>
|
||||||
<dt>Criado</dt><dd>${fmtDate(t.created_at)}</dd>
|
<dt>Criado</dt><dd>${fmtDate(t.created_at)}</dd>
|
||||||
</dl>
|
</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,
|
can_escalate: assistMeta?.can_escalate,
|
||||||
assist_status: assistMeta?.assist_status || assistMeta?.ticket_status,
|
assist_status: assistMeta?.assist_status || assistMeta?.ticket_status,
|
||||||
ticket_status: assistMeta?.ticket_status || t.status,
|
ticket_status: assistMeta?.ticket_status || t.status,
|
||||||
client_paused: assistMeta?.ticket?.client_paused ?? t.client_paused,
|
client_paused: assistMeta?.ticket?.client_paused ?? t.client_paused,
|
||||||
assisted_by: assistMeta?.assisted_by,
|
assisted_by: assistMeta?.assisted_by,
|
||||||
actions: assistMeta?.actions,
|
actions: assistMeta?.actions,
|
||||||
}, assistMeta?._console || {}) : ''}
|
}, assistMeta?._console || {})}
|
||||||
|
</section>
|
||||||
|
</div>` : ''}
|
||||||
${carbonioBlock ? `<div id="ticket-carbonio-block">${carbonioBlockPanelHtml(carbonioBlock)}</div>` : ''}
|
${carbonioBlock ? `<div id="ticket-carbonio-block">${carbonioBlockPanelHtml(carbonioBlock)}</div>` : ''}
|
||||||
<div class="actions">
|
<div class="ticket-detail-footer-actions">
|
||||||
${typeof canPatchTickets === 'function' && canPatchTickets()
|
${typeof canPatchTickets === 'function' && canPatchTickets()
|
||||||
? (closeStatuses.includes(t.status)
|
? (closeStatuses.includes(t.status)
|
||||||
? '<button type="button" class="btn btn-primary" data-action="close">Fechar ticket</button>'
|
? '<button type="button" class="btn btn-primary" data-action="close">Fechar ticket</button>'
|
||||||
|
|
@ -343,6 +415,7 @@ const TicketsDetailPanel = {
|
||||||
try {
|
try {
|
||||||
await runAssistAction('takeover', sessionId);
|
await runAssistAction('takeover', sessionId);
|
||||||
await renderTickets();
|
await renderTickets();
|
||||||
|
if (typeof refreshTicketDetailView === 'function') await refreshTicketDetailView();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message || 'Falha ao assumir sessão');
|
alert(err.message || 'Falha ao assumir sessão');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -355,6 +428,7 @@ const TicketsDetailPanel = {
|
||||||
try {
|
try {
|
||||||
await runAssistAction('resume-wizard', sessionId);
|
await runAssistAction('resume-wizard', sessionId);
|
||||||
await renderTickets();
|
await renderTickets();
|
||||||
|
if (typeof refreshTicketDetailView === 'function') await refreshTicketDetailView();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message || 'Falha ao reabrir wizard ASM');
|
alert(err.message || 'Falha ao reabrir wizard ASM');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -367,6 +441,7 @@ const TicketsDetailPanel = {
|
||||||
try {
|
try {
|
||||||
await runAssistAction('escalate', sessionId);
|
await runAssistAction('escalate', sessionId);
|
||||||
await renderTickets();
|
await renderTickets();
|
||||||
|
if (typeof refreshTicketDetailView === 'function') await refreshTicketDetailView();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message || 'Falha ao escalar');
|
alert(err.message || 'Falha ao escalar');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -391,7 +466,7 @@ const TicketsDetailPanel = {
|
||||||
this.activeTab = 'resumo';
|
this.activeTab = 'resumo';
|
||||||
this.lastTicketId = ticketId;
|
this.lastTicketId = ticketId;
|
||||||
}
|
}
|
||||||
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando…</p></div>';
|
detailEl.innerHTML = '<p class="loading">Carregando…</p>';
|
||||||
try {
|
try {
|
||||||
const t = await api(`/v1/desk/tickets/${ticketId}`);
|
const t = await api(`/v1/desk/tickets/${ticketId}`);
|
||||||
const sessionId = t.session_id || state.selectedSessionId;
|
const sessionId = t.session_id || state.selectedSessionId;
|
||||||
|
|
@ -428,22 +503,34 @@ const TicketsDetailPanel = {
|
||||||
const nextAction = this.computeNextAction(t, assistMeta, carbonioBlock);
|
const nextAction = this.computeNextAction(t, assistMeta, carbonioBlock);
|
||||||
|
|
||||||
detailEl.innerHTML = `
|
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 class="ticket-detail-header">
|
||||||
<div>
|
<div>
|
||||||
<h3 style="margin:0">Ticket #${t.id}${t.wizard_ticket_id ? ` · <code class="session-hash">${esc(t.wizard_ticket_id)}</code>` : ''}</h3>
|
<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>
|
<p class="ticket-meta">${esc(t.domain || t.subject || t.agent || '')}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
|
<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.nextActionHtml(nextAction)}
|
||||||
${this.tabsHtml({ t, sessionId, hasLive, hasFunil, hasEspelho })}
|
${this.tabsHtml({ t, sessionId, hasLive, hasFunil, hasEspelho })}
|
||||||
${this.resumoHtml(t, { sessionId, assistMeta, carbonioBlock, timeline, timing })}
|
${this.resumoHtml(t, { sessionId, assistMeta, carbonioBlock })}
|
||||||
${hasEspelho ? this.espelhoPaneHtml() : ''}
|
${hasEspelho ? this.espelhoPaneHtml() : ''}
|
||||||
${hasLive ? this.livePaneHtml(sessionId) : ''}
|
${hasLive ? this.livePaneHtml(sessionId) : ''}
|
||||||
${hasFunil ? this.funilPaneHtml(timeline, timing) : ''}
|
${hasFunil ? this.funilPaneHtml(timeline, timing) : ''}
|
||||||
</div>`;
|
</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.bindTabs(detailEl);
|
||||||
this.bindNextActions(detailEl, sessionId);
|
this.bindNextActions(detailEl, sessionId);
|
||||||
if (sessionId && t.source === 'vm112-onboard') bindAssistActions(detailEl, sessionId);
|
if (sessionId && t.source === 'vm112-onboard') bindAssistActions(detailEl, sessionId);
|
||||||
|
|
|
||||||
162
projects/ops-desk/frontend/assets/tickets-sla.js
Normal file
162
projects/ops-desk/frontend/assets/tickets-sla.js
Normal 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
|
|
@ -1,25 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Tickets Workspace — P0 lista (8 KPIs + 3 sinais) · P1 filas · P2 KPI click + softRefresh
|
* Results and Works Desk — UI aprovada (Spec 032 / Roger 2026-06-28)
|
||||||
* Arquitectura separada; app.js delega render aqui.
|
* KPI Overview + tabela operacional + alertas agentic.
|
||||||
*/
|
*/
|
||||||
const TicketsWorkspace = {
|
const TicketsWorkspace = {
|
||||||
context: null,
|
context: null,
|
||||||
searchQuery: '',
|
|
||||||
queueFilter: null,
|
queueFilter: null,
|
||||||
|
priorityFilter: null,
|
||||||
_pageReady: false,
|
_pageReady: false,
|
||||||
_listFingerprint: '',
|
_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() {
|
async loadContext() {
|
||||||
const user = typeof getUser === 'function' ? getUser() : null;
|
|
||||||
const [presence, funnel, summary] = await Promise.all([
|
const [presence, funnel, summary] = await Promise.all([
|
||||||
window.DeskLive?.enabled()
|
window.DeskLive?.enabled()
|
||||||
? api('/v1/live/presence').catch(() => ({ sessions: [] }))
|
? api('/v1/live/presence').catch(() => ({ sessions: [] }))
|
||||||
|
|
@ -39,7 +29,7 @@ const TicketsWorkspace = {
|
||||||
liveBySession,
|
liveBySession,
|
||||||
funnelBySession,
|
funnelBySession,
|
||||||
staleHours: summary.onboard_stale_hours ?? 24,
|
staleHours: summary.onboard_stale_hours ?? 24,
|
||||||
user,
|
summary,
|
||||||
};
|
};
|
||||||
return this.context;
|
return this.context;
|
||||||
},
|
},
|
||||||
|
|
@ -55,441 +45,420 @@ const TicketsWorkspace = {
|
||||||
enrichTicket(t) {
|
enrichTicket(t) {
|
||||||
const ctx = this.context || { liveBySession: {}, funnelBySession: {}, staleHours: 24 };
|
const ctx = this.context || { liveBySession: {}, funnelBySession: {}, staleHours: 24 };
|
||||||
const sid = (t.session_id || '').trim();
|
const sid = (t.session_id || '').trim();
|
||||||
const live = sid ? ctx.liveBySession[sid] : null;
|
|
||||||
const funnel = sid ? ctx.funnelBySession[sid] : null;
|
const funnel = sid ? ctx.funnelBySession[sid] : null;
|
||||||
const isActive = ['open', 'escalated', 'assisting', 'resolved'].includes(t.status);
|
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;
|
let idleHours = 0;
|
||||||
if (lastAt) {
|
if (lastAt) idleHours = (Date.now() - new Date(lastAt).getTime()) / 3600000;
|
||||||
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 stale = funnel?.stale || (isActive && idleHours >= ctx.staleHours);
|
||||||
const stage = funnel?.current_stage || t.lead_funnel_stage || t.event;
|
const sev = Number(t.severity);
|
||||||
return {
|
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,
|
...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),
|
_idleHours: Math.round(idleHours),
|
||||||
|
_ageHours: Math.round(ageHours),
|
||||||
_stale: stale && isActive,
|
_stale: stale && isActive,
|
||||||
|
_live: live,
|
||||||
|
_lastEventAt: funnel?.last_event_at || null,
|
||||||
_unassigned: isActive && !t.assigned_to && !t.assisted_by,
|
_unassigned: isActive && !t.assigned_to && !t.assisted_by,
|
||||||
_billing: Boolean(t.billing_state) || (t.subject || '').includes('[billing'),
|
_wazuh: isWazuh,
|
||||||
_wazuh: t.source === 'wazuh' || t.event === 'wazuh.alert',
|
_security: isWazuh || (t.subject || '').toLowerCase().includes('security'),
|
||||||
_carbonioHint: (t.subject || '').toLowerCase().includes('carbonio')
|
_priority: priority,
|
||||||
|| (t.subject || '').toLowerCase().includes('account_exists'),
|
_voScore: voScore,
|
||||||
|
_stageLabel: typeof FUNNEL_LABELS !== 'undefined'
|
||||||
|
? (FUNNEL_LABELS[funnel?.current_stage] || funnel?.current_stage || '')
|
||||||
|
: '',
|
||||||
};
|
};
|
||||||
},
|
if (window.TicketsSla?.computePriority) {
|
||||||
|
base = TicketsSla.computePriority(base);
|
||||||
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 (t.event) return { text: String(t.event).replace('onboarding.', '').replace('.', ' '), cls: 'phase' };
|
return base;
|
||||||
return { text: '—', cls: 'muted' };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
ticketCardHtml(t) {
|
formatMetric(val) {
|
||||||
const phase = this.phaseSignal(t);
|
if (val === '—' || val == null || val === '') return '—';
|
||||||
const isOnboard = t.source === 'vm112-onboard' || t.event?.startsWith?.('onboarding');
|
const n = parseFloat(val);
|
||||||
const title = t.event === 'wazuh.alert'
|
return Number.isFinite(n) ? `${n.toFixed(1)}h` : String(val);
|
||||||
? 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>`;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
filterByQueue(tickets) {
|
processTickets(tickets) {
|
||||||
if (!this.queueFilter) return tickets;
|
const enriched = tickets.map((t) => this.enrichTicket(this.stripEnrichment(t)));
|
||||||
const rules = {
|
if (window.TicketsSla?.processList) return TicketsSla.processList(enriched);
|
||||||
open: (t) => t.status === 'open',
|
return enriched.sort((a, b) => (b._priorityScore || b._voScore || 0) - (a._priorityScore || a._voScore || 0));
|
||||||
assisting: (t) => t.status === 'assisting',
|
},
|
||||||
escalated: (t) => t.status === 'escalated',
|
|
||||||
live: (t) => t._live,
|
computeMetrics(tickets) {
|
||||||
unassigned: (t) => t._unassigned,
|
const enriched = this.processTickets(tickets);
|
||||||
stale: (t) => t._stale,
|
let overview = null;
|
||||||
billing: (t) => t._billing,
|
if (window.TicketsSla?.computeOverview) {
|
||||||
wazuh: (t) => t._wazuh,
|
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) {
|
gaugeHtml(pct) {
|
||||||
const raw = (this.searchQuery || '').trim();
|
const p = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||||
if (!raw) return tickets;
|
const r = 42;
|
||||||
const q = raw.toLowerCase();
|
const circ = Math.PI * r;
|
||||||
const ticketIdQuery = q.replace(/^ticket\s*#?/, '').replace(/^#/, '').trim();
|
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) => {
|
priorityBarsHtml(priorities, max) {
|
||||||
if (/^\d+$/.test(ticketIdQuery) && String(t.id) === ticketIdQuery) return true;
|
const items = [
|
||||||
const wiz = (t.wizard_ticket_id || '').toLowerCase();
|
{ key: 'critical', label: 'Critical', count: priorities.critical || 0 },
|
||||||
if (wiz && (wiz === q || wiz.includes(q))) return true;
|
{ key: 'high', label: 'High', count: priorities.high || 0 },
|
||||||
const liveIp = this.context?.liveBySession?.[t.session_id]?.client_ip;
|
{ key: 'warn', label: 'Warn', count: priorities.warn || 0 },
|
||||||
const hay = [
|
{ key: 'info', label: 'Info', count: priorities.info || 0 },
|
||||||
t.id,
|
];
|
||||||
`#${t.id}`,
|
const maxVal = Math.max(15, max, ...items.map((i) => i.count), 1);
|
||||||
`ticket ${t.id}`,
|
return `
|
||||||
t.subject,
|
<div class="rwd-vbars" role="img" aria-label="Tickets by priority">
|
||||||
t.domain,
|
${items.map((it) => {
|
||||||
t.email,
|
const h = it.count > 0 ? Math.max(16, Math.round((it.count / maxVal) * 100)) : 8;
|
||||||
t.session_id,
|
return `
|
||||||
t.agent,
|
<div class="rwd-vbars__col">
|
||||||
t.description,
|
<span class="rwd-vbars__count">${it.count}</span>
|
||||||
t.assigned_to,
|
<div class="rwd-vbars__track">
|
||||||
t.assisted_by,
|
<div class="rwd-vbars__bar rwd-vbars__bar--${it.key}" style="height:${h}%"></div>
|
||||||
t.wizard_ticket_id,
|
</div>
|
||||||
liveIp,
|
<span class="rwd-vbars__label">${it.label}</span>
|
||||||
].filter(Boolean).join(' ').toLowerCase();
|
</div>`;
|
||||||
return hay.includes(q);
|
}).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 & 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) {
|
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) {
|
listFingerprint(tickets) {
|
||||||
return tickets.map((t) => [
|
return tickets.map((t) => [t.id, t.status, t._idleHours, t._priority].join(':')).join('|');
|
||||||
t.id, t.status, t._live ? 1 : 0, t._stale ? 1 : 0, t._stage, t._idleHours, t._livePath || '',
|
|
||||||
].join(':')).join('|');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setQueueFilter(key) {
|
renderShell(metrics, filtered, alerts) {
|
||||||
this.queueFilter = this.queueFilter === key ? null : key;
|
return `
|
||||||
const bar = document.getElementById('tickets-queue-bar');
|
<div class="rwd-page">
|
||||||
if (bar) {
|
${this.headerHtml()}
|
||||||
bar.innerHTML = this.queueChipsHtml();
|
${this.kpiOverviewHtml(metrics)}
|
||||||
this.bindQueueBar(bar);
|
${this.tableHtml(filtered)}
|
||||||
}
|
${this.alertsHtml(alerts)}
|
||||||
this.syncQueueUi();
|
</div>`;
|
||||||
this.renderListOnly();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
syncQueueUi() {
|
mountRoot() {
|
||||||
document.querySelectorAll('[data-ticket-kpi]').forEach((el) => {
|
let root = document.getElementById('tickets-rwd-root');
|
||||||
el.classList.toggle('active', el.dataset.ticketKpi === this.queueFilter);
|
if (!root) {
|
||||||
});
|
|
||||||
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) {
|
|
||||||
const view = document.getElementById('view-tickets');
|
const view = document.getElementById('view-tickets');
|
||||||
const toolbar = view?.querySelector('.toolbar');
|
if (!view) return null;
|
||||||
if (!toolbar) return;
|
root = document.createElement('div');
|
||||||
bar = document.createElement('div');
|
root.id = 'tickets-rwd-root';
|
||||||
bar.id = 'tickets-queue-bar';
|
view.insertBefore(root, view.firstChild);
|
||||||
toolbar.parentNode.insertBefore(bar, toolbar.nextSibling);
|
|
||||||
}
|
}
|
||||||
bar.innerHTML = this.queueChipsHtml();
|
return root;
|
||||||
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>';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async renderListOnly() {
|
async renderListOnly() {
|
||||||
const listEl = document.getElementById('ticket-list');
|
const root = document.getElementById('tickets-rwd-root');
|
||||||
if (!listEl || !state.tickets?.length) return;
|
if (!root || !state.tickets?.length) return;
|
||||||
const enriched = state.tickets.map((t) => this.enrichTicket(this.stripEnrichment(t)));
|
const metrics = this.computeMetrics(state.tickets.map((t) => this.stripEnrichment(t)));
|
||||||
state.tickets = enriched;
|
state.tickets = metrics.enriched;
|
||||||
const filtered = this.applyFilters(enriched);
|
const filtered = this.applyFilters(metrics.enriched);
|
||||||
const fp = this.listFingerprint(filtered);
|
const fp = this.listFingerprint(filtered);
|
||||||
if (fp === this._listFingerprint && listEl.querySelector('.ticket-card')) {
|
if (fp === this._listFingerprint && root.querySelector('.rwd-table')) return;
|
||||||
this.patchLiveSignals(listEl, filtered);
|
|
||||||
this.updateKpiStrip(this.computeIndicators(enriched));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._listFingerprint = fp;
|
this._listFingerprint = fp;
|
||||||
listEl.innerHTML = this.renderListHtml(filtered);
|
const alerts = await this.fetchAgenticAlerts();
|
||||||
this.bindList(listEl, filtered);
|
root.innerHTML = this.renderShell({ ...metrics, enriched: metrics.enriched }, filtered, alerts);
|
||||||
this.updateKpiStrip(this.computeIndicators(enriched));
|
this.bindTable(root, filtered);
|
||||||
|
this.bindPage(root);
|
||||||
},
|
},
|
||||||
|
|
||||||
async softRefresh() {
|
async softRefresh() {
|
||||||
if (!this._pageReady || state.view !== 'tickets') return;
|
if (!this._pageReady || state.view !== 'tickets') return;
|
||||||
try {
|
try {
|
||||||
await this.loadContext();
|
await this.loadContext();
|
||||||
const enriched = (state.tickets || []).map((t) => this.enrichTicket(this.stripEnrichment(t)));
|
await this.renderListOnly();
|
||||||
state.tickets = enriched;
|
} catch { /* poll */ }
|
||||||
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 */ }
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async renderPage({ listEl, detailEl, tickets }) {
|
async renderPage({ listEl, detailEl, tickets }) {
|
||||||
this.mountSearchToolbar();
|
this.syncPageTitle();
|
||||||
this.mountQueueBar();
|
|
||||||
await this.loadContext();
|
await this.loadContext();
|
||||||
const enriched = tickets.map((t) => this.enrichTicket(t));
|
const metrics = this.computeMetrics(tickets);
|
||||||
state.tickets = enriched;
|
state.tickets = metrics.enriched;
|
||||||
const ind = this.computeIndicators(enriched);
|
const filtered = this.applyFilters(metrics.enriched);
|
||||||
const filtered = this.applyFilters(enriched);
|
|
||||||
this._listFingerprint = this.listFingerprint(filtered);
|
this._listFingerprint = this.listFingerprint(filtered);
|
||||||
|
const alerts = await this.fetchAgenticAlerts();
|
||||||
let strip = document.getElementById('tickets-kpi-strip');
|
const root = this.mountRoot();
|
||||||
if (!strip) {
|
if (!root) return;
|
||||||
strip = document.createElement('div');
|
root.innerHTML = this.renderShell(metrics, filtered, alerts);
|
||||||
strip.id = 'tickets-kpi-strip';
|
this.bindTable(root, filtered);
|
||||||
const view = document.getElementById('view-tickets');
|
this.bindPage(root);
|
||||||
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);
|
|
||||||
this._pageReady = true;
|
this._pageReady = true;
|
||||||
|
|
||||||
if (state.selectedTicketId && window.TicketsDetailPanel) {
|
if (listEl) listEl.innerHTML = '';
|
||||||
await TicketsDetailPanel.render(state.selectedTicketId, detailEl);
|
if (detailEl && !state.selectedTicketId) {
|
||||||
} else if (state.selectedTicketId && typeof renderTicketDetail === 'function') {
|
detailEl.innerHTML = '';
|
||||||
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>';
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,19 @@ const TOPNAV_VIEW_GROUP = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONTEXT_TOOLBARS = {
|
const CONTEXT_TOOLBARS = {
|
||||||
tickets: { title: 'Tickets', host: '#view-tickets', label: 'Filas' },
|
events: { title: 'Eventos', host: '#view-events', label: 'Vistas', toolbarId: 'events-toolbar' },
|
||||||
events: { title: 'Eventos', host: '#view-events', label: 'Vistas' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function initTopnavDropdowns() {
|
function resolveContextToolbar(cfg) {
|
||||||
/* v0.11 — tabs planas; sem dropdowns */
|
if (cfg.toolbarId) return document.getElementById(cfg.toolbarId);
|
||||||
|
const view = document.querySelector(cfg.host);
|
||||||
|
return view?.querySelector('.toolbar') || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parkContextToolbars() {
|
function parkContextToolbars() {
|
||||||
Object.values(CONTEXT_TOOLBARS).forEach(({ host }) => {
|
Object.values(CONTEXT_TOOLBARS).forEach((cfg) => {
|
||||||
const view = document.querySelector(host);
|
const view = document.querySelector(cfg.host);
|
||||||
const toolbar = view?.querySelector('.toolbar');
|
const toolbar = resolveContextToolbar(cfg);
|
||||||
if (toolbar && view && toolbar.parentElement !== view) {
|
if (toolbar && view && toolbar.parentElement !== view) {
|
||||||
view.insertBefore(toolbar, view.firstChild);
|
view.insertBefore(toolbar, view.firstChild);
|
||||||
}
|
}
|
||||||
|
|
@ -62,11 +63,38 @@ function mountMatrixRoleSidebar() {
|
||||||
if (!roleNav) return false;
|
if (!roleNav) return false;
|
||||||
const nav = document.getElementById('context-nav');
|
const nav = document.getElementById('context-nav');
|
||||||
if (!nav) return false;
|
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';
|
roleNav.dataset.parkedFrom = '#access-matrix-content .am-layout';
|
||||||
nav.appendChild(roleNav);
|
nav.appendChild(roleNav);
|
||||||
return true;
|
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) {
|
function updateContextSidebar(view) {
|
||||||
const shell = document.querySelector('.shell');
|
const shell = document.querySelector('.shell');
|
||||||
const sidebar = document.getElementById('context-sidebar');
|
const sidebar = document.getElementById('context-sidebar');
|
||||||
|
|
@ -78,20 +106,24 @@ function updateContextSidebar(view) {
|
||||||
parkContextToolbars();
|
parkContextToolbars();
|
||||||
nav.innerHTML = '';
|
nav.innerHTML = '';
|
||||||
|
|
||||||
if (view === 'admin' && typeof renderAdminTabs === 'function') {
|
// Spec 032 — Tickets RWD tem header próprio; sidebar contextual duplica título e embola layout.
|
||||||
sidebar.hidden = false;
|
if (view === 'tickets') {
|
||||||
shell.classList.remove('shell--no-context');
|
sidebar.hidden = true;
|
||||||
if (titleEl) titleEl.textContent = 'Administradores';
|
shell.classList.add('shell--no-context');
|
||||||
if (labelEl) labelEl.textContent = 'Secções';
|
return;
|
||||||
nav.innerHTML = `<div id="context-admin-tabs">${renderAdminTabs()}</div>`;
|
}
|
||||||
if (typeof bindAdminTabs === 'function') bindAdminTabs();
|
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view === 'access-matrix') {
|
if (view === 'access-matrix') {
|
||||||
sidebar.hidden = false;
|
sidebar.hidden = false;
|
||||||
shell.classList.remove('shell--no-context');
|
shell.classList.remove('shell--no-context');
|
||||||
if (titleEl) titleEl.textContent = 'Matriz de Acesso';
|
if (titleEl) titleEl.textContent = '';
|
||||||
if (labelEl) labelEl.textContent = 'Funções';
|
if (labelEl) labelEl.textContent = 'Funções';
|
||||||
requestAnimationFrame(() => mountMatrixRoleSidebar());
|
requestAnimationFrame(() => mountMatrixRoleSidebar());
|
||||||
return;
|
return;
|
||||||
|
|
@ -104,8 +136,12 @@ function updateContextSidebar(view) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewEl = document.querySelector(cfg.host);
|
if (view === 'events') {
|
||||||
const toolbar = viewEl?.querySelector('.toolbar');
|
mountEventsToolbar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbar = resolveContextToolbar(cfg);
|
||||||
if (!toolbar) {
|
if (!toolbar) {
|
||||||
sidebar.hidden = true;
|
sidebar.hidden = true;
|
||||||
shell.classList.add('shell--no-context');
|
shell.classList.add('shell--no-context');
|
||||||
|
|
@ -125,4 +161,5 @@ window.DeskTopnav = {
|
||||||
updateTopnavActive,
|
updateTopnavActive,
|
||||||
updateContextSidebar,
|
updateContextSidebar,
|
||||||
remountMatrixRoles: mountMatrixRoleSidebar,
|
remountMatrixRoles: mountMatrixRoleSidebar,
|
||||||
|
remountEventsToolbar: mountEventsToolbar,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
484
projects/ops-desk/frontend/assets/user-management-panel.js
Normal file
484
projects/ops-desk/frontend/assets/user-management-panel.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 já</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 };
|
||||||
|
})();
|
||||||
939
projects/ops-desk/frontend/assets/user-wizard.js
Normal file
939
projects/ops-desk/frontend/assets/user-wizard.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
|
|
@ -7,11 +7,13 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
<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 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/styles.css?v=20260629infra2"/>
|
||||||
<link rel="stylesheet" href="/assets/desk-modern.css?v=20260626prod1"/>
|
<link rel="stylesheet" href="/assets/desk-modern.css?v=20260629acs5"/>
|
||||||
<link rel="stylesheet" href="/assets/tickets-workspace.css?v=20260619tickets2"/>
|
<link rel="stylesheet" href="/assets/tickets-workspace.css?v=20260628kpi2"/>
|
||||||
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260625pollfix"/>
|
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260702spec30"/>
|
||||||
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260620am7"/>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<svg width="0" height="0" style="position:absolute;visibility:hidden" aria-hidden="true" focusable="false">
|
<svg width="0" height="0" style="position:absolute;visibility:hidden" aria-hidden="true" focusable="false">
|
||||||
|
|
@ -229,9 +231,8 @@
|
||||||
<div class="nav-zone__links">
|
<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="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="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 de Acesso</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -252,7 +253,7 @@
|
||||||
<p class="context-sidebar__title" id="context-sidebar-title"></p>
|
<p class="context-sidebar__title" id="context-sidebar-title"></p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="context-nav" id="context-nav" aria-label="Navegação do processo"></nav>
|
<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>
|
</aside>
|
||||||
|
|
||||||
<div class="workspace-main">
|
<div class="workspace-main">
|
||||||
|
|
@ -287,6 +288,7 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="view-tickets" class="view">
|
<section id="view-tickets" class="view">
|
||||||
|
<div id="tickets-rwd-root"></div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button type="button" class="filter-btn active" data-filter="all">Todos</button>
|
<button type="button" class="filter-btn active" data-filter="all">Todos</button>
|
||||||
<button type="button" class="filter-btn" data-filter="active">Activos</button>
|
<button type="button" class="filter-btn" data-filter="active">Activos</button>
|
||||||
|
|
@ -365,7 +367,20 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sidebar-user" hidden aria-hidden="true"></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>
|
<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">
|
<aside class="team-drawer-panel" role="dialog" aria-modal="true" aria-labelledby="team-drawer-title">
|
||||||
<div class="team-drawer-header">
|
<div class="team-drawer-header">
|
||||||
|
|
@ -464,16 +479,24 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/auth.js?v=20260626prod1"></script>
|
<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/billing-ui.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/desk-live-stub.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-sla.js?v=20260628fix1"></script>
|
||||||
<script src="/assets/tickets-detail-panel.js?v=20260619tickets2"></script>
|
<script src="/assets/tickets-workspace.js?v=20260628kpi2"></script>
|
||||||
<script src="/assets/servicos.js?v=20260626prod1"></script>
|
<script src="/assets/tickets-detail-panel.js?v=20260628fix1"></script>
|
||||||
<script src="/assets/agentic-ops.js?v=20260625pollfix"></script>
|
<script src="/assets/servicos.js?v=20260630serv1"></script>
|
||||||
<script src="/assets/access-matrix.js?v=20260620am7"></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/dns-viewer.js?v=20260625handoff1"></script>
|
||||||
<script src="/assets/topnav.js?v=20260626prod1"></script>
|
<script src="/assets/topnav.js?v=20260625mo1"></script>
|
||||||
<script src="/assets/app.js?v=20260626prod1"></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
<link rel="stylesheet" href="/assets/desk-modern.css?v=20260626staging1"/>
|
<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/agentic-ops.css?v=20260623iconagentes"/>
|
||||||
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260623matriz"/>
|
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260623matriz"/>
|
||||||
|
<link rel="stylesheet" href="/assets/ligbox-ds.css?v=20260630wizard1"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<svg width="0" height="0" style="position:absolute;visibility:hidden" aria-hidden="true" focusable="false">
|
<svg width="0" height="0" style="position:absolute;visibility:hidden" aria-hidden="true" focusable="false">
|
||||||
|
|
@ -228,7 +229,7 @@
|
||||||
<div class="nav-zone__links">
|
<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="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="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="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="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>
|
<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>
|
<p class="context-sidebar__title" id="context-sidebar-title"></p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="context-nav" id="context-nav" aria-label="Navegação do processo"></nav>
|
<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>
|
</aside>
|
||||||
|
|
||||||
<div class="workspace-main">
|
<div class="workspace-main">
|
||||||
|
|
@ -433,6 +434,12 @@
|
||||||
<script src="/assets/agentic-ops.js?v=20260623iconagentes"></script>
|
<script src="/assets/agentic-ops.js?v=20260623iconagentes"></script>
|
||||||
<script src="/assets/access-matrix.js?v=20260623matriz"></script>
|
<script src="/assets/access-matrix.js?v=20260623matriz"></script>
|
||||||
<script src="/assets/topnav.js?v=20260626staging1"></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
|
|
||||||
| Campo | Valor |
|
| Campo | Valor |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **Status** | `idle` |
|
| **Status** | `done` (Spec 030 em produção; scripts promote actualizados) |
|
||||||
| **Última actualização** | 2026-07-01 |
|
| **Última actualização** | 2026-07-02 |
|
||||||
| **Agente / sessão** | — |
|
| **Agente / sessão** | Fix deploy VM122 + promote script |
|
||||||
| **Prioridade** | — |
|
| **Prioridade** | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -24,7 +24,11 @@ Agentic Ops UI (Mission Board)
|
||||||
|
|
||||||
## Feito (última sessão)
|
## 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
|
## Smoke tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Definir quando spec entrar em implementação
|
# Produção — Spec 030 presente
|
||||||
# Ver VMs em docs/vms/ ou spec.md
|
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
420
specs/039-ligbox-ops-authorization-catalog/spec.md
Normal file
420
specs/039-ligbox-ops-authorization-catalog/spec.md
Normal 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 A0–A7 | 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 §5–9.
|
||||||
|
- **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
|
||||||
125
specs/040-desk-design-system-v013/contracts/governance-api.md
Normal file
125
specs/040-desk-design-system-v013/contracts/governance-api.md
Normal 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" } ] }`
|
||||||
217
specs/040-desk-design-system-v013/spec.md
Normal file
217
specs/040-desk-design-system-v013/spec.md
Normal 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 1–2).
|
||||||
|
|
||||||
|
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)
|
||||||
12
specs/040-desk-design-system-v013/tasks.md
Normal file
12
specs/040-desk-design-system-v013/tasks.md
Normal 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
|
||||||
164
specs/041-desk-operational-feed/contracts/ops-inbox-api.md
Normal file
164
specs/041-desk-operational-feed/contracts/ops-inbox-api.md
Normal 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` |
|
||||||
13
specs/041-desk-operational-feed/tasks.md
Normal file
13
specs/041-desk-operational-feed/tasks.md
Normal 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
|
||||||
Loading…
Reference in a new issue