diff --git a/.cursor/rules/desk-deploy-release.mdc b/.cursor/rules/desk-deploy-release.mdc new file mode 100644 index 0000000..16483e3 --- /dev/null +++ b/.cursor/rules/desk-deploy-release.mdc @@ -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. diff --git a/deploy/desk-staging-modern/README.md b/deploy/desk-staging-modern/README.md new file mode 100644 index 0000000..527d1fd --- /dev/null +++ b/deploy/desk-staging-modern/README.md @@ -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/` diff --git a/deploy/desk-staging-modern/deploy-desk-full.sh b/deploy/desk-staging-modern/deploy-desk-full.sh new file mode 100755 index 0000000..e63fdd9 --- /dev/null +++ b/deploy/desk-staging-modern/deploy-desk-full.sh @@ -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 <&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)" diff --git a/deploy/desk-staging-modern/deploy-staging-only.sh b/deploy/desk-staging-modern/deploy-staging-only.sh index 8e8c632..7f05a9e 100755 --- a/deploy/desk-staging-modern/deploy-staging-only.sh +++ b/deploy/desk-staging-modern/deploy-staging-only.sh @@ -2,6 +2,8 @@ # Desk modern UI — deploy APENAS staging (desk-staging.ligbox.com.br) # NÃO toca em ligbox-ops-platform_frontend_1 (produção desk.ligbox.com.br) # +# Inclui agentic-ops.js/css (Spec 030) — mesmo pacote que promote-staging-to-prod.sh +# # Uso: ./deploy-staging-only.sh [VM122_HOST] # Spec / processo: docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md @@ -12,10 +14,14 @@ PASS="${DESK_SSH_PASS:-805353}" CONTAINER="ligbox-ops-platform-staging_frontend-staging_1" ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)" HTML="${ROOT}/assets" +REMOTE="/tmp/desk-staging-deploy" +MIN_AGENTIC_JS_LINES=1200 -echo "==> Desk staging modern — fonte: ${ROOT}" +echo "==> Desk staging modern + Agentic Ops — fonte: ${ROOT}" echo "==> Container: ${CONTAINER} (NÃO produção)" +sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" "mkdir -p ${REMOTE}" + sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \ "${ROOT}/index.staging.html" \ "${HTML}/topnav.js" \ @@ -24,23 +30,36 @@ sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \ "${HTML}/styles.css" \ "${HTML}/servicos.js" \ "${HTML}/auth.js" \ - "${HOST}:/tmp/desk-staging-deploy/" + "${HTML}/agentic-ops.js" \ + "${HTML}/agentic-ops.css" \ + "${HOST}:${REMOTE}/" sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s <= ${MIN_AGENTIC_JS_LINES})" >&2 + exit 1 +fi +echo "agentic-ops.js linhas: \${AGENTIC_LINES} (Spec 030 OK)" +docker exec \$C grep -q "Squad Command" /usr/share/nginx/html/assets/agentic-ops.js && echo "Squad Command presente" +docker exec \$C grep -c "navigateScope\\|PURGE_BLOCKLIST" /usr/share/nginx/html/assets/servicos.js EOF echo "" diff --git a/deploy/desk-staging-modern/promote-staging-to-prod.sh b/deploy/desk-staging-modern/promote-staging-to-prod.sh index 721a68f..8e8ee87 100755 --- a/deploy/desk-staging-modern/promote-staging-to-prod.sh +++ b/deploy/desk-staging-modern/promote-staging-to-prod.sh @@ -1,6 +1,13 @@ #!/usr/bin/env bash # Promove Desk modern UI (staging validado) → produção desk.ligbox.com.br -# Rollback: index.legacy.html + app.legacy.js no repo +# +# IMPORTANTE: Agentic Ops (Spec 030) via agentic-ops.js + agentic-ops.css DEVE ir +# no mesmo deploy que index.html / desk-modern.css. Deploy só do "casco" Desk sem +# Agentic deixa a página híbrida (incidente 2026-07-02). +# +# Rollback shell: ./rollback-prod-to-legacy.sh +# Processo: docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md +# Agentic: specs/030-agentic-ops-ui/spec.md set -euo pipefail @@ -9,11 +16,13 @@ PASS="${DESK_SSH_PASS:-805353}" CONTAINER="ligbox-ops-platform_frontend_1" ROOT="$(cd "$(dirname "$0")/../../projects/ops-desk/frontend" && pwd)" HTML="${ROOT}/assets" +REMOTE="/tmp/desk-prod-deploy" +MIN_AGENTIC_JS_LINES=1200 -echo "==> Desk modern → PRODUÇÃO" +echo "==> Desk modern + Agentic Ops (Spec 030) → PRODUÇÃO" echo "==> Container: ${CONTAINER}" -sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" "mkdir -p /tmp/desk-prod-deploy" +sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" "mkdir -p ${REMOTE}" sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \ "${ROOT}/index.html" \ @@ -23,24 +32,51 @@ sshpass -p "${PASS}" scp -o StrictHostKeyChecking=no \ "${HTML}/styles.css" \ "${HTML}/servicos.js" \ "${HTML}/auth.js" \ - "${HOST}:/tmp/desk-prod-deploy/" + "${HTML}/modules.js" \ + "${HTML}/tickets-sla.js" \ + "${HTML}/tickets-workspace.js" \ + "${HTML}/tickets-workspace.css" \ + "${HTML}/tickets-detail-panel.js" \ + "${HTML}/agentic-ops.js" \ + "${HTML}/agentic-ops.css" \ + "${HOST}:${REMOTE}/" sshpass -p "${PASS}" ssh -o StrictHostKeyChecking=no "${HOST}" bash -s </dev/null | tail -1 docker exec \$C test -f /usr/share/nginx/html/assets/desk-modern.css && echo "desk-modern.css OK" +docker exec \$C test -f /usr/share/nginx/html/assets/agentic-ops.js && echo "agentic-ops.js OK" +docker exec \$C test -f /usr/share/nginx/html/assets/agentic-ops.css && echo "agentic-ops.css OK" + +AGENTIC_LINES=\$(docker exec \$C wc -l < /usr/share/nginx/html/assets/agentic-ops.js | tr -d ' ') +if [[ "\${AGENTIC_LINES}" -lt ${MIN_AGENTIC_JS_LINES} ]]; then + echo "ERRO: agentic-ops.js tem \${AGENTIC_LINES} linhas (esperado >= ${MIN_AGENTIC_JS_LINES} — Spec 030)" >&2 + exit 1 +fi +echo "agentic-ops.js linhas: \${AGENTIC_LINES} (Spec 030 OK)" + +docker exec \$C grep -q "Squad Command" /usr/share/nginx/html/assets/agentic-ops.js && echo "agentic-ops.js contém Squad Command (Spec 030)" +docker exec \$C grep "agentic-ops" /usr/share/nginx/html/index.html | head -3 +docker exec \$C grep -c "navigateScope\\|shell--v2\\|DeskTopnav" /usr/share/nginx/html/assets/app.js /usr/share/nginx/html/index.html 2>/dev/null | tail -1 EOF echo "" echo "Produção actualizada: https://desk.ligbox.com.br/?desk=1" -echo "Rollback: ./rollback-prod-to-legacy.sh" +echo "Agentic Ops: view agentic-ops — hard refresh (Ctrl+Shift+R)" +echo "Rollback shell: ./rollback-prod-to-legacy.sh" diff --git a/deploy/desk-staging-modern/smoke-desk.sh b/deploy/desk-staging-modern/smoke-desk.sh new file mode 100755 index 0000000..68668ad --- /dev/null +++ b/deploy/desk-staging-modern/smoke-desk.sh @@ -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 diff --git a/deploy/vm112-wizard/deploy-wizard-vm112-smoke.sh b/deploy/vm112-wizard/deploy-wizard-vm112-smoke.sh new file mode 100755 index 0000000..a606595 --- /dev/null +++ b/deploy/vm112-wizard/deploy-wizard-vm112-smoke.sh @@ -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 < 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() diff --git a/docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md b/docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md index 69caa0c..8300492 100644 --- a/docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md +++ b/docs/anais-referencia/20260626_DESK_STAGING_MODERN_PROCESS.md @@ -127,8 +127,29 @@ Ou rebuild: `docker-compose -f docker-compose.agentic-staging.yml build frontend | Backup rollback | `index.legacy.html` · `app.legacy.js` | | Script promote | `deploy/desk-staging-modern/promote-staging-to-prod.sh` | | Script rollback | `deploy/desk-staging-modern/rollback-prod-to-legacy.sh` | +| README deploy | `deploy/desk-staging-modern/README.md` | **Incluído na produção:** modals purge-history + infra-process, tickets-workspace, dns-viewer, filtro Purge eventos, Escopo OPS completo. **Removido:** banners console-v2 inline (link Console no topnav substitui). +--- + +## Actualização 2026-07-02 — Agentic Ops no pacote de promote ✅ + +| Item | Valor | +|------|--------| +| Problema | Deploy parcial (30/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 + diff --git a/docs/vms/README.md b/docs/vms/README.md index 1f7ea28..fddb475 100644 --- a/docs/vms/README.md +++ b/docs/vms/README.md @@ -23,16 +23,16 @@ ligbox-ops-platform/ ## Mapa rápido -| VM/CT | IP | SSH WAN | Papel | Deploy no repo | -|-------|-----|---------|-------|----------------| -| **112** | 10.10.10.112 | :2512 | Wizard onboard + Carbonio mail | `projects/wizard/` | -| **122** | 10.10.10.122 | :2522 | Ops Desk API + worker + UI MVP | `projects/ops-desk/` | -| **123** | 10.10.10.123 | :2523 | FOSSBilling + Odoo + OpenPanel + Console UI | `projects/finance/` | -| **104** | 10.10.10.104 | :2504 | Wazuh SIEM | integração Spec 002, 019 | -| **114** | 10.10.10.114 | — | Traefik (CT) | `docs/network/TRAEFIK_*` | -| **116** | 10.10.10.116 | :2516 | RustDesk relay + portal remoto | — | -| **124** | 10.10.10.124 | :2524 | **Nextcloud Hub** — storage mail (Spec 034) | `deploy/vm116-nextcloud/` | -| **130** | 10.10.10.130 | :2530 | **Spec Hub** Git + Obsidian + Portal | CT130 local | +| VM/CT | IP | SSH WAN | Senha SSH root | Papel | Deploy no repo | +|-------|-----|---------|----------------|-------|----------------| +| **112** | 10.10.10.112 | :2512 | **`@betinplace`** | Wizard onboard + Carbonio mail | `deploy/vm112-wizard/` | +| **122** | 10.10.10.122 | :2522 | `805353` | Ops Desk API + worker + UI MVP | `projects/ops-desk/` | +| **123** | 10.10.10.123 | :2523 | `805353` | FOSSBilling + Odoo + OpenPanel + Console UI | `projects/finance/` | +| **104** | 10.10.10.104 | :2504 | `805353` | Wazuh SIEM | integração Spec 002, 019 | +| **114** | 10.10.10.114 | — | — | Traefik (CT) | `docs/network/TRAEFIK_*` | +| **116** | 10.10.10.116 | :2516 | `805353` | RustDesk relay + portal remoto | — | +| **124** | 10.10.10.124 | :2524 | `805353` | **Nextcloud Hub** — storage mail (Spec 034) | `deploy/vm116-nextcloud/` | +| **130** | 10.10.10.130 | :2530 | `805353` | **Spec Hub** Git + Obsidian + Portal | CT130 local | --- diff --git a/docs/vms/VM112.md b/docs/vms/VM112.md index 1d3a63f..7f33f76 100644 --- a/docs/vms/VM112.md +++ b/docs/vms/VM112.md @@ -4,6 +4,8 @@ |------|-------| | **IP LAN** | `10.10.10.112` | | **SSH WAN** | `95.216.14.146:2512` | +| **SSH root (LAN)** | `sshpass -p '@betinplace' ssh root@10.10.10.112` | +| **Senha root** | **`@betinplace`** (≠ VM122/130 que usam `805353`) | | **Hostname** | vm112-mail-ibytera | | **URLs** | `onboard.ligbox.com.br` · API `:8090` | diff --git a/projects/ops-desk/api/app/action_catalog.py b/projects/ops-desk/api/app/action_catalog.py new file mode 100644 index 0000000..0298e44 --- /dev/null +++ b/projects/ops-desk/api/app/action_catalog.py @@ -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" diff --git a/projects/ops-desk/api/app/auth_routes.py b/projects/ops-desk/api/app/auth_routes.py index 2e28500..8a38b50 100644 --- a/projects/ops-desk/api/app/auth_routes.py +++ b/projects/ops-desk/api/app/auth_routes.py @@ -2,6 +2,8 @@ from __future__ import annotations +import secrets +import string from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request @@ -41,6 +43,22 @@ class UserUpdateRequest(BaseModel): display_name: str | None = None +class UserCreateRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + password: str = Field(min_length=6) + role: str + display_name: str | None = None + phone: str | None = Field(default=None, max_length=32) + active: bool = True + + +class UserCloneRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + password: str | None = Field(default=None, min_length=6) + display_name: str | None = None + active: bool = True + + class ChangePasswordRequest(BaseModel): current_password: str = Field(min_length=1) new_password: str = Field(min_length=8) @@ -185,6 +203,79 @@ def list_users(user: auth.DeskUser = Depends(auth.get_current_user)): return {"users": users} +def _normalize_username(raw: str) -> str: + target = raw.strip() + if target.lower() != "root": + target = target.lower() + return target + + +def _fetch_user_public(conn, username: str) -> dict: + row = conn.execute( + """ + SELECT u.username, u.role, u.display_name, u.active, u.last_login_at, + u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled, + (SELECT COUNT(*) FROM desk_backup_codes b + WHERE b.username = u.username AND b.used_at IS NULL) AS backup_codes_remaining + FROM desk_users u WHERE u.username = ? + """, + (username,), + ).fetchone() + if not row: + raise HTTPException(404, "user not found") + item = auth.user_public_dict(row) + item["totp_enabled"] = bool(row["totp_enabled"]) + item["backup_codes_remaining"] = int(row["backup_codes_remaining"] or 0) + return item + + +@router.post("/users") +def create_user( + body: UserCreateRequest, + user: auth.DeskUser = Depends(auth.get_current_user), +): + if not can_manage_users(user.role): + raise HTTPException(403, "insufficient permissions") + email = body.email.strip().lower() + if "@" not in email: + raise HTTPException(400, "email inválido") + if body.role not in ROLES: + raise HTTPException(400, "invalid role") + if body.role == "super_admin" and user.role != "super_admin": + raise HTTPException(403, "apenas Super Admin pode criar Super Admin") + display = (body.display_name or email.split("@")[0]).strip() or email.split("@")[0] + now = datetime.now(timezone.utc).isoformat() + with auth.db() as conn: + exists = conn.execute( + "SELECT 1 FROM desk_users WHERE username = ? OR email = ?", + (email, email), + ).fetchone() + if exists: + raise HTTPException(409, "utilizador já existe") + conn.execute( + """ + INSERT INTO desk_users + (username, password_hash, role, display_name, email, phone, + mfa_enabled, totp_secret, totp_enabled, active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, NULL, 0, ?, ?, ?) + """, + ( + email, + auth.hash_password(body.password), + body.role, + display, + email, + body.phone, + 1 if body.active else 0, + now, + now, + ), + ) + conn.commit() + item = _fetch_user_public(conn, email) + return {"user": item, "message": "Utilizador criado"} + + @router.patch("/users/{username}") def update_user( username: str, @@ -193,9 +284,7 @@ def update_user( ): if not can_manage_users(user.role): raise HTTPException(403, "insufficient permissions") - target = username.strip() - if target.lower() != "root": - target = target.lower() + target = _normalize_username(username) updates: list[str] = [] params: list[object] = [] if body.role is not None: @@ -226,20 +315,87 @@ def update_user( conn.commit() if cur.rowcount == 0: raise HTTPException(404, "user not found") + item = _fetch_user_public(conn, target) + return {"user": item} + + +@router.delete("/users/{username}") +def delete_user( + username: str, + user: auth.DeskUser = Depends(auth.get_current_user), +): + if not can_manage_users(user.role): + raise HTTPException(403, "insufficient permissions") + target = _normalize_username(username) + if target == "root": + raise HTTPException(400, "não é possível eliminar root") + if target == user.username.lower(): + raise HTTPException(400, "não pode eliminar a sua própria conta") + with auth.db() as conn: row = conn.execute( - """ - SELECT u.username, u.role, u.display_name, u.active, u.last_login_at, - u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled, - (SELECT COUNT(*) FROM desk_backup_codes b - WHERE b.username = u.username AND b.used_at IS NULL) AS backup_codes_remaining - FROM desk_users u WHERE u.username = ? - """, + "SELECT username FROM desk_users WHERE username = ?", (target,), ).fetchone() - item = auth.user_public_dict(row) - item["totp_enabled"] = bool(row["totp_enabled"]) - item["backup_codes_remaining"] = int(row["backup_codes_remaining"] or 0) - return {"user": item} + if not row: + raise HTTPException(404, "user not found") + conn.execute("DELETE FROM desk_backup_codes WHERE username = ?", (target,)) + conn.execute("DELETE FROM desk_users WHERE username = ?", (target,)) + conn.commit() + return {"ok": True, "message": f"Utilizador {target} eliminado"} + + +@router.post("/users/{username}/clone") +def clone_user( + username: str, + body: UserCloneRequest, + user: auth.DeskUser = Depends(auth.get_current_user), +): + if not can_manage_users(user.role): + raise HTTPException(403, "insufficient permissions") + source = _normalize_username(username) + email = body.email.strip().lower() + if "@" not in email: + raise HTTPException(400, "email inválido") + pwd = body.password or "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12)) + now = datetime.now(timezone.utc).isoformat() + with auth.db() as conn: + src = conn.execute( + "SELECT username, role, display_name FROM desk_users WHERE username = ?", + (source,), + ).fetchone() + if not src: + raise HTTPException(404, "utilizador origem não encontrado") + exists = conn.execute( + "SELECT 1 FROM desk_users WHERE username = ? OR email = ?", + (email, email), + ).fetchone() + if exists: + raise HTTPException(409, "utilizador destino já existe") + display = (body.display_name or f"{src['display_name'] or src['username']} (cópia)").strip() + conn.execute( + """ + INSERT INTO desk_users + (username, password_hash, role, display_name, email, phone, + mfa_enabled, totp_secret, totp_enabled, active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, NULL, 0, NULL, 0, ?, ?, ?) + """, + ( + email, + auth.hash_password(pwd), + src["role"], + display, + email, + 1 if body.active else 0, + now, + now, + ), + ) + conn.commit() + item = _fetch_user_public(conn, email) + out = {"user": item, "message": "Utilizador copiado"} + if not body.password: + out["generated_password"] = pwd + return out @router.post("/users/{username}/reset-2fa") @@ -249,9 +405,7 @@ def reset_user_2fa( ): if not can_manage_users(user.role): raise HTTPException(403, "insufficient permissions") - target = username.strip() - if target.lower() != "root": - target = target.lower() + target = _normalize_username(username) if target == "root": raise HTTPException(400, "não é possível resetar 2FA do root por aqui") now = datetime.now(timezone.utc).isoformat() diff --git a/projects/ops-desk/api/app/desk_governance_store.py b/projects/ops-desk/api/app/desk_governance_store.py new file mode 100644 index 0000000..2d89637 --- /dev/null +++ b/projects/ops-desk/api/app/desk_governance_store.py @@ -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 {} diff --git a/projects/ops-desk/api/app/email_relay.py b/projects/ops-desk/api/app/email_relay.py new file mode 100644 index 0000000..6fcc9cd --- /dev/null +++ b/projects/ops-desk/api/app/email_relay.py @@ -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(), + } diff --git a/projects/ops-desk/api/app/email_relay_routes.py b/projects/ops-desk/api/app/email_relay_routes.py new file mode 100644 index 0000000..696ec29 --- /dev/null +++ b/projects/ops-desk/api/app/email_relay_routes.py @@ -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) diff --git a/projects/ops-desk/api/app/governance_routes.py b/projects/ops-desk/api/app/governance_routes.py new file mode 100644 index 0000000..15a8f32 --- /dev/null +++ b/projects/ops-desk/api/app/governance_routes.py @@ -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} diff --git a/projects/ops-desk/api/app/main.py b/projects/ops-desk/api/app/main.py index 1c3ef63..e757a4a 100644 --- a/projects/ops-desk/api/app/main.py +++ b/projects/ops-desk/api/app/main.py @@ -28,9 +28,12 @@ from app.migration.router import router as migration_router from app.billing_routes import router as billing_router from app.security_routes import router as security_router from app.infra_stack_routes import router as infra_stack_router +from app.email_relay_routes import router as email_relay_router from app.vm123.routes import router as vm123_router from app.agents.routes import router as agents_router from app.rbac_routes import router as rbac_router +from app.governance_routes import router as governance_router +from app.ops_inbox_routes import router as ops_inbox_router from app.domain_console_sandbox_routes import router as domain_console_sandbox_router from app.domain_console_routes import router as domain_console_router from app.agents.store import init_agent_schema @@ -132,7 +135,7 @@ _cors_raw = os.getenv( ) _cors_origins = [o.strip() for o in _cors_raw.split(",") if o.strip()] -app = FastAPI(title="Ligbox Ops Platform API", version="0.9.7-spec029-agentic") +app = FastAPI(title="Ligbox Ops Platform API", version="0.13.0-design-system") app.add_middleware( CORSMiddleware, allow_origins=_cors_origins or ["*"], @@ -152,9 +155,12 @@ app.include_router(carbonio_release_router) app.include_router(migration_router) app.include_router(billing_router) app.include_router(infra_stack_router) +app.include_router(email_relay_router) app.include_router(vm123_router) app.include_router(agents_router) app.include_router(rbac_router) +app.include_router(governance_router) +app.include_router(ops_inbox_router) app.include_router(domain_console_sandbox_router) app.include_router(domain_console_router) @@ -213,6 +219,14 @@ def init_db(): from app import agent_bindings agent_bindings.init_schema(conn) + from app import action_catalog + + action_catalog.init_schema(conn) + from app.desk_governance_store import init_governance_schema + from app.ops_inbox_store import init_inbox_schema + + init_governance_schema(conn) + init_inbox_schema(conn) from app.domain_console_sandbox_store import init_schema as init_dcs_schema init_dcs_schema(conn) diff --git a/projects/ops-desk/api/app/ops_inbox_routes.py b/projects/ops-desk/api/app/ops_inbox_routes.py new file mode 100644 index 0000000..b1b94ea --- /dev/null +++ b/projects/ops-desk/api/app/ops_inbox_routes.py @@ -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} diff --git a/projects/ops-desk/api/app/ops_inbox_store.py b/projects/ops-desk/api/app/ops_inbox_store.py new file mode 100644 index 0000000..b11597b --- /dev/null +++ b/projects/ops-desk/api/app/ops_inbox_store.py @@ -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) diff --git a/projects/ops-desk/api/app/permissions.py b/projects/ops-desk/api/app/permissions.py index 3d14e25..aa9c2f1 100644 --- a/projects/ops-desk/api/app/permissions.py +++ b/projects/ops-desk/api/app/permissions.py @@ -19,6 +19,7 @@ BUSINESS_ROLES = frozenset( "security_analyst", "content_editor", "agentic_operator", + "partner", } ) @@ -51,6 +52,7 @@ ROLE_LABELS: dict[str, str] = { "security_analyst": "Segurança / SOC", "content_editor": "Conteúdo / CMS", "agentic_operator": "Operador Agentes IA", + "partner": "Partner", "api_service": "API Service", "agent_system": "Agent System", } @@ -230,6 +232,11 @@ def can_manage_users(role: str) -> bool: return role == "super_admin" +def can_approve_registration(role: str) -> bool: + """Aprovar/rejeitar pedidos de cadastro — Spec 039 (SU + CO).""" + return role in ("super_admin", "ops_lead") + + def can_manage_vm112_domains(role: str) -> bool: """Admin Desk — domínios orquestrados VM112 (Spec 017).""" return role in ("super_admin", "ops_lead", "devops") @@ -302,7 +309,8 @@ def can_openpanel_provision(role: str) -> bool: def can_openpanel_delete(role: str) -> bool: - return role in ("super_admin", "devops") + """Deletar instância OpenPanel — Spec 039: apenas SU + CO.""" + return role in ("super_admin", "ops_lead") def roles_meta() -> dict: diff --git a/projects/ops-desk/api/app/rbac_matrix.py b/projects/ops-desk/api/app/rbac_matrix.py index 4971f7a..db3c84a 100644 --- a/projects/ops-desk/api/app/rbac_matrix.py +++ b/projects/ops-desk/api/app/rbac_matrix.py @@ -468,6 +468,8 @@ def matrix_export(conn) -> dict[str, Any]: "tabs": [ {"id": "overview", "label": "Visão da função", "type": "overview", "hint": "Desk + software + APIs da função seleccionada"}, + {"id": "quem-faz-o-que", "label": "Quem faz o quê", "type": "executive_map", + "hint": "Mapa executivo Spec 039 — editar permissões por acção"}, {"id": "vm122", "label": "Matriz Desk", "type": "desk_modules", "hint": "VM122 — módulos internos da plataforma"}, {"id": "software", "label": "Software & Infra", "type": "software", @@ -476,6 +478,9 @@ def matrix_export(conn) -> dict[str, Any]: "hint": "Bindings Odoo-style — grupos, roles, permissões"}, {"id": "agents", "label": "Agentes IA", "type": "agents", "hint": "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": [ {"id": "vm122", "label": "Desk VM122", "host": "10.10.10.122", "desc": "Módulos internos"}, diff --git a/projects/ops-desk/api/app/rbac_routes.py b/projects/ops-desk/api/app/rbac_routes.py index 4a635eb..817ba22 100644 --- a/projects/ops-desk/api/app/rbac_routes.py +++ b/projects/ops-desk/api/app/rbac_routes.py @@ -8,7 +8,7 @@ import sqlite3 from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field -from app import agent_bindings, auth +from app import agent_bindings, auth, action_catalog from app.rbac_matrix import matrix_export router = APIRouter(prefix="/api/v1", tags=["rbac"]) @@ -77,6 +77,45 @@ class GovernanceCapPatch(BaseModel): enabled: bool +class ActionOverridePatch(BaseModel): + action_id: str = Field(..., min_length=5, max_length=128) + role_id: str = Field(..., min_length=2, max_length=32) + level: str = Field(..., pattern="^(full|read|link|api|approve|system|none)$") + reset: bool = False + + +@router.get("/rbac/actions") +def rbac_actions_catalog( + user: auth.DeskUser = Depends(auth.get_current_user), + conn: sqlite3.Connection = Depends(_db), +): + """Spec 039 — mapa executivo + catálogo de acções (defaults + overrides).""" + _require_matrix_view(user) + return action_catalog.export_catalog(conn, edit_enabled=_access_matrix_edit_enabled()) + + +@router.patch("/rbac/actions/override") +def patch_action_override( + body: ActionOverridePatch, + user: auth.DeskUser = Depends(auth.get_current_user), + conn: sqlite3.Connection = Depends(_db), +): + """Alterar permissão de uma acção × função (persiste + audit).""" + _require_matrix_edit(user) + try: + result = action_catalog.set_action_override( + conn, + action_id=body.action_id, + role_id=body.role_id, + level=body.level, + username=user.username, + reset=body.reset, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return result + + @router.patch("/rbac/agent-bindings") def patch_agent_binding( body: AgentBindingPatch, diff --git a/projects/ops-desk/api/app/registration_routes.py b/projects/ops-desk/api/app/registration_routes.py index 884f347..d4be9c2 100644 --- a/projects/ops-desk/api/app/registration_routes.py +++ b/projects/ops-desk/api/app/registration_routes.py @@ -6,13 +6,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from app import auth, desk_tickets, mail_notify, registration_store -from app.permissions import ROLES, can_manage_users +from app.permissions import ASSIGNABLE_ROLES, can_approve_registration from app import ntfy_notify from app.totp_util import otpauth_uri router = APIRouter(prefix="/api/v1/auth", tags=["registration"]) -ASSIGNABLE_ROLES = frozenset({"ops_lead", "technician", "noc"}) +REGISTRATION_ASSIGNABLE_ROLES = ASSIGNABLE_ROLES class RegisterRequest(BaseModel): @@ -63,8 +63,14 @@ def register(body: RegisterRequest): } +def _require_registration_approver(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser: + if not can_approve_registration(user.role): + raise HTTPException(403, "forbidden") + return user + + @router.get("/registration-requests") -def list_registration_requests(user: auth.DeskUser = Depends(auth.require_roles("super_admin"))): +def list_registration_requests(user: auth.DeskUser = Depends(_require_registration_approver)): with auth.db() as conn: items = registration_store.list_requests(conn) pending = sum(1 for i in items if i["status"] == "pending") @@ -75,10 +81,12 @@ def list_registration_requests(user: auth.DeskUser = Depends(auth.require_roles( def approve_registration( request_id: int, body: ApproveRequest, - user: auth.DeskUser = Depends(auth.require_roles("super_admin")), + user: auth.DeskUser = Depends(_require_registration_approver), ): - if body.role not in ASSIGNABLE_ROLES: - raise HTTPException(400, f"role must be one of: {', '.join(sorted(ASSIGNABLE_ROLES))}") + if body.role not in REGISTRATION_ASSIGNABLE_ROLES: + raise HTTPException( + 400, f"role must be one of: {', '.join(sorted(REGISTRATION_ASSIGNABLE_ROLES))}" + ) try: with auth.db() as conn: row = registration_store.approve_request(conn, request_id, body.role, user.username) @@ -107,7 +115,7 @@ def approve_registration( def reject_registration( request_id: int, body: RejectRequest, - user: auth.DeskUser = Depends(auth.require_roles("super_admin")), + user: auth.DeskUser = Depends(_require_registration_approver), ): try: with auth.db() as conn: diff --git a/projects/ops-desk/api/app/stack_health.py b/projects/ops-desk/api/app/stack_health.py index 198ce7e..46ef638 100644 --- a/projects/ops-desk/api/app/stack_health.py +++ b/projects/ops-desk/api/app/stack_health.py @@ -27,18 +27,35 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat() +def _normalize_expect_status(expect_status: int | tuple[int, ...] | list[int]) -> tuple[int, ...]: + """Garante tupla de códigos HTTP — evita bug Python `(200)` vs `(200,)`. + + Ver Spec 033 § Incidente probe `expect_status` (2026-06-29). + """ + if isinstance(expect_status, int): + return (expect_status,) + if isinstance(expect_status, list): + return tuple(int(x) for x in expect_status) + if isinstance(expect_status, tuple): + if len(expect_status) == 1 and isinstance(expect_status[0], int): + return expect_status + return tuple(int(x) for x in expect_status) + raise TypeError(f"expect_status inválido: {type(expect_status)!r}") + + def _probe_http( *, url: str, timeout: float = 8.0, verify: bool = True, headers: dict[str, str] | None = None, - expect_status: tuple[int, ...] = (200), + expect_status: int | tuple[int, ...] | list[int] = (200,), ) -> dict[str, Any]: try: + codes = _normalize_expect_status(expect_status) with httpx.Client(timeout=timeout, verify=verify, follow_redirects=True) as client: res = client.get(url, headers=headers or {}) - ok = res.status_code in expect_status + ok = res.status_code in codes return { "ok": ok, "status": "online" if ok else "check", @@ -60,6 +77,15 @@ def _probe_redis(redis_url: str) -> dict[str, Any]: return {"ok": False, "status": "down", "detail": str(exc)} +def _probe_email_relay() -> dict[str, Any]: + try: + from app import email_relay + + return email_relay.probe_stack() + except Exception as exc: + return {"ok": False, "status": "down", "detail": str(exc)} + + def build_stack_catalog() -> list[dict[str, Any]]: """Catálogo estático — apps, APIs e software do stack Ligbox.""" redis_url = os.getenv("REDIS_URL", "redis://redis:6379/0") @@ -116,7 +142,7 @@ def build_stack_catalog() -> list[dict[str, Any]]: "url": f"{VM112_API}/api/admin/domains", "probe": lambda: _probe_http( url=f"{VM112_API}/api/onboarding/health", - expect_status=(200), + expect_status=(200,), ), }, ], @@ -222,6 +248,16 @@ def build_stack_catalog() -> list[dict[str, Any]]: "url": "/api/v1/infra/purge-auth-domains", "probe": lambda: {"ok": True, "status": "online", "detail": "módulo local"}, }, + { + "id": "vm122-email-relay", + "title": "Email Relay (Postfix)", + "spec": "004", + "kind": "sw", + "icon": "📨", + "accent": "slate", + "url": "/api/v1/infra/email-relay/status", + "probe": _probe_email_relay, + }, ], }, { diff --git a/projects/ops-desk/api/tests/test_action_catalog_039.py b/projects/ops-desk/api/tests/test_action_catalog_039.py new file mode 100644 index 0000000..6a1ca07 --- /dev/null +++ b/projects/ops-desk/api/tests/test_action_catalog_039.py @@ -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() diff --git a/projects/ops-desk/api/tests/test_stack_health_033.py b/projects/ops-desk/api/tests/test_stack_health_033.py new file mode 100644 index 0000000..0a497be --- /dev/null +++ b/projects/ops-desk/api/tests/test_stack_health_033.py @@ -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() diff --git a/projects/ops-desk/frontend/assets/access-control-hub.js b/projects/ops-desk/frontend/assets/access-control-hub.js new file mode 100644 index 0000000..07983bf --- /dev/null +++ b/projects/ops-desk/frontend/assets/access-control-hub.js @@ -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: ``, + userCheck: ``, + snowflake: ``, + shield: ``, + plus: ``, + chevronDown: ``, + filter: ``, + search: ``, + more: ``, + star: ``, + close: ``, + edit: ``, + freeze: ``, + copy: ``, + lock: ``, + shieldCheck: ``, + usersGroup: ``, + trash: ``, + checkCircle: ``, + infoShield: ``, + }; + + 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 ` +
+
${ICONS[iconKey]}
+
+ ${esc(value)} + ${esc(label)} + ${esc(sub)} +
+
`; + } + + function quickAction(iconKey, label, dataAttr) { + return ` + `; + } + + function esc(s) { + return String(s ?? '').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 ` +
+ + +
+ + +
+
`; + } + + function subnavHtml() { + return ` + `; + } + + function kpiSectionHtml() { + return ` +
+
+ ${subnavHtml()} +
${createSplitButton()}
+
+
+ ${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')} +
+
`; + } + + function filterField(label, inner) { + return ` + `; + } + + function userTableRow(u) { + const rm = roleMeta(u.role); + const sel = selectedUser === u.username ? ' ach-table-row--selected' : ''; + const tfaHtml = u.totp_enabled + ? `${ICONS.checkCircle}` + : ``; + const statusHtml = u.active + ? `Activo` + : `${ICONS.snowflake} Congelado`; + return ` + + +
+ ${esc(initials(u.display_name || u.username))} + + ${esc(u.display_name || u.username)} + + +
+ + + ${esc(rm.code)} + ${esc(rm.label)} + + ${esc(rm.group)} + ${statusHtml} + ${tfaHtml} + ${fmtDateTime(u.last_login_at)} + + + + `; + } + + function usersTable(list) { + return ` +
+ + + + + + + + + + + + + + ${list.length + ? list.map(userTableRow).join('') + : ''} + +
UtilizadorPerfilGrupoEstado2FAÚltimo acessoAcções
Nenhum utilizador encontrado.
+
+
+ Mostrando ${list.length} de ${users.length} +
`; + } + + 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 ? ` +
+ ${ICONS.infoShield} + A mostrar apenas utilizadores da função ${esc(roleLockMeta.label)} (seleccionada na Matriz). Para ver todos, escolha outra função na barra lateral ou use o filtro Perfil abaixo. +
` : ''; + return ` +
+
+ ${kpiSectionHtml()} + ${roleLockBanner} +
+ + ${filterField('Perfil', ` + `)} + ${filterField('Grupo', ` + `)} + ${filterField('Estado', ` + `)} + +
+ ${usersTable(list)} +
+ +
`; + } + + function detailPanel() { + const u = users.find((x) => x.username === selectedUser); + if (!u) { + return `

Seleccione um utilizador

`; + } + 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 = ` +
+
+ Perfil + + ${esc(rm.code)} + ${esc(rm.label)} + +
+
+ Grupo + ${esc(rm.group)} +
+
+ Telefone + ${esc(u.phone || '—')} +
+
+ Criado em + ${fmtDateTime(u.created_at)} +
+
+ Último acesso + ${fmtDateTime(u.last_login_at)} +
+
+ 2FA + + ${tfaOn + ? `${ICONS.checkCircle} Activado` + : 'Não activo'} + +
+
`; + } else if (detailTab === 'permissions') { + tabBody = `

Permissões herdadas do perfil ${esc(rm.label)}. Edição avançada na aba «Quem faz o quê».

+

Abrir mapa de permissões

`; + } else { + tabBody = `

A carregar…

`; + loadAudit(u.username); + } + + return ` +
+
+
+
${esc(initials(u.display_name || u.username))}
+
+
+ ${esc(u.display_name || u.username)} + ${u.active ? 'Activo' : 'Congelado'} +
+

${esc(u.email || u.username)}

+
+
+
+ + +
+
+ +
${tabBody}
+
+

Ações rápidas

+
+ ${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')} +
+ ${!isRoot ? ` + ` : ''} +
+
+ ${ICONS.infoShield} + As alterações realizadas aqui são registadas em audit log e não podem ser desfeitas. +
+
`; + } + + 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) => `
${esc(e.summary)}
${fmtDate(e.created_at)}
`).join('') + : '

Sem actividades registadas.

'; + } catch (e) { + el.innerHTML = `

Erro: ${esc(e.message)}

`; + } + } + + function capabilitiesPanel() { + if (!window.DeskAccessControlPanel?.paint) { + return '

Módulo de capacidades indisponível.

'; + } + return '
'; + } + + function render() { + if (!host) return; + const body = subTab === 'users' ? usersPanel() : capabilitiesPanel(); + host.innerHTML = ` +
+ ${subTab === 'capabilities' ? `
${subnavHtml()}
` : ''} + ${body} +
+ ${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 = ` +
+
+

${esc(title)}

+

${esc(message)}

+

Acção registada no audit log.

+
+ + +
+
+
`; + 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 = '

Sem permissão — apenas Super Admin gere acessos.

'; + return; + } + host.innerHTML = '

Carregando Controle de acesso…

'; + try { + await loadData(); + if (!selectedUser && users.length) selectedUser = users[0].username; + render(); + } catch (e) { + host.innerHTML = `

Erro: ${esc(e.message)}

`; + } + } + + 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 }; +})(); diff --git a/projects/ops-desk/frontend/assets/access-control-panel.js b/projects/ops-desk/frontend/assets/access-control-panel.js new file mode 100644 index 0000000..70d2e8b --- /dev/null +++ b/projects/ops-desk/frontend/assets/access-control-panel.js @@ -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, '>'); + } + + 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 ` +
+ ${esc(a.label)}${partial ? ` (${esc(lv)})` : ''} + +
`; + }).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 ` +
+
+ ${esc(code)} +
+

Capacidades da função

+

Função ${esc(roleMeta?.label || selectedRole)}. ${editHint}

+
+
+
${rows}
+

Abrir mapa completo «Quem faz o quê»

+
`; + } + + 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 = '

Carregando capacidades…

'; + let levels = {}; + try { + levels = await loadLevelsForRole(selectedRole); + } catch (e) { + host.innerHTML = `

Catálogo indisponível: ${esc(e.message)}

`; + 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?.(), + }; +})(); diff --git a/projects/ops-desk/frontend/assets/access-control-support.css b/projects/ops-desk/frontend/assets/access-control-support.css new file mode 100644 index 0000000..b36c247 --- /dev/null +++ b/projects/ops-desk/frontend/assets/access-control-support.css @@ -0,0 +1,1185 @@ +/* Controle de acesso Suporte — identidade ACS (Spec UI Roger 2026-06-29) */ + +:root { + --acs-blue: #2b6cb0; + --acs-blue-dark: #1a4f8a; + --acs-blue-light: #ebf4ff; + --acs-green: #38a169; + --acs-green-bg: #e6f6ed; + --acs-red: #e53e3e; + --acs-red-bg: #fde8e8; + --acs-gray-bg: #f7fafc; + --acs-card-shadow: 0 4px 18px rgba(26, 79, 138, 0.08); +} + +.shell--v2 .main.main--acs > .page-header { + display: none !important; +} + +#view-admin.view.active { + max-width: none; + padding: 0; +} + +.acs-page { + position: relative; + z-index: 0; + margin: 0; + min-height: calc(100vh - 58px); + background: var(--acs-gray-bg); +} + +.acs-hero { + position: relative; + padding: 1.75rem 1.75rem 2.25rem; + background: linear-gradient(135deg, var(--acs-blue-dark) 0%, var(--acs-blue) 55%, #3182ce 100%); + color: #fff; + clip-path: polygon(0 0, 100% 0, 100% 82%, 0 100%); + margin-bottom: 0.5rem; +} + +.acs-hero h1 { + margin: 0; + font-size: 1.65rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.acs-breadcrumb { + margin: 0.45rem 0 0; + font-size: 0.78rem; + opacity: 0.9; +} + +.acs-breadcrumb a { + color: #fff; + text-decoration: none; +} + +.acs-breadcrumb a:hover { + text-decoration: underline; +} + +.acs-body { + padding: 0 1.5rem 2rem; + margin-top: -1.25rem; +} + +.acs-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 1rem; + align-items: start; +} + +.acs-kpi-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.65rem; + margin-bottom: 0.85rem; +} + +.acs-card { + background: #fff; + border-radius: 12px; + border: 1px solid #e2e8f0; + box-shadow: var(--acs-card-shadow); +} + +.acs-kpi { + padding: 0.85rem 1rem; + text-align: center; +} + +.acs-kpi-val { + display: block; + font-size: 1.55rem; + font-weight: 700; + color: var(--acs-blue); + line-height: 1.1; +} + +.acs-kpi-label { + display: block; + margin-top: 0.2rem; + font-size: 0.68rem; + font-weight: 650; + color: #718096; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.acs-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + align-items: flex-end; + padding: 0.85rem 1rem; + margin-bottom: 0.85rem; +} + +.acs-search { + flex: 1 1 220px; + position: relative; +} + +.acs-search input { + width: 100%; + padding: 0.5rem 0.75rem 0.5rem 2.1rem; + border: 1px solid #cbd5e0; + border-radius: 8px; + font: inherit; + font-size: 0.85rem; + background: #fff; +} + +.acs-search::before { + content: "⌕"; + position: absolute; + left: 0.65rem; + top: 50%; + transform: translateY(-50%); + color: #a0aec0; + font-size: 0.95rem; +} + +.acs-filter { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.72rem; + font-weight: 600; + color: #718096; +} + +.acs-filter select { + padding: 0.48rem 0.55rem; + border: 1px solid #cbd5e0; + border-radius: 8px; + font: inherit; + font-size: 0.82rem; + min-width: 120px; +} + +.acs-btn-primary { + appearance: none; + border: none; + background: var(--acs-blue); + color: #fff; + font: inherit; + font-size: 0.82rem; + font-weight: 600; + padding: 0.5rem 0.9rem; + border-radius: 8px; + cursor: pointer; + white-space: nowrap; +} + +.acs-btn-primary:hover { + background: var(--acs-blue-dark); +} + +.acs-btn-ghost { + appearance: none; + border: 1px solid #cbd5e0; + background: #fff; + color: var(--acs-blue); + font: inherit; + font-size: 0.82rem; + font-weight: 600; + padding: 0.48rem 0.85rem; + border-radius: 8px; + cursor: pointer; +} + +.acs-table-card { + overflow: hidden; +} + +.acs-table-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid #edf2f7; +} + +.acs-table-head h3 { + margin: 0; + font-size: 0.95rem; + font-weight: 700; + color: #1a202c; +} + +.acs-table-wrap { + overflow-x: auto; + overflow-y: visible; + -webkit-overflow-scrolling: touch; +} + +.acs-page--matrix .acs-table-card { + overflow: visible; +} + +.acs-table { + width: 100%; + border-collapse: collapse; + font-size: 0.84rem; +} + +.acs-table th { + text-align: left; + padding: 0.55rem 1rem; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #718096; + background: #f8fafc; + border-bottom: 1px solid #edf2f7; +} + +.acs-table td { + padding: 0.65rem 1rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; +} + +.acs-table tbody tr.acs-row { + cursor: pointer; + transition: background 0.12s ease; +} + +.acs-table tbody tr.acs-row:hover, +.acs-table tbody tr.acs-row.selected { + background: var(--acs-blue-light); +} + +.acs-user-cell { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.acs-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 0.75rem; + font-weight: 700; + color: #fff; + background: linear-gradient(135deg, var(--acs-blue), #4299e1); + flex-shrink: 0; +} + +.acs-avatar-lg { + width: 64px; + height: 64px; + font-size: 1.1rem; +} + +.acs-user-name { + display: block; + font-weight: 650; + color: #1a202c; +} + +.acs-user-sub { + display: block; + font-size: 0.76rem; + color: #718096; +} + +.acs-status { + display: inline-block; + padding: 0.22rem 0.55rem; + border-radius: 6px; + font-size: 0.72rem; + font-weight: 650; +} + +.acs-status--on { + background: var(--acs-green-bg); + color: var(--acs-green); +} + +.acs-status--off { + background: var(--acs-red-bg); + color: var(--acs-red); +} + +.acs-table-foot { + padding: 0.6rem 1rem; + font-size: 0.76rem; + color: #718096; + border-top: 1px solid #edf2f7; +} + +/* Rail — lista rápida de usuários */ +.acs-rail { + position: sticky; + top: 0.75rem; +} + +.acs-rail-card { + padding: 0.85rem; +} + +.acs-rail-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.55rem; +} + +.acs-rail-head h4 { + margin: 0; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #718096; +} + +.acs-rail-count { + font-size: 0.68rem; + color: #a0aec0; +} + +.acs-rail-search input { + width: 100%; + padding: 0.45rem 0.6rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 0.78rem; + margin-bottom: 0.55rem; +} + +.acs-rail-list { + list-style: none; + margin: 0; + padding: 0; + max-height: calc(100vh - 280px); + overflow-y: auto; +} + +.acs-rail-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.35rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.8rem; +} + +.acs-rail-item:hover, +.acs-rail-item.selected { + background: var(--acs-blue-light); +} + +.acs-rail-item .acs-avatar { + width: 28px; + height: 28px; + font-size: 0.62rem; +} + +/* Drawer / modal detalhe */ +.team-drawer.acs-drawer .team-drawer-panel { + width: min(100%, 480px); + border-radius: 12px 0 0 12px; + border-left: none; + box-shadow: -8px 0 32px rgba(26, 79, 138, 0.15); +} + +.team-drawer.acs-drawer .team-drawer-header { + background: linear-gradient(90deg, var(--acs-blue-light), #fff); + border-bottom: 1px solid #e2e8f0; +} + +.team-drawer.acs-drawer .team-drawer-header h3 { + color: var(--acs-blue-dark); +} + +.acs-field { + display: block; + margin-bottom: 0.75rem; +} + +.acs-field label { + display: block; + font-size: 0.72rem; + font-weight: 650; + color: #4a5568; + margin-bottom: 0.3rem; +} + +.acs-field input, +.acs-field select { + width: 100%; + padding: 0.5rem 0.65rem; + border: 1px solid #cbd5e0; + border-radius: 8px; + font: inherit; + font-size: 0.85rem; +} + +.acs-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.55rem 0; + border-bottom: 1px solid #edf2f7; + font-size: 0.84rem; +} + +.acs-toggle { + position: relative; + width: 42px; + height: 24px; + flex-shrink: 0; +} + +.acs-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.acs-toggle-slider { + position: absolute; + inset: 0; + background: #cbd5e0; + border-radius: 999px; + cursor: pointer; + transition: background 0.2s; +} + +.acs-toggle-slider::before { + content: ""; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); +} + +.acs-toggle input:checked + .acs-toggle-slider { + background: var(--acs-blue); +} + +.acs-toggle input:checked + .acs-toggle-slider::before { + transform: translateX(18px); +} + +.acs-drawer-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #edf2f7; +} + +.acs-drawer-actions .btn-primary { + background: var(--acs-blue); + border-color: var(--acs-blue); +} + +@media (max-width: 960px) { + .acs-layout { + grid-template-columns: 1fr; + } + .acs-rail { + order: -1; + position: static; + } + .acs-kpi-row { + grid-template-columns: repeat(2, 1fr); + } + .acs-matrix-split { + grid-template-columns: 1fr; + } +} + +/* Embutido na Matriz de Acesso */ +.acs-page--embedded { + margin: 0; + min-height: 0; + background: transparent; +} + +.acs-page--matrix .acs-body { + padding: 0; +} + +.acs-page--matrix { + overflow: visible; +} + +.acs-matrix-users { + min-width: 0; + overflow: visible; +} + +.acs-matrix-users-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + margin: 0.35rem 0 0.85rem; + padding: 0.65rem 0.9rem; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; +} + +.acs-matrix-users-title { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #718096; + flex: 0 0 auto; +} + +.acs-matrix-users-meta { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-size: 0.84rem; + color: #2d3748; + min-width: 0; +} + +.acs-matrix-users-count { + color: #718096; + font-size: 0.78rem; + white-space: nowrap; +} + +.acs-matrix-users-count::before { + content: "·"; + margin-right: 0.5rem; + color: #cbd5e0; +} + +.acs-kpi-row--matrix { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + margin-bottom: 0.65rem; +} + +.acs-page--matrix .acs-kpi { + padding: 0.65rem 0.5rem; +} + +.acs-page--matrix .acs-kpi-val { + font-size: 1.25rem; +} + +.acs-page--matrix .acs-kpi-label { + font-size: 0.62rem; +} + +.acs-page--matrix .acs-layout { + gap: 0.65rem; +} + +.acs-page--matrix .acs-rail { + position: static; +} + +@media (min-width: 1100px) { + .acs-kpi-row--matrix { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.acs-matrix-wrap { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.acs-matrix-split { + display: grid; + grid-template-columns: minmax(260px, 300px) minmax(0, 1fr); + gap: 1rem; + align-items: start; + min-width: 0; +} + +.acs-perms-card { + padding: 1rem; + position: sticky; + top: 0.5rem; +} + +.acs-perms-head { + display: flex; + gap: 0.75rem; + align-items: flex-start; + margin-bottom: 0.85rem; +} + +.acs-perms-head h3 { + margin: 0; + font-size: 1rem; + color: #1a202c; +} + +.acs-perms-desc { + margin: 0.35rem 0 0; + font-size: 0.78rem; + color: #718096; + line-height: 1.45; +} + +.acs-role-code { + display: grid; + place-items: center; + min-width: 42px; + height: 42px; + border-radius: 10px; + background: linear-gradient(135deg, #2b6cb0, #4299e1); + color: #fff; + font-weight: 800; + font-size: 0.85rem; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.acs-role-code-inline { + font-size: 0.75rem; + padding: 0.1rem 0.35rem; + border-radius: 4px; + background: #ebf4ff; + color: #2b6cb0; +} + +.acs-perm-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.45rem 0; + border-bottom: 1px solid #edf2f7; + font-size: 0.82rem; +} + +.acs-perm-label { + color: #2d3748; +} + +.acs-toggle--readonly input:disabled + .acs-toggle-slider { + opacity: 1; + cursor: default; +} + +.acs-env-block { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid #edf2f7; +} + +.acs-env-block h4 { + margin: 0 0 0.45rem; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #718096; +} + +.acs-env-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.acs-env-chip { + font-size: 0.72rem; + padding: 0.25rem 0.5rem; + border-radius: 6px; + background: #ebf4ff; + color: #2b6cb0; + font-weight: 600; +} + +.acs-env-chip--muted { + background: #f7fafc; + color: #a0aec0; +} + +.acs-code-legend { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.acs-code-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; + padding: 0.2rem 0.45rem; + border-radius: 6px; + background: #f7fafc; + border: 1px solid #e2e8f0; + color: #4a5568; +} + +.acs-code-pill code { + font-weight: 700; + color: #2b6cb0; +} + +.acs-code-pill--root code { + color: #c05621; +} + +.acs-matrix-role-banner { + display: none; +} + +#access-matrix-content .am-access-control-host { + min-width: 0; + overflow: visible; + padding: 0.35rem 0.15rem 0.5rem 0; +} + +#access-matrix-content .am-wrap { + min-width: 0; + overflow: visible; +} + +#access-matrix-content .am-panel--access-control .am-access-control-host .acs-page--embedded { + padding-top: 0; +} + +/* Sub-abas Controle de acesso */ +.acs-subnav { + display: flex; + gap: 0.35rem; + margin-bottom: 1rem; + padding: 0.25rem; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + width: fit-content; + max-width: 100%; + flex-wrap: wrap; +} + +.acs-subtab { + border: none; + background: transparent; + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + color: #64748b; + cursor: pointer; +} + +.acs-subtab:hover { + background: #f1f5f9; + color: #334155; +} + +.acs-subtab.active { + background: #4a152c; + color: #fff; +} + +.acs-capabilities-wrap { + max-width: 520px; +} + +/* Gestão de utilizadores (um-*) */ +.um-panel { + min-width: 0; +} + +.um-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.um-head h3 { + margin: 0; + font-size: 1.1rem; + color: #1e293b; +} + +.um-desc { + margin: 0.35rem 0 0; + font-size: 0.82rem; + color: #64748b; +} + +.um-btn-primary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.55rem 1rem; + border: none; + border-radius: 8px; + background: #4a152c; + color: #fff; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.um-btn-primary:hover { + background: #3d1124; +} + +.um-btn-ghost { + padding: 0.55rem 1rem; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + color: #374151; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.um-kpi-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.65rem; + margin-bottom: 0.85rem; +} + +.um-kpi { + padding: 0.75rem 1rem; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + text-align: center; +} + +.um-kpi strong { + display: block; + font-size: 1.35rem; + color: #2563eb; +} + +.um-kpi span { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; +} + +.um-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + align-items: flex-end; + margin-bottom: 0.85rem; + padding: 0.85rem 1rem; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; +} + +.um-search { + flex: 1; + min-width: 180px; + padding: 0.45rem 0.65rem; + border: 1px solid #cbd5e0; + border-radius: 8px; + font-size: 0.85rem; +} + +.um-toolbar label { + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.72rem; + color: #64748b; +} + +.um-toolbar select { + padding: 0.4rem 0.55rem; + border: 1px solid #cbd5e0; + border-radius: 8px; + font-size: 0.82rem; +} + +.um-table-wrap { + overflow-x: auto; + overscroll-behavior-x: contain; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; +} + +.um-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.um-table th { + text-align: left; + padding: 0.65rem 0.85rem; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.um-table td { + padding: 0.7rem 0.85rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; +} + +.um-sub { + display: block; + font-size: 0.72rem; + color: #94a3b8; +} + +.um-role-badge { + display: inline-block; + padding: 0.12rem 0.4rem; + border-radius: 6px; + background: #dbeafe; + color: #1d4ed8; + font-size: 0.68rem; + font-weight: 700; +} + +.um-status--on { + color: #15803d; + font-weight: 600; +} + +.um-status--off { + color: #b91c1c; + font-weight: 600; +} + +.um-actions { + white-space: nowrap; +} + +.um-act { + border: none; + background: transparent; + color: #4a152c; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + padding: 0.2rem 0.35rem; +} + +.um-act:hover { + text-decoration: underline; +} + +.um-act--danger { + color: #b91c1c; +} + +.um-empty { + padding: 2rem; + text-align: center; + color: #64748b; + background: #fff; + border: 1px dashed #cbd5e0; + border-radius: 10px; +} + +.um-modal-root { + position: fixed; + inset: 0; + z-index: 1600; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.um-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + z-index: 1599; +} + +.um-modal-card { + position: relative; + z-index: 1601; + width: min(440px, 100%); + max-height: 90vh; + overflow-y: auto; + background: #fff; + border-radius: 12px; + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2); +} + +.um-modal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.15rem 0.5rem; +} + +.um-modal-head h3 { + margin: 0; + font-size: 1.05rem; +} + +.um-close { + border: none; + background: transparent; + font-size: 1.35rem; + cursor: pointer; + color: #64748b; +} + +.um-form { + padding: 0 1.15rem 1.15rem; +} + +.um-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-bottom: 0.85rem; + font-size: 0.82rem; + color: #475569; +} + +.um-field input, +.um-field select { + padding: 0.45rem 0.65rem; + border: 1px solid #cbd5e0; + border-radius: 8px; + font-size: 0.85rem; +} + +.um-hint { + font-size: 0.72rem; + color: #94a3b8; +} + +.um-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.85rem; + font-size: 0.85rem; +} + +.um-meta { + margin: 0 0 0.75rem; + font-size: 0.78rem; + color: #64748b; +} + +.um-msg--err { + color: #b91c1c; + font-size: 0.82rem; +} + +.um-modal-foot { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding-top: 0.5rem; +} + +@media (max-width: 768px) { + .um-kpi-row { + grid-template-columns: repeat(2, 1fr); + } + .um-head { + flex-direction: column; + } +} + +body.um-scroll-lock { + overflow: hidden; +} + +.um-panel--embedded .um-head--embedded { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.65rem; +} + +.um-panel--embedded .acs-matrix-users-meta { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.25rem; + font-size: 0.82rem; +} + +.um-btn-primary--sm, +.um-btn-ghost--sm { + padding: 0.4rem 0.75rem; + font-size: 0.78rem; +} + +.um-mini-stats { + display: flex; + gap: 1rem; + margin-bottom: 0.65rem; + font-size: 0.75rem; + color: #64748b; +} + +.um-mini-stats strong { + color: #2563eb; +} + +.um-toolbar--compact { + margin-bottom: 0.65rem; + padding: 0.55rem 0.65rem; +} + +.um-toolbar--compact .um-search { + min-width: 120px; +} + +.um-table--compact th, +.um-table--compact td { + padding: 0.45rem 0.55rem; + font-size: 0.78rem; +} + +.um-hint--block { + display: block; + margin-bottom: 0.75rem; + padding: 0.45rem 0.65rem; + background: #f0fdf4; + border-radius: 8px; + color: #166534; +} + diff --git a/projects/ops-desk/frontend/assets/access-matrix.css b/projects/ops-desk/frontend/assets/access-matrix.css index 8afc872..5317fe8 100644 --- a/projects/ops-desk/frontend/assets/access-matrix.css +++ b/projects/ops-desk/frontend/assets/access-matrix.css @@ -48,6 +48,13 @@ border-bottom: 1px solid var(--am-border); } +#access-matrix-content .am-header--toolbar { + justify-content: flex-end; + padding: 0; + border-bottom: none; + min-height: 0; +} + #access-matrix-content .am-page-title { margin: 0; font-size: 1.35rem; @@ -128,6 +135,8 @@ top: 0.5rem; max-height: calc(100vh - 200px); overflow: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: auto; padding: 0.85rem; background: linear-gradient(180deg, #fffdf9 0%, #faf6f0 100%); border: 1px solid var(--am-border); @@ -148,15 +157,54 @@ font-weight: 600; } +#access-matrix-content .am-role-code, +.context-nav .am-role-code { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 2.15rem; + height: 1.35rem; + padding: 0 0.35rem; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.03em; + color: var(--am-accent); + background: var(--am-accent-soft); + border-radius: 4px; + line-height: 1; +} + +#access-matrix-content .am-role-label, +.context-nav .am-role-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25; +} + +#access-matrix-content .am-role-btn, +.context-nav .am-role-btn { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; + overflow: hidden; +} + #access-matrix-content .am-role-btn { - display: block; + display: flex; + align-items: center; + gap: 0.45rem; width: 100%; text-align: left; border: none; border-left: 2px solid transparent; background: transparent; border-radius: 0 4px 4px 0; - padding: 0.4rem 0.5rem 0.4rem 0.65rem; + padding: 0.45rem 0.5rem 0.45rem 0.55rem; font: inherit; font-size: 0.82rem; cursor: pointer; @@ -953,3 +1001,818 @@ grid-template-columns: 1fr; } } + +/* Aba Controle de acesso — separada após Agentes IA */ +#access-matrix-content .am-tab-sep { + width: 1px; + align-self: stretch; + background: var(--am-border); + margin: 0 0.35rem; +} + +#access-matrix-content .am-tab--access-control { + font-weight: 600; + border-left: 3px solid var(--am-accent); +} + +#access-matrix-content .am-tab--access-control.active { + background: #e8f0fa; + color: #2b6cb0; + box-shadow: inset 0 -2px 0 #2b6cb0; +} + +#access-matrix-content .am-panel--access-control { + border: none; + box-shadow: none; + background: transparent; + padding: 0; + overflow: visible; +} + +#access-matrix-content .am-role-code { + margin-right: 0; +} + +#access-matrix-content .am-role-btn { + gap: 0.45rem; +} + +/* Spec 039 — Quem faz o quê (mockup Roger) */ +#access-matrix-content .am-panel--executive { + border: none; + box-shadow: none; + background: transparent; + padding: 0; + overflow: visible; +} + +#access-matrix-content .am-executive-map-host { + overflow: visible; + contain: layout style; +} + +body.qfx-scroll-lock { + overflow: hidden; + overscroll-behavior: none; +} + +#access-matrix-content .qfx-layout { + position: relative; + min-height: 420px; + overflow: visible; +} + +#access-matrix-content .qfx-layout--panel { + display: grid; + grid-template-columns: minmax(0, 1fr) min(400px, 36%); + gap: 1rem; + align-items: start; +} + +@media (max-width: 1100px) { + #access-matrix-content .qfx-layout--panel { + grid-template-columns: 1fr; + } + #access-matrix-content .qfx-side-panel { + order: 2; + } +} + +#access-matrix-content .qfx-main { + min-width: 0; +} + +#access-matrix-content .qfx-side-panel { + align-self: start; + min-width: 0; +} + +#access-matrix-content .qfx-side-card { + display: flex; + flex-direction: column; + max-height: none; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 12px; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.08); + overflow: hidden; +} + +#access-matrix-content .qfx-side-card .qfx-modal-body { + flex: 1; + overflow: visible; + overscroll-behavior: contain; +} + +#access-matrix-content .qfx-level-badge { + display: inline-block; + padding: 0.35rem 0.75rem; + border-radius: 8px; + font-size: 0.82rem; + font-weight: 600; + border: 1px solid transparent; +} + +#access-matrix-content .qfx-readonly-field { + margin: 0; + padding: 0.45rem 0.65rem; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f9fafb; + font-size: 0.84rem; + color: #374151; +} + +#access-matrix-content .qfx-readonly-hint { + font-size: 0.82rem; +} + +#access-matrix-content .qfx-btn-edit--full { + width: 100%; + justify-content: center; +} + +#access-matrix-content .qfx-hint { + margin: 0 0 0.5rem; + font-size: 0.75rem; + color: #9ca3af; +} + +#access-matrix-content .qfx-edit-subtitle { + margin: 0.35rem 0 0; + font-size: 0.82rem; + color: #6b7280; + font-weight: 400; +} + +#access-matrix-content .qfx-edit-modal-root { + position: fixed; + inset: 0; + z-index: 1400; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; +} + +#access-matrix-content .qfx-edit-modal-root .qfx-backdrop { + z-index: 1399; +} + +#access-matrix-content .qfx-modal-card--edit { + z-index: 1401; +} + +#access-matrix-content .qfx-page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +#access-matrix-content .qfx-title { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + color: #2d3748; +} + +#access-matrix-content .qfx-subtitle { + margin: 0.25rem 0 0; + font-size: 0.82rem; + color: #718096; +} + +#access-matrix-content .qfx-badge-edit, +#access-matrix-content .qfx-badge-read { + font-size: 0.72rem; + padding: 0.25rem 0.6rem; + border-radius: 999px; + white-space: nowrap; +} + +#access-matrix-content .qfx-badge-edit { + background: #f0fff4; + color: #276749; + border: 1px solid #9ae6b4; +} + +#access-matrix-content .qfx-badge-read { + background: #edf2f7; + color: #4a5568; +} + +#access-matrix-content .qfx-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-end; + margin-bottom: 1rem; + padding: 0.75rem 1rem; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; +} + +#access-matrix-content .qfx-field { + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.75rem; + color: #718096; +} + +#access-matrix-content .qfx-field--search { + flex: 1; + min-width: 200px; +} + +#access-matrix-content .qfx-select, +#access-matrix-content .qfx-search { + padding: 0.45rem 0.65rem; + border: 1px solid #cbd5e0; + border-radius: 8px; + font-size: 0.85rem; + background: #fff; +} + +#access-matrix-content .qfx-select--full { + width: 100%; +} + +#access-matrix-content .qfx-btn-ghost { + padding: 0.45rem 0.85rem; + border: 1px solid #cbd5e0; + border-radius: 8px; + background: #fff; + font-size: 0.82rem; + cursor: pointer; + color: #4a5568; +} + +#access-matrix-content .qfx-layout--modal .qfx-main { + filter: none; +} + +#access-matrix-content .qfx-kpi-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.25rem; +} + +@media (max-width: 1100px) { + #access-matrix-content .qfx-kpi-grid { + grid-template-columns: 1fr; + } +} + +#access-matrix-content .qfx-kpi { + position: relative; + padding: 1.15rem 1.25rem; + border-radius: 12px; + border: 1px solid #e2e8f0; + background: #fff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + min-height: 118px; +} + +#access-matrix-content .qfx-kpi-body { + padding-right: 3.25rem; +} + +#access-matrix-content .qfx-kpi-icon-ring { + position: absolute; + top: 1rem; + right: 1rem; + width: 2.75rem; + height: 2.75rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +#access-matrix-content .qfx-kpi--action .qfx-kpi-icon-ring { + background: #dcfce7; + color: #16a34a; +} + +#access-matrix-content .qfx-kpi--who .qfx-kpi-icon-ring { + background: #dbeafe; + color: #2563eb; +} + +#access-matrix-content .qfx-kpi--why .qfx-kpi-icon-ring { + background: #ede9fe; + color: #7c3aed; +} + +#access-matrix-content .qfx-kpi-label { + margin: 0; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + color: #374151; +} + +#access-matrix-content .qfx-kpi-subtitle { + margin: 0.2rem 0 0.85rem; + font-size: 0.78rem; + color: #9ca3af; + line-height: 1.35; +} + +#access-matrix-content .qfx-kpi-val { + display: block; + font-size: 2rem; + font-weight: 700; + line-height: 1; + letter-spacing: -0.02em; +} + +#access-matrix-content .qfx-kpi--action .qfx-kpi-val { color: #16a34a; } +#access-matrix-content .qfx-kpi--who .qfx-kpi-val { color: #2563eb; } +#access-matrix-content .qfx-kpi--why .qfx-kpi-val { color: #7c3aed; } + +#access-matrix-content .qfx-kpi-foot { + display: block; + margin-top: 0.35rem; + font-size: 0.78rem; + color: #9ca3af; +} + +#access-matrix-content .qfx-table-block { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; +} + +#access-matrix-content .qfx-table-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1rem; + border-bottom: 1px solid #e2e8f0; + background: #f7fafc; +} + +#access-matrix-content .qfx-table-head h4 { + margin: 0; + font-size: 0.95rem; +} + +#access-matrix-content .qfx-table-count { + font-size: 0.78rem; + color: #718096; +} + +#access-matrix-content .qfx-table-wrap { + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: auto; + scrollbar-gutter: stable; +} + +#access-matrix-content .qfx-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +#access-matrix-content .qfx-table th { + text-align: left; + padding: 0.65rem 0.85rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + color: #718096; + background: #fafafa; + border-bottom: 1px solid #e2e8f0; +} + +#access-matrix-content .qfx-table td { + padding: 0.75rem 0.85rem; + border-bottom: 1px solid #edf2f7; + vertical-align: middle; +} + +#access-matrix-content .qfx-row { + cursor: pointer; + transition: background 0.12s; +} + +#access-matrix-content .qfx-row:hover { + background: #f7fafc; +} + +#access-matrix-content .qfx-row--open { + background: #ebf8ff; +} + +#access-matrix-content .qfx-row--custom { + box-shadow: inset 3px 0 0 #d69e2e; +} + +#access-matrix-content .qfx-action-title { + display: block; + font-size: 0.88rem; + color: #2d3748; + margin-bottom: 0.2rem; +} + +#access-matrix-content .qfx-action-id { + font-size: 0.68rem; + color: #a0aec0; + font-family: ui-monospace, monospace; +} + +#access-matrix-content .qfx-col-who { + max-width: 260px; +} + +#access-matrix-content .qfx-split-tag { + display: inline-flex; + align-items: stretch; + margin: 0.12rem 0.2rem 0.12rem 0; + padding: 0; + border: 1px solid #e5e7eb; + border-radius: 999px; + overflow: hidden; + background: #fff; + font: inherit; + vertical-align: middle; + cursor: default; +} + +#access-matrix-content button.qfx-split-tag { + cursor: pointer; +} + +#access-matrix-content button.qfx-split-tag:hover { + border-color: #cbd5e1; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); +} + +#access-matrix-content .qfx-split-tag.off { + opacity: 0.42; +} + +#access-matrix-content .qfx-split-tag__code { + display: inline-flex; + align-items: center; + padding: 0.18rem 0.42rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--tag-fg, #475569); + background: var(--tag-bg, #f1f5f9); +} + +#access-matrix-content .qfx-split-tag__name { + display: inline-flex; + align-items: center; + padding: 0.18rem 0.55rem 0.18rem 0.35rem; + font-size: 0.68rem; + font-weight: 500; + color: #374151; + background: #fff; + white-space: nowrap; +} + +#access-matrix-content .qfx-split-tag--compact .qfx-split-tag__code { + padding: 0.12rem 0.35rem; + font-size: 0.62rem; +} + +#access-matrix-content .qfx-split-tag--compact .qfx-split-tag__name { + display: none; +} + +#access-matrix-content .qfx-split-tag-grid { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +#access-matrix-content .qfx-col-why { + color: #4a5568; + max-width: 220px; + line-height: 1.35; +} + +#access-matrix-content .qfx-level-select { + min-width: 7.5rem; + padding: 0.35rem 0.5rem; + border-radius: 8px; + border: 1px solid #cbd5e0; + font-size: 0.78rem; + font-weight: 600; +} + +#access-matrix-content .qfx-level--full { + background: #f0fff4; + color: #276749; + border-color: #9ae6b4; +} + +#access-matrix-content .qfx-level--read { + background: #ebf8ff; + color: #2b6cb0; + border-color: #90cdf4; +} + +#access-matrix-content .qfx-level--none { + background: #fff5f5; + color: #c53030; + border-color: #feb2b2; +} + +#access-matrix-content .qfx-icon-btn { + border: none; + background: transparent; + cursor: pointer; + padding: 0.25rem 0.35rem; + font-size: 1rem; + opacity: 0.65; +} + +#access-matrix-content .qfx-icon-btn:hover { + opacity: 1; +} + +#access-matrix-content .qfx-muted { + color: #cbd5e0; +} + +#access-matrix-content .qfx-empty { + text-align: center; + color: #718096; + padding: 2rem !important; +} + +#access-matrix-content .qfx-modal-root { + position: fixed; + inset: 0; + z-index: 1300; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; +} + +#access-matrix-content .qfx-backdrop { + position: fixed; + inset: 0; + z-index: 1299; + background: rgba(15, 23, 42, 0.42); +} + +#access-matrix-content .qfx-modal-card { + position: relative; + z-index: 1301; + width: min(440px, 100%); + max-height: min(90vh, 720px); + display: flex; + flex-direction: column; + background: #fff; + border-radius: 12px; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18); + overflow: hidden; +} + +#access-matrix-content .qfx-modal-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 1.25rem 1.35rem 0.85rem; +} + +#access-matrix-content .qfx-modal-head-text h3 { + margin: 0; + font-size: 1.05rem; + line-height: 1.35; + font-weight: 700; + color: #111827; +} + +#access-matrix-content .qfx-tag-pill { + display: inline-block; + margin-top: 0.55rem; + padding: 0.22rem 0.65rem; + border-radius: 999px; + background: #dcfce7; + color: #15803d; + font-size: 0.72rem; + font-weight: 600; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +#access-matrix-content .qfx-modal-close { + border: none; + background: transparent; + width: 2rem; + height: 2rem; + border-radius: 8px; + font-size: 1.35rem; + line-height: 1; + cursor: pointer; + color: #6b7280; +} + +#access-matrix-content .qfx-modal-close:hover { + background: #f3f4f6; + color: #111827; +} + +#access-matrix-content .qfx-modal-body { + flex: 1; + overflow-y: auto; + padding: 0 1.35rem 1rem; +} + +#access-matrix-content .qfx-modal-block { + margin-bottom: 1.1rem; +} + +#access-matrix-content .qfx-modal-block h4 { + margin: 0 0 0.4rem; + font-size: 0.88rem; + font-weight: 700; + color: #111827; +} + +#access-matrix-content .qfx-modal-block p { + margin: 0; + font-size: 0.84rem; + line-height: 1.45; + color: #6b7280; +} + +#access-matrix-content .qfx-finalidade-main { + color: #374151 !important; + font-weight: 500; +} + +#access-matrix-content .qfx-finalidade-sub { + margin-top: 0.25rem !important; + font-size: 0.78rem !important; + color: #9ca3af !important; +} + +#access-matrix-content .qfx-level-field { + margin-top: 0.15rem; +} + +#access-matrix-content .qfx-level-select--modal { + width: 100%; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2315803d' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2rem; +} + +#access-matrix-content .qfx-select--modal { + width: 100%; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2rem; +} + +#access-matrix-content .qfx-modal-foot { + display: flex; + justify-content: flex-end; + gap: 0.65rem; + padding: 1rem 1.35rem 1.15rem; + border-top: 1px solid #e5e7eb; +} + +#access-matrix-content .qfx-btn-cancel { + padding: 0.55rem 1rem; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + color: #374151; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +#access-matrix-content .qfx-btn-cancel:hover { + background: #f9fafb; +} + +#access-matrix-content .qfx-btn-edit { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.55rem 1rem; + border: none; + border-radius: 8px; + background: #4a152c; + color: #fff; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +#access-matrix-content .qfx-btn-edit:hover:not(:disabled) { + background: #3d1124; +} + +#access-matrix-content .qfx-btn-edit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +#access-matrix-content .qfx-error { + color: #c53030; + font-size: 0.82rem; + margin: 0.5rem 0; +} + +#access-matrix-content .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +/* Modal editar permissão — portal no body (z-index acima de tudo) */ +#qfx-edit-modal-portal .qfx-edit-modal-root { + position: fixed; + inset: 0; + z-index: 1500; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + overscroll-behavior: none; +} + +#qfx-edit-modal-portal .qfx-backdrop { + position: fixed; + inset: 0; + z-index: 1499; + background: rgba(15, 23, 42, 0.42); +} + +#qfx-edit-modal-portal .qfx-modal-card { + position: relative; + z-index: 1501; + width: min(440px, 100%); + max-height: min(90vh, 720px); + display: flex; + flex-direction: column; + background: #fff; + border-radius: 12px; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18); + overflow: hidden; +} + +#qfx-edit-modal-portal .qfx-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; padding: 1.25rem 1.35rem 0.85rem; } +#qfx-edit-modal-portal .qfx-modal-head-text h3 { margin: 0; font-size: 1.05rem; line-height: 1.35; font-weight: 700; color: #111827; } +#qfx-edit-modal-portal .qfx-tag-pill { display: inline-block; margin-top: 0.55rem; padding: 0.22rem 0.65rem; border-radius: 999px; background: #dcfce7; color: #15803d; font-size: 0.72rem; font-weight: 600; font-family: ui-monospace, Menlo, monospace; } +#qfx-edit-modal-portal .qfx-edit-subtitle { margin: 0.35rem 0 0; font-size: 0.82rem; color: #6b7280; } +#qfx-edit-modal-portal .qfx-modal-close { border: none; background: transparent; width: 2rem; height: 2rem; border-radius: 8px; font-size: 1.35rem; cursor: pointer; color: #6b7280; } +#qfx-edit-modal-portal .qfx-modal-body { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 0 1.35rem 1rem; } +#qfx-edit-modal-portal .qfx-modal-block { margin-bottom: 1.1rem; } +#qfx-edit-modal-portal .qfx-modal-block h4 { margin: 0 0 0.4rem; font-size: 0.88rem; font-weight: 700; color: #111827; } +#qfx-edit-modal-portal .qfx-hint { margin: 0 0 0.5rem; font-size: 0.75rem; color: #9ca3af; } +#qfx-edit-modal-portal .qfx-split-tag-grid { display: flex; flex-wrap: wrap; gap: 0.45rem; } +#qfx-edit-modal-portal .qfx-split-tag { display: inline-flex; align-items: stretch; border: 1px solid #e5e7eb; border-radius: 999px; overflow: hidden; background: #fff; font: inherit; cursor: pointer; } +#qfx-edit-modal-portal .qfx-split-tag.off { opacity: 0.42; } +#qfx-edit-modal-portal .qfx-split-tag__code { display: inline-flex; align-items: center; padding: 0.18rem 0.42rem; font-size: 0.68rem; font-weight: 700; color: var(--tag-fg, #475569); background: var(--tag-bg, #f1f5f9); } +#qfx-edit-modal-portal .qfx-split-tag__name { display: inline-flex; align-items: center; padding: 0.18rem 0.55rem 0.18rem 0.35rem; font-size: 0.68rem; color: #374151; background: #fff; white-space: nowrap; } +#qfx-edit-modal-portal .qfx-level-select { width: 100%; padding: 0.45rem 0.65rem; border-radius: 8px; border: 1px solid #cbd5e0; font-size: 0.85rem; font-weight: 600; } +#qfx-edit-modal-portal .qfx-level-select--modal { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2315803d' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.75rem center; padding-right: 2rem; } +#qfx-edit-modal-portal .qfx-level--full { background: #f0fff4; color: #276749; border-color: #9ae6b4; } +#qfx-edit-modal-portal .qfx-level--read { background: #ebf8ff; color: #2b6cb0; border-color: #90cdf4; } +#qfx-edit-modal-portal .qfx-level--none { background: #fff5f5; color: #c53030; border-color: #feb2b2; } +#qfx-edit-modal-portal .qfx-select { width: 100%; padding: 0.45rem 0.65rem; border: 1px solid #cbd5e0; border-radius: 8px; font-size: 0.85rem; background: #fff; } +#qfx-edit-modal-portal .qfx-modal-foot { display: flex; justify-content: flex-end; gap: 0.65rem; padding: 1rem 1.35rem 1.15rem; border-top: 1px solid #e5e7eb; } +#qfx-edit-modal-portal .qfx-btn-cancel { padding: 0.55rem 1rem; border: 1px solid #d1d5db; border-radius: 8px; background: #fff; color: #374151; font-size: 0.85rem; font-weight: 600; cursor: pointer; } +#qfx-edit-modal-portal .qfx-btn-edit { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.55rem 1rem; border: none; border-radius: 8px; background: #4a152c; color: #fff; font-size: 0.85rem; font-weight: 600; cursor: pointer; } +#qfx-edit-modal-portal .qfx-btn-edit:disabled { opacity: 0.5; cursor: not-allowed; } +#qfx-edit-modal-portal .qfx-error { color: #c53030; font-size: 0.82rem; margin: 0.5rem 0; } + +.acs-partial { + font-style: normal; + font-size: 0.75rem; + color: #b7791f; +} + +.acs-goto-exec { + color: var(--am-accent, #2b6cb0); +} + diff --git a/projects/ops-desk/frontend/assets/access-matrix.js b/projects/ops-desk/frontend/assets/access-matrix.js index a391639..31fbf87 100644 --- a/projects/ops-desk/frontend/assets/access-matrix.js +++ b/projects/ops-desk/frontend/assets/access-matrix.js @@ -19,8 +19,199 @@ softwareFilter: 'all', saving: false, saveError: null, + moduleDraft: null, + bindingDraft: null, }; + async function crudApi(method, path, body) { + const opts = { + method, + headers: authHeaders({ 'Content-Type': 'application/json' }), + }; + if (body !== undefined) opts.body = JSON.stringify(body); + const r = await fetchWithTimeout(`/api/v1${path}`, opts); + if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`); + if (method === 'DELETE' || r.status === 204) return {}; + const ct = r.headers.get('content-type') || ''; + if (ct.includes('json')) return r.json(); + return r.text(); + } + + async function reloadMatrix() { + state.data = await api('/rbac/matrix'); + state.moduleDraft = null; + state.bindingDraft = null; + } + + function selectedRoleMeta() { + return state.data?.catalog?.roles?.[state.selectedRole] || {}; + } + + function canCrud() { + return !!state.data?.crud_enabled; + } + + function isRoleLocked() { + return !!selectedRoleMeta().locked; + } + + async function handleNewRole() { + const id = window.prompt('ID da função (slug, ex.: billing_analyst):'); + if (!id) return; + const label = window.prompt('Nome exibido (pt-BR):', id.replace(/_/g, ' ')); + if (!label) return; + state.saving = true; + state.saveError = null; + try { + await crudApi('POST', '/rbac/roles', { + id: id.trim().toLowerCase(), + label: label.trim(), + category: 'custom', + description: '', + }); + await reloadMatrix(); + state.selectedRole = id.trim().toLowerCase(); + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } catch (e) { + state.saveError = e.message; + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } finally { + state.saving = false; + } + } + + async function handleCloneRole() { + const newId = window.prompt(`Copiar ${state.selectedRole} — novo ID (slug):`); + if (!newId) return; + const newLabel = window.prompt('Nome da cópia:', `${selectedRoleMeta().label || state.selectedRole} (cópia)`); + if (!newLabel) return; + state.saving = true; + try { + await crudApi('POST', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/clone`, { + new_id: newId.trim().toLowerCase(), + new_label: newLabel.trim(), + }); + await reloadMatrix(); + state.selectedRole = newId.trim().toLowerCase(); + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } catch (e) { + state.saveError = e.message; + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } finally { + state.saving = false; + } + } + + async function handleFreezeRole() { + const meta = selectedRoleMeta(); + const next = meta.status === 'frozen' ? 'active' : 'frozen'; + const msg = next === 'frozen' ? 'Pausar esta função? Novas atribuições serão bloqueadas.' : 'Reactivar função?'; + if (!window.confirm(msg)) return; + state.saving = true; + try { + await crudApi('PATCH', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/status`, { status: next }); + await reloadMatrix(); + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } catch (e) { + state.saveError = e.message; + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } finally { + state.saving = false; + } + } + + async function handleDeleteRole() { + if (!window.confirm(`Arquivar função ${state.selectedRole}? Falha se houver utilizadores activos.`)) return; + state.saving = true; + try { + await crudApi('DELETE', `/rbac/roles/${encodeURIComponent(state.selectedRole)}`); + await reloadMatrix(); + state.selectedRole = state.data.role_columns?.[0] || 'super_admin'; + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } catch (e) { + state.saveError = e.message; + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } finally { + state.saving = false; + } + } + + function handleReportDownload() { + const token = localStorage.getItem('desk_token') || sessionStorage.getItem('desk_token'); + const url = `/api/v1/rbac/roles/${encodeURIComponent(state.selectedRole)}/report.csv`; + fetch(url, { headers: authHeaders() }) + .then((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.blob(); + }) + .then((blob) => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `matriz-${state.selectedRole}.csv`; + a.click(); + }) + .catch((e) => { state.saveError = e.message; paint(document.getElementById('access-matrix-content')); }); + } + + async function saveModuleDraft() { + if (!state.moduleDraft) return; + state.saving = true; + try { + await crudApi('PUT', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/modules`, { + modules: state.moduleDraft, + }); + await reloadMatrix(); + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } catch (e) { + state.saveError = e.message; + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } finally { + state.saving = false; + } + } + + async function saveBindingDraft() { + if (!state.bindingDraft) return; + state.saving = true; + try { + await crudApi('PUT', `/rbac/roles/${encodeURIComponent(state.selectedRole)}/bindings`, { + bindings: state.bindingDraft, + }); + await reloadMatrix(); + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } catch (e) { + state.saveError = e.message; + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } finally { + state.saving = false; + } + } + + function renderToolbar() { + if (!canCrud()) return ''; + const meta = selectedRoleMeta(); + const frozen = meta.status === 'frozen'; + return `
+ + + + + +
`; + } + async function patchApi(path, body) { const r = await fetchWithTimeout(`/api/v1${path}`, { method: 'PATCH', @@ -121,7 +312,7 @@ if (!groups[cat]) groups[cat] = []; groups[cat].push(role); }); - const order = ['ops', 'commercial', 'business', 'platform', 'system']; + const order = ['ops', 'commercial', 'business', 'platform', 'custom', 'system']; return order .filter((c) => groups[c]?.length) .map((c) => ({ id: c, label: categoryLabels[c] || c, roles: groups[c] })); @@ -138,12 +329,20 @@ function renderRoleSidebar() { const groups = rolesByCategory(state.data.catalog, state.data.category_labels || {}); return groups.map((g) => ` -
-

${esc(g.label)}

- ${g.roles.map((r) => ` - - `).join('')} + `).join(''); } @@ -193,8 +392,43 @@ } function renderDeskMatrix() { + if (canCrud() && !isRoleLocked()) { + if (!state.moduleDraft) { + const draft = {}; + (state.data.desk_modules || []).forEach((row) => { + draft[row.id] = row.levels[state.selectedRole] || 'none'; + }); + state.moduleDraft = draft; + } + const levels = ['full', 'read', 'api', 'link', 'none']; + const rows = (state.data.desk_modules || []).map((row) => ` + + ${esc(row.label)} ${esc(row.id)} + + + + `).join(''); + return ` +

Editor de módulos Desk — função ${esc(roleLabel(state.selectedRole))}

+
+ + + ${rows} +
MóduloAcesso
+
+
+ +
+
+

Grelha completa (todas as funções)

+ ${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`; + } return ` -

Grelha completa — módulos internos do Desk (VM122). Use Visão da função para resumo ou Software & Infra para VM112/123.

+

Grelha completa — módulos internos do Desk (VM122).

${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`; } @@ -342,9 +576,54 @@ function renderBindings() { const role = state.data.catalog.roles[state.selectedRole]; if (!role) return '

Função não encontrada

'; + let editor = ''; + if (canCrud() && !isRoleLocked()) { + if (!state.bindingDraft) { + state.bindingDraft = (role.bindings || []).map((b) => ({ ...b })); + } + const rows = state.bindingDraft.map((b, i) => ` + + + + + + + `).join(''); + editor = ` +
+

Editor bindings

+
+ + + ${rows} +
ServiçoTipoValorAcesso
+
+
+ + +
+
`; + } + const groups = (state.data.external_groups || []).map((g) => + `
  • ${esc(g.id)} — ${esc(g.label)} ${esc(g.service)}
  • ` + ).join(''); return ` + ${editor}

    Bindings estilo Odoo — grupos, roles e permissões provisionados por função.

    - ${renderBindingCards(role.bindings || [], '')}`; + ${renderBindingCards(role.bindings || [], '')} + ${groups ? `

    Grupos externos registados

      ${groups}
    ` : ''}`; + } + + function renderAudit() { + const entries = state.data.role_audit || []; + if (!entries.length) return '

    Sem alterações registadas ainda.

    '; + return `
    + ${entries.map((e) => ` +
    +
    ${esc(e.action)} · ${esc(e.entity_type)} ${esc(e.entity_id)}
    +

    ${esc(e.actor)} · ${esc(e.created_at)}

    +
    `).join('')} +
    `; } function roleLabel(roleId) { @@ -547,6 +826,18 @@ } function renderMainPanel() { + if (state.tab === 'access-control') { + return ` +
    +
    +
    `; + } + if (state.tab === 'quem-faz-o-que') { + return ` +
    +
    +
    `; + } const role = state.data.catalog.roles[state.selectedRole]; let body = ''; if (state.tab === 'overview') body = renderRoleOverview(); @@ -554,6 +845,7 @@ else if (state.tab === 'software') body = renderSoftwareMatrix(); else if (state.tab === 'bindings') body = renderBindings(); else if (state.tab === 'agents') body = renderAgents(); + else if (state.tab === 'audit') body = renderAudit(); return `
    @@ -571,21 +863,30 @@ } function renderTabs() { - return (state.data.tabs || []).map((t) => - `` - ).join(''); + return (state.data.tabs || []).map((t) => { + const sep = t.separated ? '' : ''; + const extra = t.id === 'access-control' ? ' am-tab--access-control' : (t.id === 'quem-faz-o-que' ? ' am-tab--quem-faz-o-que' : ''); + return `${sep}`; + }).join(''); } function bindEvents(root) { root.querySelectorAll('[data-am-role]').forEach((btn) => { btn.addEventListener('click', () => { state.selectedRole = btn.dataset.amRole; + state.moduleDraft = null; + state.bindingDraft = null; + window.DeskExecutiveMap?.resetFilters?.(); paint(root); }); }); root.querySelectorAll('[data-am-tab]').forEach((btn) => { btn.addEventListener('click', () => { state.tab = btn.dataset.amTab; + if (typeof window.getDeskState === 'function') { + const ds = window.getDeskState(); + if (ds) ds.matrixTab = state.tab; + } paint(root); }); }); @@ -609,18 +910,53 @@ toggleCap(capId, roleId); }); }); + root.querySelectorAll('[data-am-action]').forEach((btn) => { + btn.addEventListener('click', () => { + const a = btn.dataset.amAction; + if (a === 'new-role') handleNewRole(); + if (a === 'clone-role') handleCloneRole(); + if (a === 'freeze-role') handleFreezeRole(); + if (a === 'delete-role') handleDeleteRole(); + if (a === 'report-csv') handleReportDownload(); + if (a === 'save-modules') saveModuleDraft(); + if (a === 'save-bindings') saveBindingDraft(); + if (a === 'add-binding') { + state.bindingDraft = state.bindingDraft || []; + state.bindingDraft.push({ service: 'desk', type: 'permission', value: 'read_tickets', access: 'read' }); + paint(root); + } + }); + }); + root.querySelectorAll('[data-am-mod]').forEach((sel) => { + sel.addEventListener('change', () => { + if (!state.moduleDraft) return; + state.moduleDraft[sel.dataset.amMod] = sel.value; + }); + }); + root.querySelectorAll('.am-bind-in').forEach((inp) => { + inp.addEventListener('change', () => { + const i = Number(inp.dataset.amBind); + const field = inp.dataset.field; + if (!state.bindingDraft?.[i]) return; + state.bindingDraft[i][field] = inp.value; + }); + }); + root.querySelectorAll('[data-am-rm-bind]').forEach((btn) => { + btn.addEventListener('click', () => { + const i = Number(btn.dataset.amRmBind); + state.bindingDraft.splice(i, 1); + paint(root); + }); + }); } function paint(root) { root.innerHTML = `
    -
    -
    -

    Matriz de Acessos

    -

    Quatro camadas: Desk VM122 · VM112 · VM123 · Infra externa

    -
    - ${state.data?.editable ? 'Edição · audit ON' : 'Preview · read-only'}${state.saving ? ' · …' : ''} +
    + ${state.data?.crud_enabled ? 'CRUD · audit ON' : state.data?.editable ? 'Edição · audit ON' : 'Consulta'}${state.saving ? ' · …' : ''}
    + ${renderToolbar()} ${state.saveError ? `

    ${esc(state.saveError)}

    ` : ''}
    @@ -629,17 +965,41 @@
    `; bindEvents(root); + window.DeskTopnav?.remountMatrixRoles?.(); + if (state.tab === 'access-control') { + const host = root.querySelector('#am-access-control-host'); + const roleMeta = state.data?.catalog?.roles?.[state.selectedRole]; + window.DeskAccessControlPanel?.paint?.(host, { + selectedRole: state.selectedRole, + roleMeta, + catalog: state.data?.catalog, + editable: !!state.data?.editable, + }); + } + if (state.tab === 'quem-faz-o-que') { + const host = root.querySelector('#am-executive-map-host'); + window.DeskExecutiveMap?.paint?.(host, { selectedRole: state.selectedRole }); + } } async function renderAccessMatrix() { const root = document.getElementById('access-matrix-content'); if (!root) return; - root.innerHTML = '

    Carregando matriz de acessos…

    '; + if (typeof ensureValidSession === 'function' && !(await ensureValidSession())) return; + root.innerHTML = '

    Carregando Matriz de Acesso…

    '; try { state.data = await api('/rbac/matrix'); if (!state.selectedRole && state.data.role_columns?.length) { state.selectedRole = state.data.role_columns[0]; } + const tabIds = (state.data.tabs || []).map((t) => t.id); + if (typeof window.getDeskState === 'function') { + const ds = window.getDeskState(); + if (ds?.matrixTab && tabIds.includes(ds.matrixTab)) { + state.tab = ds.matrixTab; + } + } + if (!tabIds.includes(state.tab) && tabIds.length) state.tab = tabIds[0]; paint(root); } catch (e) { root.innerHTML = `

    Matriz indisponível: ${esc(e.message)}

    `; @@ -647,5 +1007,8 @@ } window.renderAccessMatrix = renderAccessMatrix; - window.DeskAccessMatrix = { renderAccessMatrix }; + window.DeskAccessMatrix = { + renderAccessMatrix, + getState: () => state, + }; })(); diff --git a/projects/ops-desk/frontend/assets/app.js b/projects/ops-desk/frontend/assets/app.js index 01e3dee..40907c3 100644 --- a/projects/ops-desk/frontend/assets/app.js +++ b/projects/ops-desk/frontend/assets/app.js @@ -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 ` + `; +} + +function adminTabsInContent() { + return document.querySelector('.shell--v2') ? '' : renderAdminTabs(); +} + +function bindAdminTabs() { + document.querySelectorAll('[data-admin-tab]').forEach((btn) => { + btn.onclick = () => { + state.adminPanel = btn.dataset.adminTab === 'matrix' ? 'matrix' : 'team'; + renderAdmin(); + window.DeskTopnav?.updateContextSidebar?.('admin'); + }; + }); +} + function applyRoleNav() { const user = getUser(); if (!user) return; @@ -374,7 +398,7 @@ function setView(name) { document.getElementById('page-title').textContent = titles[name] || 'Ligbox Ops'; const subEl = document.getElementById('page-subtitle'); if (subEl) subEl.textContent = subtitles[name] || subtitles.dashboard; - document.querySelectorAll('.nav button').forEach((b) => { + document.querySelectorAll('.nav button, [data-desk-nav]').forEach((b) => { b.classList.toggle('active', b.dataset.view === name); }); document.querySelectorAll('[data-desk-nav]').forEach((b) => { @@ -3161,6 +3185,13 @@ async function renderAdmin() { el.innerHTML = '

    Sem permissão

    '; return; } + const showMatrixTab = state.features?.access_matrix_ui && getUser()?.role === 'super_admin'; + if (showMatrixTab && state.adminPanel === 'matrix') { + el.innerHTML = `${adminTabsInContent()}

    Carregando matriz…

    `; + bindAdminTabs(); + if (typeof window.renderAccessMatrix === 'function') await window.renderAccessMatrix(); + return; + } el.innerHTML = '

    Carregando equipe…

    '; try { const [usersData, regData] = await Promise.all([ @@ -3197,6 +3228,7 @@ async function renderAdmin() { `).join(''); el.innerHTML = ` + ${adminTabsInContent()}
    @@ -3262,6 +3294,7 @@ async function renderAdmin() {
    `; + bindAdminTabs(); const applyFilters = () => { state.adminFilter = { q: document.getElementById('team-filter-q')?.value || '', diff --git a/projects/ops-desk/frontend/assets/app.staging.js b/projects/ops-desk/frontend/assets/app.staging.js index 01e3dee..c543ca6 100644 --- a/projects/ops-desk/frontend/assets/app.staging.js +++ b/projects/ops-desk/frontend/assets/app.staging.js @@ -104,11 +104,11 @@ function resolveBootView() { return fallback; } -async function api(path, options = {}) { +async function api(path, options = {}, timeoutMs) { const res = await fetchWithTimeout(`${API}${path}`, { headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }), ...options, - }); + }, timeoutMs); if (res.status === 401) { logout(); throw new Error('sessão expirada'); @@ -169,6 +169,7 @@ let state = { adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' }, adminSelected: null, adminPanel: 'team', + matrixTab: null, features: { access_matrix_ui: false }, socWindow: '24h', socLastEventId: null, @@ -281,7 +282,6 @@ function applyRoleNav() { } if (canManageUsers()) { document.getElementById('nav-messages')?.removeAttribute('hidden'); - document.getElementById('nav-admin')?.removeAttribute('hidden'); } if (user.role === 'super_admin') { document.getElementById('nav-modules')?.removeAttribute('hidden'); @@ -330,26 +330,46 @@ function setView(name) { if (name === 'access-matrix' && !state.features?.access_matrix_ui) { name = resolveDefaultView(); } else if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) { - name = resolveDefaultView(); + if (!(name === 'admin' && typeof canManageUsers === 'function' && canManageUsers())) { + name = resolveDefaultView(); + } + } + if (name === 'admin') { + /* Controle de acesso só via Matriz de Acesso (aba access-control) — Roger 2026-06-25 */ + if (state.features?.access_matrix_ui && getUser()?.role === 'super_admin') { + name = 'access-matrix'; + state.matrixTab = state.matrixTab || 'access-control'; + } else { + state.adminPanel = 'team'; + } + } + if (name !== 'admin' && name !== 'access-matrix') closeTeamDrawer(); + if (name === 'access-matrix' && state.matrixTab) { + /* tab aplicada em renderAccessMatrix */ + } else if (name !== 'access-matrix') { + state.matrixTab = null; } if (state.view === 'account' && name !== 'account') { state.accountLoaded = false; } + window.DeskUserWizard?.close?.(); + document.body.classList.remove('um-scroll-lock'); state.view = name; - if (name === 'admin') state.adminPanel = 'team'; const titles = { dashboard: 'Dashboard', overview: 'Audit Overview', 'overview-home': 'Serviços IaaS', - tickets: 'Tickets', + tickets: 'Sessão Tickets', events: 'Eventos webhook', tenants: 'Tenants', infra: 'INFRA CODE', infra2: 'SOC — Infra 2', 'agentic-ops': 'Agentic Ops', - messages: 'Mensagens — pedidos de cadastro', - admin: 'Administradores', - 'access-matrix': 'Matriz de Acessos', + messages: 'Central Operacional', + admin: 'Controle de acesso — Spec 040', + 'access-matrix': state.matrixTab === 'access-control' + ? 'Controle de acesso — Spec 040' + : (state.matrixTab === 'quem-faz-o-que' ? 'Quem faz o quê — Spec 039' : 'Matriz de Acesso — Spec 027'), account: 'Minha conta', leads: 'Leads abandonados', modules: 'Módulos', @@ -364,9 +384,13 @@ function setView(name) { infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130', infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real', 'agentic-ops': 'Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)', - messages: 'Operações Ligbox — onboarding, tickets e monitoramento', - admin: 'Operações Ligbox — onboarding, tickets e monitoramento', - 'access-matrix': 'Preview read-only — Spec 027 · funções × VM112/122/123 (estilo Odoo groups)', + messages: 'Spec 041 · Operational feed — inbox unificada (mock até integrações)', + admin: 'Spec 040 · UserWizard + Access Control Hub · audit ON', + 'access-matrix': state.matrixTab === 'access-control' + ? 'Spec 040 · Gestão de utilizadores · cards · painel direito' + : (state.matrixTab === 'quem-faz-o-que' + ? 'Spec 039 · Access capabilities · Quem faz o quê' + : 'Spec 027 · Controle por função · perfil · grupo · módulo'), account: 'Operações Ligbox — onboarding, tickets e monitoramento', leads: 'Operações Ligbox — onboarding, tickets e monitoramento', modules: 'Activar ou desativar funcionalidades do Desk sem afectar o núcleo', @@ -384,6 +408,9 @@ function setView(name) { }); window.DeskTopnav?.updateTopnavActive?.(name); window.DeskTopnav?.updateContextSidebar?.(name); + document.querySelector('.main')?.classList.toggle('main--rwd-tickets', name === 'tickets'); + document.querySelector('.main')?.classList.toggle('main--access-matrix', name === 'access-matrix'); + document.querySelector('.main')?.classList.toggle('main--acs', name === 'admin'); Object.entries(views).forEach(([k, el]) => el?.classList.toggle('active', k === name)); reschedulePoll(); refresh(); @@ -412,10 +439,12 @@ window.DeskNavigate = { let pollTimer = null; let socRenderInFlight = false; +let infraRenderInFlight = false; function reschedulePoll() { if (pollTimer) clearInterval(pollTimer); let ms = 30000; if (state.view === 'infra2') ms = 15000; + if (state.view === 'infra') ms = 60000; // VM112 /admin/domains ~10–15s — poll menos agressivo (Spec 018 Serviços IaaS) if (state.view === 'overview-home') ms = 90000; pollTimer = setInterval(() => refresh({ poll: true }), ms); @@ -930,6 +959,7 @@ function bindAssistActions(container, sessionId) { try { await runAssistAction(btn.dataset.assist, sessionId); await renderTickets(); + await refreshTicketDetailView(); } catch (e) { alert(e.message || 'Falha na ação de assistência'); } finally { @@ -945,6 +975,7 @@ function bindAssistActions(container, sessionId) { try { await api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/actions/${encodeURIComponent(actionId)}`, { method: 'POST' }); await renderTickets(); + await refreshTicketDetailView(); } catch (e) { alert(e.message || 'Falha na ação'); } finally { @@ -2038,15 +2069,21 @@ function buildOverviewHomeTrail(events, domainsFlat, filter, windowHours) { async function renderOverviewHome(options = {}) { const el = document.getElementById('overview-home-content'); if (!el) return; - if (window.DeskServices?.renderPage) { - await window.DeskServices.renderPage(el, options); - return; + try { + if (window.DeskServices?.renderPage) { + await window.DeskServices.renderPage(el, options); + return; + } + if (window.DeskAccounts?.renderPage) { + await window.DeskAccounts.renderPage(el, options); + return; + } + el.innerHTML = '

    Módulo Serviços não carregado.

    '; + } catch (e) { + console.error('renderOverviewHome failed', e); + el.innerHTML = `

    Erro ao carregar Serviços: ${esc(e.message)}

    `; + el.querySelector('#servicos-retry-boot')?.addEventListener('click', () => renderOverviewHome(options)); } - if (window.DeskAccounts?.renderPage) { - await window.DeskAccounts.renderPage(el, options); - return; - } - el.innerHTML = '

    Módulo Serviços não carregado.

    '; } async function renderLeads(options = {}) { @@ -2189,13 +2226,26 @@ async function renderSessionDetail() { } } -async function renderTicketDetail() { - const detailEl = document.getElementById('ticket-detail'); - if (!state.selectedTicketId) return; - if (window.TicketsDetailPanel) { - await TicketsDetailPanel.render(state.selectedTicketId, detailEl); +async function refreshTicketDetailView() { + if (!state.selectedTicketId || !window.TicketsDetailPanel) return; + const body = document.getElementById('ticket-drawer-body'); + if (TicketsDetailPanel.isOpen?.() && body) { + await TicketsDetailPanel.render(state.selectedTicketId, body); return; } + if (TicketsDetailPanel.openDrawer) { + await TicketsDetailPanel.openDrawer(state.selectedTicketId); + } +} + +async function renderTicketDetail() { + if (!state.selectedTicketId) return; + if (window.TicketsDetailPanel?.openDrawer) { + await TicketsDetailPanel.openDrawer(state.selectedTicketId); + return; + } + const detailEl = document.getElementById('ticket-detail'); + if (!detailEl) return; detailEl.innerHTML = '

    Carregando…

    '; try { const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`); @@ -2282,6 +2332,7 @@ async function updateTicketStatus(status) { body: JSON.stringify({ status }), }); await renderTickets(); + await refreshTicketDetailView(); } async function renderEvents(options = {}) { @@ -2374,6 +2425,9 @@ function syncEventsToolbar() { sub.textContent = subs[state.eventsTab] || 'Operações Ligbox — onboarding, tickets e monitoramento'; } } + if (state.view === 'events') { + window.DeskTopnav?.remountEventsToolbar?.(); + } } function carbonioBlockStatusBadge(status) { @@ -3015,7 +3069,11 @@ async function saveUser(username, payload, msgEl) { msgEl.className = 'admin-msg ok'; } closeTeamDrawer(); - await renderAdmin(); + if (state.view === 'access-matrix') { + await window.renderAccessMatrix?.(); + } else { + await renderAdmin(); + } } catch (e) { if (msgEl) { msgEl.textContent = e.message; @@ -3025,11 +3083,37 @@ async function saveUser(username, payload, msgEl) { } } -function filterAdminUsers(users) { +function renderAdminTabs() { + if (!state.features?.access_matrix_ui || getUser()?.role !== 'super_admin') return ''; + const teamActive = state.adminPanel !== 'matrix'; + return ` + `; +} + +function bindAdminTabs() { + document.querySelectorAll('[data-admin-tab]').forEach((btn) => { + btn.onclick = () => { + if (btn.dataset.adminTab === 'matrix') { + setView('access-matrix'); + return; + } + state.adminPanel = 'team'; + renderAdmin(); + window.DeskTopnav?.updateContextSidebar?.('admin'); + }; + }); +} + +function filterAdminUsers(users, opts = {}) { const { q, role, status, mfa } = state.adminFilter; + const roleLock = opts.roleLock; const query = (q || '').trim().toLowerCase(); return users.filter((u) => { - if (role !== 'all' && u.role !== role) return false; + if (roleLock && u.role !== roleLock && !(roleLock === 'super_admin' && u.username === 'root')) return false; + if (!roleLock && role !== 'all' && u.role !== role) return false; if (status === 'active' && !u.active) return false; if (status === 'inactive' && u.active) return false; if (mfa === 'on' && !u.totp_enabled) return false; @@ -3074,7 +3158,7 @@ function openTeamDrawer(username) { body.innerHTML = `
    - +

    ${esc(user.display_name || user.username)}

    ${esc(email)}

    @@ -3087,30 +3171,34 @@ function openTeamDrawer(username) {
    Segurança
    ${mfaBadgeHtml(user)}
    -