mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Fable 5
parent
59087c2b2c
commit
954892afa2
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 +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 = () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user