fix(client): copyToClipboard com fallback fora de secure context — navigator.clipboard não existe em http://IP

Em self-host servindo http://IP, navigator.clipboard é undefined e os botões
'copiar' (token de API, códigos MFA, ID de erro, paleta) lançavam TypeError.
Helper lib/clipboard.ts: usa navigator.clipboard quando existe, senão fallback
textarea + execCommand('copy'); devolve boolean pro toast de sucesso/falha.
Trocados os 7 usos nos 5 componentes client. Teste-régua (mesmo padrão do
crypto.randomUUID) proíbe navigator.clipboard cru em 'use client'. Nota no
README self-host: http funciona 100%; HTTPS via Caddy é o recomendado.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Rafael Melgaço
2026-07-18 20:31:28 -03:00
co-authored by Claude Fable 5
parent 59087c2b2c
commit 954892afa2
8 changed files with 126 additions and 17 deletions
@@ -8,6 +8,7 @@ import {
useRevokeApiToken,
type CreatedApiToken,
} from "@/hooks/team/useApiTokens";
import { copyToClipboard } from "@/lib/clipboard";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -226,8 +227,10 @@ export function ApiTokensClient() {
type="button"
variant="secondary"
onClick={() => {
navigator.clipboard.writeText(created.plaintext);
toast.success("Token copiado.");
void copyToClipboard(created.plaintext).then((ok) => {
if (ok) toast.success("Token copiado.");
else toast.error("Não foi possível copiar — selecione o token acima.");
});
}}
>
Copiar para clipboard
+3 -1
View File
@@ -1,5 +1,7 @@
"use client";
import { copyToClipboard } from "@/lib/clipboard";
import { PALETTES } from "../lib/tokens";
import type { ColorScale, PaletteId } from "../lib/tokens";
import { useVariant } from "../lib/variant-context";
@@ -19,7 +21,7 @@ function Swatches({ scale, onDarkAfter = 600 }: { scale: ColorScale; onDarkAfter
className="ds-swatch"
data-on-dark={onDark}
style={{ background: hex }}
onClick={() => navigator.clipboard?.writeText(hex)}
onClick={() => void copyToClipboard(hex)}
title={`${stop} · ${hex} (clique para copiar)`}
>
<span>{stop}</span>
+6 -4
View File
@@ -3,6 +3,8 @@
import * as Sentry from "@sentry/nextjs";
import { useEffect, useState } from "react";
import { copyToClipboard } from "@/lib/clipboard";
export default function GlobalError({
error,
reset,
@@ -70,12 +72,12 @@ export default function GlobalError({
<button
type="button"
onClick={() => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(displayId).then(() => {
void copyToClipboard(displayId).then((ok) => {
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
}
});
}}
style={{
padding: "0.5rem 1rem",
+4 -6
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/clipboard";
import { cn } from "@/lib/utils";
interface RecoveryCodesPanelProps {
@@ -20,12 +21,9 @@ export function RecoveryCodesPanel({ codes, onAcknowledge }: RecoveryCodesPanelP
const [acked, setAcked] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(codes.join("\n"));
toast.success("Códigos copiados para a área de transferência.");
} catch {
toast.error("Não foi possível copiar. Selecione e copie manualmente.");
}
const ok = await copyToClipboard(codes.join("\n"));
if (ok) toast.success("Códigos copiados para a área de transferência.");
else toast.error("Não foi possível copiar. Selecione e copie manualmente.");
};
const handleDownload = () => {
+5 -4
View File
@@ -4,6 +4,7 @@ import * as Sentry from "@sentry/nextjs";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { copyToClipboard } from "@/lib/clipboard";
export interface SegmentErrorProps {
error: Error & { digest?: string };
@@ -25,12 +26,12 @@ export function SegmentError({ error, reset, segment }: SegmentErrorProps) {
const displayId = eventId ?? error.digest ?? "—";
function copyId() {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(displayId).then(() => {
void copyToClipboard(displayId).then((ok) => {
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
}
});
}
return (
+6
View File
@@ -37,6 +37,12 @@ Edite o `.env` e preencha (mínimo):
> `NEXT_PUBLIC_APP_URL` começar com `http://` — o cookie de sessão deixa de
> ser `Secure` automaticamente e o login funciona. Com `https://`, `Secure`
> sempre ligado.
>
> Em `http://` o app funciona 100%: as três Web APIs que somem fora de
> secure context estão tratadas (cookie `Secure`, `crypto.randomUUID` e
> `navigator.clipboard` — os botões de copiar usam fallback). Ainda assim,
> HTTPS via Caddy é o recomendado de produção: fecha a família inteira de
> restrições de contexto não-seguro de uma vez.
- **Segredos** (gere com `openssl rand -base64 32` cada): `INTERNAL_SECRET`,
`INTERNAL_CRON_SECRET`, `CPF_ENCRYPTION_KEY`, `AI_CRED_AES_KEY`,
`WAHA_BYO_ENCRYPTION_KEY`, `IMPERSONATE_COOKIE_SECRET`, `LGPD_SIGNING_KEY`, `SRH_TOKEN`
+63
View File
@@ -0,0 +1,63 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyToClipboard } from "./clipboard";
/** Família dos "erros inesperados" em http://IP: navigator.clipboard é undefined fora de secure context. */
describe("copyToClipboard — dentro E fora de secure context", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("com navigator.clipboard disponível → delega e devolve true", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
await expect(copyToClipboard("abc")).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith("abc");
});
it("SEM navigator.clipboard (contexto não-seguro) → fallback execCommand e devolve true", async () => {
vi.stubGlobal("navigator", {}); // clipboard ausente — exatamente o browser em http://IP
const exec = vi.fn().mockReturnValue(true);
document.execCommand = exec;
await expect(copyToClipboard("xyz")).resolves.toBe(true);
expect(exec).toHaveBeenCalledWith("copy");
});
it("clipboard nega (permissão) E execCommand falha → devolve false, sem lançar", async () => {
vi.stubGlobal("navigator", {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) },
});
document.execCommand = vi.fn().mockReturnValue(false);
await expect(copyToClipboard("x")).resolves.toBe(false);
});
/**
* Régua anti-regressão (mesmo padrão da de crypto.randomUUID): código
* CLIENT-SIDE não pode chamar navigator.clipboard cru — em http://IP é
* TypeError no clique do botão "copiar". Sempre via lib/clipboard.ts.
*/
it('nenhum arquivo "use client" usa navigator.clipboard cru', () => {
const root = join(__dirname, "..");
const offenders: string[] = [];
const walk = (dir: string) => {
for (const name of readdirSync(dir)) {
if (name === "node_modules" || name === ".next" || name.startsWith(".")) continue;
const p = join(dir, name);
if (statSync(p).isDirectory()) {
walk(p);
} else if (/\.(ts|tsx)$/.test(name) && !/\.test\./.test(name)) {
if (p.endsWith(join("lib", "clipboard.ts"))) continue; // o próprio helper
const src = readFileSync(p, "utf8");
const isClient = src.slice(0, 200).includes('"use client"');
if (isClient && /navigator\.clipboard/.test(src)) offenders.push(p);
}
}
};
for (const d of ["app", "components", "hooks", "lib"]) walk(join(root, d));
expect(offenders).toEqual([]);
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Cópia pra área de transferência que funciona TAMBÉM em contexto não-seguro
* (self-host servindo http://IP): `navigator.clipboard` só existe em
* isSecureContext — fora dele o fallback usa textarea + execCommand('copy').
*
* Regra do repo (teste-régua): componente client NUNCA chama
* navigator.clipboard direto — sempre este helper.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== "undefined" && navigator.clipboard !== undefined) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// permissão negada / documento sem foco — tenta o fallback abaixo
}
}
if (typeof document === "undefined") return false;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
textarea.remove();
return ok;
}