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:
25
TODO.md
25
TODO.md
@@ -2573,6 +2573,31 @@ teste01 — "so fica cinza")
|
|||||||
200 OK observado no tráfego WebSocket real, botão do widget muda de
|
200 OK observado no tráfego WebSocket real, botão do widget muda de
|
||||||
cinza pra verde (conectado).
|
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
|
## Riscos conhecidos
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Copy, Check, TriangleAlert } from "lucide-react";
|
import { Copy, Check, TriangleAlert, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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,
|
* Mostra um segredo em texto puro UMA vez (senha SIP recém-gerada,
|
||||||
* agente.md secao 39: "nunca mostrar novamente a senha inteira" depois
|
* 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 }) {
|
export function SecretReveal({ label, value }: { label: string; value: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
async function onCopy() {
|
async function onCopy() {
|
||||||
|
setError(false);
|
||||||
|
let ok = false;
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
|
ok = true;
|
||||||
|
} catch {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) ok = legacyCopy(value);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
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 (
|
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"
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user