fix(ui): botão de copiar senha não fazia nada em HTTP puro (fora de localhost)

navigator.clipboard só existe em contexto seguro (HTTPS ou localhost) —
este ambiente é HTTP puro, acessado pelo IP real da VM. SecretReveal
(usado na criação de ramal e em "ver senha atual") chamava
navigator.clipboard.writeText sem tratamento de erro, então em contexto
inseguro o botão simplesmente não fazia nada, sem nenhum aviso.

Fix: fallback pra document.execCommand("copy") (textarea temporário)
quando a Clipboard API moderna não existe ou falha, funciona em qualquer
contexto. Se as duas formas falharem, mostra aviso pra copiar manualmente
em vez de falhar em silêncio.

Testado com Playwright acessando o IP real da VM (não localhost, pra
reproduzir o contexto inseguro de verdade): confirmado
navigator.clipboard.writeText undefined nesse contexto (reproduz o
sintoma exato), e os dois fluxos de senha mostram sucesso via o fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-31 13:11:03 -03:00
parent b33d35db50
commit 9d3c84064f
2 changed files with 88 additions and 5 deletions

25
TODO.md
View File

@@ -2573,6 +2573,31 @@ teste01 — "so fica cinza")
200 OK observado no tráfego WebSocket real, botão do widget muda de
cinza pra verde (conectado).
## PHASE 71 — Botão de copiar senha SIP não fazia nada (pedido do
usuário: "o botão de copiar a senha dentro do cadastro do ramal está com
problema")
- [x] **Achado real**: `navigator.clipboard` só existe em "contexto
seguro" (HTTPS ou `localhost`) — este ambiente é HTTP puro,
acessado pelo IP da VM (nunca localhost pro usuário de verdade).
`SecretReveal` (usado tanto na criação de ramal quanto em "ver
senha atual") chamava `navigator.clipboard.writeText` direto, sem
try/catch — em contexto inseguro isso derruba/rejeita sem erro
visível nenhum, o botão simplesmente não fazia nada. Confirmado
com Playwright acessando `http://10.10.32.138:3001` (IP real, não
localhost): `navigator.clipboard.writeText` de fato `undefined`
nesse contexto — reproduz o sintoma exato.
- [x] Fix: fallback pra `document.execCommand("copy")` (textarea
temporário fora da tela) quando a Clipboard API moderna não existe
ou falha — funciona em qualquer contexto, inclusive HTTP. Se as
duas formas falharem, mostra aviso pra selecionar e copiar
manualmente (Ctrl+C) em vez de falhar em silêncio — o campo já é
`select-all`.
- [x] Testado ponta a ponta com Playwright acessando o IP real da VM
(não localhost, pra genuinamente cair no contexto inseguro): os
dois fluxos (senha mostrada na criação do ramal, e "ver senha
atual" de um ramal já existente) mostram o ícone verde de sucesso
via o fallback.
---
## Riscos conhecidos

View File

@@ -1,9 +1,36 @@
"use client";
import { useState } from "react";
import { Copy, Check, TriangleAlert } from "lucide-react";
import { Copy, Check, TriangleAlert, X } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* `navigator.clipboard` só existe em "contexto seguro" (HTTPS ou
* localhost) — achado real reportado pelo usuário: o botão de copiar não
* fazia nada (sem erro nenhum, silencioso) quando acessado pelo IP da VM
* em HTTP puro (agente.md: ambiente de laboratório sem HTTPS ainda).
* `execCommand("copy")` sobre um textarea temporário fora da tela é o
* fallback clássico que funciona em qualquer contexto, inclusive HTTP.
*/
function legacyCopy(value: string): boolean {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
document.body.removeChild(textarea);
return ok;
}
/**
* Mostra um segredo em texto puro UMA vez (senha SIP recém-gerada,
* agente.md secao 39: "nunca mostrar novamente a senha inteira" depois
@@ -12,11 +39,31 @@ import { cn } from "@/lib/utils";
*/
export function SecretReveal({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
async function onCopy() {
setError(false);
let ok = false;
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
ok = true;
} catch {
ok = false;
}
}
if (!ok) ok = legacyCopy(value);
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// Nenhuma das duas formas funcionou — o texto já está selecionável
// (`select-all` no <code> abaixo), avisa pra copiar manualmente
// (Ctrl+C) em vez de falhar em silêncio.
setError(true);
setTimeout(() => setError(false), 4000);
}
}
return (
@@ -35,9 +82,20 @@ export function SecretReveal({ label, value }: { label: string; value: string })
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Copiar senha"
>
{copied ? <Check className="h-4 w-4 text-status-green" aria-hidden /> : <Copy className="h-4 w-4" aria-hidden />}
{copied ? (
<Check className="h-4 w-4 text-status-green" aria-hidden />
) : error ? (
<X className="h-4 w-4 text-destructive" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</div>
{error && (
<p className="mt-2 text-xs text-destructive">
Não deu pra copiar automaticamente selecione o texto acima e copie com Ctrl+C (ou Cmd+C).
</p>
)}
</div>
);
}