fix: guard browser APIs in insecure contexts (#2157)
This commit is contained in:
+2
-11
@@ -7,6 +7,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import { Skeleton } from "@kandev/ui/skeleton";
|
||||
import { previewAgentCommandAction, type CommandPreviewResponse } from "@/app/actions/agents";
|
||||
import type { CLIFlag, ProfileEnvVar } from "@/lib/types/http";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
type CommandPreviewCardProps = {
|
||||
agentName: string;
|
||||
@@ -84,17 +85,7 @@ function CommandPreviewContent({ preview, cliPassthrough, envPrefix }: CommandPr
|
||||
const handleCopy = async () => {
|
||||
if (!displayCommand) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayCommand);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = displayCommand;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
if (await copyToClipboard(displayCommand)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api";
|
||||
import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery";
|
||||
import { useAgentRuntimeUpdates } from "@/hooks/domains/settings/use-agent-runtime-updates";
|
||||
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog";
|
||||
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
|
||||
@@ -48,21 +49,10 @@ import type {
|
||||
function useCopyCommand() {
|
||||
const [copiedValue, setCopiedValue] = useState<string | null>(null);
|
||||
const copy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
if (await copyToClipboard(text)) {
|
||||
setCopiedValue(text);
|
||||
setTimeout(() => setCopiedValue(null), 2000);
|
||||
}
|
||||
setCopiedValue(text);
|
||||
setTimeout(() => setCopiedValue(null), 2000);
|
||||
}, []);
|
||||
return { copiedValue, copy };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Workflow, WorkflowTemplate, Workspace } from "@/lib/types/http";
|
||||
import { createDraftWorkflowSteps, useWorkflowCreation } from "./use-workflow-creation";
|
||||
|
||||
@@ -27,6 +27,11 @@ beforeEach(() => {
|
||||
vi.spyOn(crypto, "randomUUID").mockReturnValue("00000000-0000-4000-8000-000000000001");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useWorkflowCreation", () => {
|
||||
it("remaps template transition references to client step identities", () => {
|
||||
const steps = createDraftWorkflowSteps("temp-workflow-1", [
|
||||
@@ -67,6 +72,21 @@ describe("useWorkflowCreation", () => {
|
||||
expect(result.current.initialStepsByWorkflowId.get(workflow.id)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("creates a workflow when crypto.randomUUID is unavailable", () => {
|
||||
vi.stubGlobal("crypto", {});
|
||||
const { result, getWorkflows } = renderCreationHook();
|
||||
|
||||
act(() => {
|
||||
result.current.setNewWorkflowName("HTTP Workflow");
|
||||
result.current.setSelectedTemplateId(null);
|
||||
});
|
||||
act(() => result.current.handleCreateWorkflow());
|
||||
|
||||
const [workflow] = getWorkflows();
|
||||
expect(workflow.id).toMatch(/^temp-workflow-[0-9a-f-]{36}$/);
|
||||
expect(result.current.initialStepsByWorkflowId.get(workflow.id)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("uses template fields without persisting from the dialog", () => {
|
||||
const { result, getWorkflows } = renderCreationHook([template]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import {
|
||||
workflowId as toWorkflowId,
|
||||
workspaceId as toWorkspaceId,
|
||||
@@ -25,7 +26,7 @@ type WorkflowCreationArgs = {
|
||||
};
|
||||
|
||||
function newClientId(prefix: string): string {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
return `${prefix}-${generateUUID()}`;
|
||||
}
|
||||
|
||||
function remapStepReferences<T>(value: T, mappings: Map<string, string>): T {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Label } from "@kandev/ui/label";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { IconCopy, IconCheck, IconEye, IconEyeOff } from "@tabler/icons-react";
|
||||
import { revealWebhookSecret } from "@/lib/api/domains/automation-api";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
type WebhookConfigProps = {
|
||||
automationId: string | null;
|
||||
@@ -62,9 +63,10 @@ export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps)
|
||||
const [samplePayload, setSamplePayload] = useState("");
|
||||
|
||||
const copyValue = useCallback(async (value: string, kind: "url" | "secret") => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(kind);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
if (await copyToClipboard(value)) {
|
||||
setCopied(kind);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const detectedKeys = useMemo(() => extractKeys(samplePayload), [samplePayload]);
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { FileDiffMetadata } from "@pierre/diffs";
|
||||
import { ExternalVcsFileLink } from "@/components/editors/external-vcs-file-link";
|
||||
import type { ViewMode } from "@/hooks/use-global-view-mode";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100";
|
||||
|
||||
@@ -201,7 +202,7 @@ export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
|
||||
<DiffHeaderToolbarButtons
|
||||
resolvedPath={resolvedPath}
|
||||
isMarkdownFile={checkIsMarkdown(resolvedPath)}
|
||||
onCopyDiff={() => navigator.clipboard.writeText(diff || "")}
|
||||
onCopyDiff={() => void copyToClipboard(diff || "")}
|
||||
wordWrap={wordWrap}
|
||||
onToggleWordWrap={onToggleWordWrap}
|
||||
viewMode={viewMode}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useOpenSessionInEditor } from "@/hooks/use-open-session-in-editor";
|
||||
import { useOpenSessionFolder } from "@/hooks/use-open-session-folder";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { EditorOption } from "@/lib/types/http";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
type FileActionsDropdownProps = {
|
||||
/** File path to open / copy */
|
||||
@@ -77,8 +78,9 @@ function useFileActions({
|
||||
}, [worktreePath, filePath]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
navigator.clipboard.writeText(absolutePath);
|
||||
onCopied?.();
|
||||
void copyToClipboard(absolutePath).then((success) => {
|
||||
if (success) onCopied?.();
|
||||
});
|
||||
}, [absolutePath, onCopied]);
|
||||
|
||||
const handleOpenInEditor = useCallback(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
export type ContextMenuState = {
|
||||
x: number;
|
||||
@@ -48,7 +49,7 @@ export function DiffViewerContextMenu({
|
||||
role="menuitem"
|
||||
className={itemCls}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(contextMenu.lineContent);
|
||||
void copyToClipboard(contextMenu.lineContent);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTheme } from "@/components/theme/app-theme";
|
||||
import { cn } from "@kandev/ui/lib/utils";
|
||||
import type { FileDiffData, DiffComment, DiffCommentUpdate } from "@/lib/diff/types";
|
||||
import { getMonacoLanguage } from "@/lib/editor/language-map";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { useCommandPanelOpen } from "@/lib/commands/command-registry";
|
||||
import { useDiffEditorHeight } from "@/hooks/use-diff-editor-height";
|
||||
import { useGlobalViewMode } from "@/hooks/use-global-view-mode";
|
||||
@@ -174,7 +175,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
setWordWrap={state.setWordWrap}
|
||||
globalViewMode={state.globalViewMode}
|
||||
setGlobalViewMode={state.setGlobalViewMode}
|
||||
onCopyDiff={() => navigator.clipboard.writeText(state.diff ?? "")}
|
||||
onCopyDiff={() => void copyToClipboard(state.diff ?? "")}
|
||||
onOpenFile={onOpenFile}
|
||||
onRevert={onRevert}
|
||||
sessionId={props.sessionId}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { editor as monacoEditor } from "monaco-editor";
|
||||
import type { DiffOnMount } from "@monaco-editor/react";
|
||||
import type { DiffComment, DiffCommentUpdate } from "@/lib/diff/types";
|
||||
import { buildDiffComment, useCommentedLines, useCommentActions } from "@/lib/diff/comment-utils";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { useDiffComments } from "@/components/diff/use-diff-comments";
|
||||
import { useGutterComments } from "@/hooks/use-gutter-comments";
|
||||
import type { ContextMenuState } from "./diff-viewer-context-menu";
|
||||
@@ -323,7 +324,7 @@ export function useDiffViewerComments(opts: UseDiffViewerCommentsOpts) {
|
||||
}
|
||||
}
|
||||
}
|
||||
navigator.clipboard.writeText(changedLines.join("\n"));
|
||||
void copyToClipboard(changedLines.join("\n"));
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
|
||||
import type { AvailableAgent, ToolStatus } from "@/lib/types/http";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
export type AgentSetting = {
|
||||
profileId: string;
|
||||
@@ -26,20 +27,10 @@ export type AgentSetting = {
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
if (await copyToClipboard(text)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ExternalVcsFileMenuItem,
|
||||
} from "@/components/editors/external-vcs-file-link";
|
||||
import { useGlobalViewMode } from "@/hooks/use-global-view-mode";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { isMarkdownFile } from "@/lib/utils/file-types";
|
||||
|
||||
@@ -203,7 +204,7 @@ function MobileFileActionsMenu(props: FileDiffToolbarProps) {
|
||||
repo,
|
||||
} = props;
|
||||
const handleCopyDiff = useCallback(() => {
|
||||
void navigator.clipboard.writeText(diff || "");
|
||||
void copyToClipboard(diff || "");
|
||||
}, [diff]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -304,7 +305,7 @@ function DesktopFileDiffToolbar(props: FileDiffToolbarProps) {
|
||||
} = props;
|
||||
const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
|
||||
const handleCopyDiff = useCallback(() => {
|
||||
void navigator.clipboard.writeText(diff || "");
|
||||
void copyToClipboard(diff || "");
|
||||
}, [diff]);
|
||||
const handleToggleViewMode = useCallback(
|
||||
() => setGlobalViewMode(globalViewMode === "split" ? "unified" : "split"),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { listTokens, mintToken, revokeToken, type ApiToken } from "@/lib/api/domains/auth-api";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
function useTokensList() {
|
||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||
@@ -193,8 +194,9 @@ function MintTokenDialog({
|
||||
rawToken={rawToken}
|
||||
copied={copied}
|
||||
onCopy={() => {
|
||||
void navigator.clipboard.writeText(rawToken);
|
||||
setCopied(true);
|
||||
void copyToClipboard(rawToken).then((success) => {
|
||||
if (success) setCopied(true);
|
||||
});
|
||||
}}
|
||||
onDone={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { SettingsSection } from "@/components/settings/settings-section";
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import {
|
||||
buildAuggieCliCommand,
|
||||
buildAuggieConfig,
|
||||
@@ -35,16 +36,12 @@ export function ExternalMcpSettings() {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
function handleCopy(text: string) {
|
||||
if (typeof navigator === "undefined") return;
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
void copyToClipboard(text).then((success) => {
|
||||
if (success) {
|
||||
setCopied(text);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort: clipboard may be unavailable in non-secure contexts.
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { IconCheck, IconCopy, IconLoader2, IconX } from "@tabler/icons-react";
|
||||
import { toast } from "sonner";
|
||||
import { probeSSHAgents, probeSSHShells } from "@/lib/api/domains/ssh-api";
|
||||
import type { SSHAgentReadinessRow } from "@/lib/types/http-ssh";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
|
||||
export interface SSHAgentReadinessCardProps {
|
||||
@@ -374,7 +375,9 @@ function InstallHint({ hint, available }: { hint?: string; available: boolean })
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
aria-label="Copy install hint"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(hint).then(() => toast.success("Install hint copied"));
|
||||
void copyToClipboard(hint).then((success) => {
|
||||
if (success) toast.success("Install hint copied");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconCopy className="h-3 w-3" />
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { createInvite } from "@/lib/api/domains/auth-api";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -103,8 +104,7 @@ function InviteForm({
|
||||
function InviteLinkResult({ url, onDone }: { url: string; onDone: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const onCopy = async () => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
if (await copyToClipboard(url)) setCopied(true);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { IconCopy, IconDownload, IconFileText, IconRefresh } from "@tabler/icons
|
||||
import { useLogFiles } from "@/hooks/domains/system/use-log-files";
|
||||
import { useLogTail } from "@/hooks/domains/system/use-log-tail";
|
||||
import { buildLogDownloadUrl } from "@/lib/api/domains/system-api";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { formatBytes } from "@/lib/utils/format-bytes";
|
||||
import { useActionFeedback, type ActionFeedbackState } from "@/hooks/use-action-feedback";
|
||||
import { ActionButtonContent } from "./action-button-content";
|
||||
@@ -177,10 +178,9 @@ export function LogViewer() {
|
||||
|
||||
const onCopy = () =>
|
||||
void copyFeedback.run(async () => {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
||||
throw new Error("Clipboard API not available");
|
||||
if (!(await copyToClipboard(tail.join("\n") + "\n"))) {
|
||||
throw new Error("Clipboard copy failed");
|
||||
}
|
||||
await navigator.clipboard.writeText(tail.join("\n") + "\n");
|
||||
});
|
||||
|
||||
const current = files.find((f) => f.current);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createWorkflowAction,
|
||||
createWorkflowStepAction,
|
||||
@@ -38,6 +38,10 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const workflow = {
|
||||
id: "wf-1",
|
||||
workspace_id: "ws-1",
|
||||
@@ -143,6 +147,32 @@ describe("useWorkflowStepActions", () => {
|
||||
});
|
||||
expect(createWorkflowStepAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a client-only step when crypto.randomUUID is unavailable", async () => {
|
||||
vi.stubGlobal("crypto", {});
|
||||
const setWorkflowSteps = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useWorkflowStepActions({
|
||||
workflow,
|
||||
workflowSteps: [step("step-1", "Todo", 0, true)],
|
||||
setWorkflowSteps,
|
||||
refreshWorkflowSteps: vi.fn(),
|
||||
setStepToDelete: vi.fn(),
|
||||
setStepTaskCount: vi.fn(),
|
||||
setTargetStepForMigration: vi.fn(),
|
||||
setStepDeleteOpen: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(() => result.current.handleAddWorkflowStep());
|
||||
|
||||
const updater = setWorkflowSteps.mock.calls[0][0] as (steps: WorkflowStep[]) => WorkflowStep[];
|
||||
expect(updater([])[0]).toMatchObject({
|
||||
id: expect.stringMatching(/^temp-step-[0-9a-f-]{36}$/),
|
||||
name: "New Step",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkflowStepActions destructive and ordering paths", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Workflow, WorkflowStep } from "@/lib/types/http";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useSerializedMutationQueue } from "./use-serialized-mutation-queue";
|
||||
import type { WorkflowMutationGuardController } from "./workflow-mutation-guard";
|
||||
@@ -74,7 +75,7 @@ const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const;
|
||||
|
||||
function createDraftStep(workflow: Workflow, position: number): WorkflowStep {
|
||||
return {
|
||||
id: `temp-step-${crypto.randomUUID()}`,
|
||||
id: `temp-step-${generateUUID()}`,
|
||||
workflow_id: workflow.id,
|
||||
...NEW_STEP_DEFAULTS,
|
||||
position,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconTerminal2, IconCopy, IconCheck } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import type { RecoveryAuthMethod } from "@/components/task/chat/types";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
function buildFullCommand(termAuth: RecoveryAuthMethod["terminal_auth"]): string | null {
|
||||
if (!termAuth) return null;
|
||||
@@ -65,12 +66,9 @@ function AuthMethodRow({
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!fullCommand) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullCommand);
|
||||
if (await copyToClipboard(fullCommand)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// clipboard API unavailable
|
||||
}
|
||||
}, [fullCommand]);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
import { transformPathsInText } from "@/lib/utils";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import { DiffViewBlock } from "./diff-view-block";
|
||||
import { ExpandableRow } from "./expandable-row";
|
||||
@@ -181,9 +182,12 @@ export const ToolEditMessage = memo(function ToolEditMessage({
|
||||
const handleCopyPath = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (filePath) {
|
||||
navigator.clipboard?.writeText(filePath);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
void copyToClipboard(filePath).then((success) => {
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type SSHLiveStatus,
|
||||
type TaskEnvironment,
|
||||
} from "@/lib/api/domains/task-environment-api";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { resolveExecutorEnvironmentStatus, type StatusTone } from "./executor-environment-status";
|
||||
|
||||
const TONE_CLASSES: Record<StatusTone, string> = {
|
||||
@@ -129,9 +130,9 @@ function Field({
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Copy ${label}`}
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
.writeText(copyValue ?? value)
|
||||
.then(() => toast.success(`${label} copied`));
|
||||
void copyToClipboard(copyValue ?? value).then((success) => {
|
||||
if (success) toast.success(`${label} copied`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconCopy className="h-3 w-3" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconCheck, IconCopy, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import type { Annotation } from "@/lib/preview-inspect-bridge";
|
||||
import { formatAnnotations } from "@/lib/preview-inspect-bridge";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
interface AnnotationsPanelProps {
|
||||
annotations: Annotation[];
|
||||
@@ -30,12 +31,11 @@ export function AnnotationsPanel({ annotations, onRemove, onClear }: Annotations
|
||||
if (annotations.length === 0) return null;
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatAnnotations(annotations));
|
||||
if (await copyToClipboard(formatAnnotations(annotations))) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error(`AnnotationsPanel: clipboard write failed: ${String(err)}`);
|
||||
} else {
|
||||
console.error("AnnotationsPanel: clipboard write failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Badge } from "@kandev/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@kandev/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { listPorts, listTunnels, type ListeningPort } from "@/lib/api/domains/port-api";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { useTunnelActions } from "./use-tunnel-actions";
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import { toast } from "sonner";
|
||||
@@ -49,10 +50,11 @@ function InfoTip({ text }: { text: string }) {
|
||||
|
||||
function UrlActions({ url }: { url: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard?.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (await copyToClipboard(url)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type SnapshotPreview,
|
||||
} from "@/lib/api/domains/share-api";
|
||||
import { useShares } from "@/hooks/domains/session/use-shares";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
import { ShareList } from "./share-list";
|
||||
import { ShareSnapshotPreview } from "./share-snapshot-preview";
|
||||
@@ -188,9 +189,10 @@ function PublishedState({
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(share.url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
if (await copyToClipboard(share.url)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -35,6 +35,7 @@ import { StatusIcon } from "@/app/office/tasks/[id]/status-icon";
|
||||
import { StageProgressBar } from "./stage-progress-bar";
|
||||
import { SubtaskStepper } from "./subtask-stepper";
|
||||
import { hasBlockerChain } from "./workflow-sort";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { NewTaskDialog } from "@/app/office/components/new-task-dialog";
|
||||
import { ActiveSessionRefProvider } from "./components/active-session-ref-context";
|
||||
import { TopbarWorkingIndicator } from "./components/topbar-working-indicator";
|
||||
@@ -139,7 +140,7 @@ function TaskHeaderRow({ task, activeHold }: { task: Task; activeHold: TreeHold
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer"
|
||||
onClick={() => navigator.clipboard.writeText(task.identifier)}
|
||||
onClick={() => void copyToClipboard(task.identifier)}
|
||||
>
|
||||
<IconCopy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,69 +1,12 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Fallback copy method for non-secure contexts (HTTP)
|
||||
* where navigator.clipboard is not available.
|
||||
*
|
||||
* Appends the temp textarea inside the nearest Radix dialog container
|
||||
* (if the active element lives inside one) to avoid the Radix FocusScope
|
||||
* stealing focus back to the dialog before execCommand runs.
|
||||
*/
|
||||
function fallbackCopy(text: string): boolean {
|
||||
const previousActive = document.activeElement as HTMLElement | null;
|
||||
|
||||
// Mount inside the dialog so Radix FocusScope doesn't steal focus away.
|
||||
const container =
|
||||
previousActive?.closest<HTMLElement>('[data-slot="dialog-content"], [role="dialog"]') ??
|
||||
document.body;
|
||||
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.top = "0";
|
||||
textArea.style.left = "0";
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
container.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
let success = false;
|
||||
try {
|
||||
success = document.execCommand("copy");
|
||||
} catch {
|
||||
success = false;
|
||||
} finally {
|
||||
try {
|
||||
container.removeChild(textArea);
|
||||
} catch {
|
||||
// Node may already be gone (e.g. dialog closed mid-copy); ignore.
|
||||
}
|
||||
previousActive?.focus();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
export function useCopyToClipboard(duration = 2000) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = useCallback(
|
||||
async (text: string) => {
|
||||
let success = false;
|
||||
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
success = true;
|
||||
} catch {
|
||||
// Fall through to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for non-secure contexts
|
||||
if (!success) {
|
||||
success = fallbackCopy(text);
|
||||
}
|
||||
const success = await copyToClipboard(text);
|
||||
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
import type { UtilityGenerationResult } from "./use-utility-agent-generator";
|
||||
import { usePromptResultDelivery } from "./use-prompt-result-delivery";
|
||||
|
||||
@@ -10,6 +12,12 @@ vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: mockToast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/copy-to-clipboard", () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCopyToClipboard = vi.mocked(copyToClipboard);
|
||||
|
||||
const GENERATED_RESULT = {
|
||||
content: "enhanced prompt output",
|
||||
callId: "call-123",
|
||||
@@ -20,10 +28,7 @@ const INSERT_FAILURE_MESSAGE = "Enhanced prompt was generated but could not be i
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
mockCopyToClipboard.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -100,12 +105,7 @@ it("applyPending clears only after apply succeeds", () => {
|
||||
expect(result.current.pendingResult).toBeNull();
|
||||
});
|
||||
|
||||
it("copyPending writes the pending result and reports success", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
it("copyPending delegates to the clipboard utility and reports success", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePromptResultDelivery({
|
||||
scopeKey: "test",
|
||||
@@ -123,7 +123,7 @@ it("copyPending writes the pending result and reports success", async () => {
|
||||
await result.current.copyPending();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(GENERATED_RESULT.content);
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(GENERATED_RESULT.content);
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Enhanced prompt copied to clipboard.",
|
||||
@@ -133,14 +133,8 @@ it("copyPending writes the pending result and reports success", async () => {
|
||||
expect(result.current.pendingResult).toEqual(GENERATED_RESULT);
|
||||
});
|
||||
|
||||
it("copyPending reports clipboard failure without clearing the result", async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const appendChild = vi.spyOn(document.body, "appendChild");
|
||||
const createElement = vi.spyOn(document, "createElement");
|
||||
it("copyPending reports utility failure without clearing the result", async () => {
|
||||
mockCopyToClipboard.mockResolvedValue(false);
|
||||
const { result } = renderHook(() =>
|
||||
usePromptResultDelivery({
|
||||
scopeKey: "test",
|
||||
@@ -158,7 +152,7 @@ it("copyPending reports clipboard failure without clearing the result", async ()
|
||||
await result.current.copyPending();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(GENERATED_RESULT.content);
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(GENERATED_RESULT.content);
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Enhanced prompt could not be copied.",
|
||||
@@ -166,43 +160,6 @@ it("copyPending reports clipboard failure without clearing the result", async ()
|
||||
}),
|
||||
);
|
||||
expect(result.current.pendingResult).toEqual(GENERATED_RESULT);
|
||||
expect(appendChild).not.toHaveBeenCalled();
|
||||
expect(createElement).not.toHaveBeenCalledWith("textarea");
|
||||
});
|
||||
|
||||
it("copyPending reports failure without DOM fallback when clipboard is unavailable", async () => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
const appendChild = vi.spyOn(document.body, "appendChild");
|
||||
const createElement = vi.spyOn(document, "createElement");
|
||||
const { result } = renderHook(() =>
|
||||
usePromptResultDelivery({
|
||||
scopeKey: "test",
|
||||
getCurrent: () => "edited",
|
||||
apply: vi.fn(() => true),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.deliver("original", GENERATED_RESULT, result.current.captureScope());
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.copyPending();
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Enhanced prompt could not be copied.",
|
||||
variant: "error",
|
||||
}),
|
||||
);
|
||||
expect(result.current.pendingResult).toEqual(GENERATED_RESULT);
|
||||
expect(appendChild).not.toHaveBeenCalled();
|
||||
expect(createElement).not.toHaveBeenCalledWith("textarea");
|
||||
});
|
||||
|
||||
it("dismissPending clears the retained result", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
|
||||
import type { UtilityGenerationResult } from "./use-utility-agent-generator";
|
||||
|
||||
@@ -30,19 +31,6 @@ type PendingResult = {
|
||||
generation: number;
|
||||
};
|
||||
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function usePromptResultDelivery({
|
||||
scopeKey,
|
||||
getCurrent,
|
||||
@@ -111,7 +99,7 @@ export function usePromptResultDelivery({
|
||||
return;
|
||||
}
|
||||
|
||||
const copied = await copyText(pendingResult.result.content);
|
||||
const copied = await copyToClipboard(pendingResult.result.content);
|
||||
toast({
|
||||
description: copied ? COPY_SUCCESS_MESSAGE : COPY_FAILURE_MESSAGE,
|
||||
variant: copied ? "success" : "error",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SavedLayout } from "@/lib/types/http";
|
||||
import { panel, type LayoutState } from "@/lib/state/layout-manager";
|
||||
import {
|
||||
@@ -29,6 +29,10 @@ const CENTER_COLUMN_ID = "center";
|
||||
const LEGACY_LAYOUT_VALUE = "old-format";
|
||||
const SESSION_PANEL_ID = "session:session-one";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function reusableLayout(panelIds: string[] = ["chat", "files", "changes"]): LayoutState {
|
||||
return {
|
||||
columns: [
|
||||
@@ -296,6 +300,17 @@ describe("layout profile IDs", () => {
|
||||
expect(first).toMatch(/^layout-[0-9a-f-]{36}$/);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("uses the UUID fallback when crypto.randomUUID is unavailable", () => {
|
||||
vi.stubGlobal("crypto", {});
|
||||
|
||||
const first = createLayoutProfileId();
|
||||
const second = createLayoutProfileId();
|
||||
|
||||
expect(first).toMatch(/^layout-[0-9a-f-]{36}$/);
|
||||
expect(second).toMatch(/^layout-[0-9a-f-]{36}$/);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in layout overrides", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type LayoutState,
|
||||
} from "@/lib/state/layout-manager";
|
||||
import type { SavedLayout } from "@/lib/types/http";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export type BuiltInLayoutProfileId = Exclude<BuiltInPreset, "compact">;
|
||||
|
||||
@@ -96,7 +97,7 @@ const REUSABLE_PANEL_ID_SET = new Set<string>(REUSABLE_PANEL_IDS);
|
||||
const BUILT_IN_OVERRIDE_PREFIX = "layout-override-";
|
||||
|
||||
export function createLayoutProfileId(): string {
|
||||
return `layout-${globalThis.crypto.randomUUID()}`;
|
||||
return `layout-${generateUUID()}`;
|
||||
}
|
||||
|
||||
export function getBuiltInLayoutProfile(id: BuiltInLayoutProfileId): BuiltInLayoutProfile {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { copyToClipboard } from "./copy-to-clipboard";
|
||||
|
||||
const SAMPLE_TEXT = "test clipboard content";
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("prefers the modern Clipboard API", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const execCommand = vi.mocked(document.execCommand);
|
||||
|
||||
await expect(copyToClipboard(SAMPLE_TEXT)).resolves.toBe(true);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(SAMPLE_TEXT);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the DOM fallback when the Clipboard API is unavailable", async () => {
|
||||
const execCommand = vi.mocked(document.execCommand).mockReturnValue(true);
|
||||
|
||||
await expect(copyToClipboard(SAMPLE_TEXT)).resolves.toBe(true);
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the DOM fallback when the Clipboard API rejects and restores dialog focus", async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("permission denied"));
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const execCommand = vi.mocked(document.execCommand).mockReturnValue(true);
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
const trigger = document.createElement("button");
|
||||
dialog.appendChild(trigger);
|
||||
document.body.appendChild(dialog);
|
||||
trigger.focus();
|
||||
|
||||
await expect(copyToClipboard(SAMPLE_TEXT)).resolves.toBe(true);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(SAMPLE_TEXT);
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(dialog.querySelector("textarea")).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it("returns false when both copy mechanisms fail", async () => {
|
||||
const execCommand = vi.mocked(document.execCommand).mockReturnValue(false);
|
||||
|
||||
await expect(copyToClipboard(SAMPLE_TEXT)).resolves.toBe(false);
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copy text using the Clipboard API when available, with a DOM fallback for
|
||||
* non-secure contexts and browsers that reject the modern API.
|
||||
*
|
||||
* The fallback keeps a temporary textarea inside the active dialog when there
|
||||
* is one so Radix FocusScope does not steal focus before execCommand runs.
|
||||
*/
|
||||
function fallbackCopy(text: string): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
|
||||
const previousActive = document.activeElement as HTMLElement | null;
|
||||
const container =
|
||||
previousActive?.closest<HTMLElement>('[data-slot="dialog-content"], [role="dialog"]') ??
|
||||
document.body;
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.top = "0";
|
||||
textArea.style.left = "0";
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
container.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
let success = false;
|
||||
try {
|
||||
success = document.execCommand("copy");
|
||||
} catch {
|
||||
success = false;
|
||||
} finally {
|
||||
try {
|
||||
container.removeChild(textArea);
|
||||
} catch {
|
||||
// The dialog may have closed while the copy was in progress.
|
||||
}
|
||||
previousActive?.focus();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to the DOM fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackCopy(text);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
spec: docs/specs/secure-context-browser-fallbacks/spec.md
|
||||
created: 2026-08-02
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Secure-context browser fallbacks
|
||||
|
||||
## Overview
|
||||
|
||||
Route every client-only UUID through the existing guarded generator, then
|
||||
extract the existing clipboard fallback into a shared utility and migrate
|
||||
direct Clipboard API call sites to it. The audit confirms that file hashing,
|
||||
voice input, notifications, and sound already check capability boundaries; the
|
||||
focused tests will preserve those fallbacks while covering the newly repaired
|
||||
workflow, layout, and copy paths.
|
||||
|
||||
## Root cause
|
||||
|
||||
`useWorkflowCreation`, workflow step creation, and layout profile creation call
|
||||
`crypto.randomUUID()` directly. On a non-localhost HTTP origin the browser
|
||||
exposes `crypto` but not `randomUUID`, so Add Workflow throws before its draft
|
||||
state is updated. The shared `generateUUID()` helper already handles that
|
||||
condition, but those call sites bypass it. Several copy actions similarly call
|
||||
`navigator.clipboard.writeText()` directly even though the existing copy hook
|
||||
has a DOM fallback for insecure contexts.
|
||||
|
||||
## Frontend — client-only UUIDs
|
||||
|
||||
- `apps/web/app/settings/workspace/use-workflow-creation.ts`: use
|
||||
`generateUUID()` for temporary workflow IDs.
|
||||
- `apps/web/components/settings/workflow-card-actions.ts`: use
|
||||
`generateUUID()` for temporary step IDs.
|
||||
- `apps/web/lib/layout/layout-profiles.ts`: use `generateUUID()` for layout
|
||||
profile IDs.
|
||||
- `apps/web/app/settings/workspace/use-workflow-creation.test.ts`,
|
||||
`apps/web/components/settings/workflow-card-actions.test.ts`, and
|
||||
`apps/web/lib/layout/layout-profiles.test.ts`: add insecure-context coverage
|
||||
with `crypto` missing `randomUUID`.
|
||||
- Keep `apps/web/lib/utils/file-diff.ts` unchanged unless its existing guarded
|
||||
fallback needs a test adjustment; it already degrades to `djb2Hash` when
|
||||
`crypto.subtle` is unavailable.
|
||||
|
||||
## Frontend — clipboard fallback
|
||||
|
||||
- Add `apps/web/lib/utils/copy-to-clipboard.ts` with the existing dialog-aware
|
||||
DOM fallback and a promise-returning `copyToClipboard(text)` function.
|
||||
- Refactor `apps/web/hooks/use-copy-to-clipboard.ts` to use the shared utility,
|
||||
preserving its copied state and timing.
|
||||
- Migrate direct Clipboard API calls in the settings, onboarding, task,
|
||||
diff/editor, review, and share surfaces to the shared utility. Preserve each
|
||||
caller's current success state, toast, and error behavior.
|
||||
- The audited call sites are `apps/web/app/settings/agents/page.tsx`,
|
||||
`apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx`,
|
||||
`apps/web/components/settings/external-mcp-settings.tsx`,
|
||||
`apps/web/components/automations/trigger-configs/webhook-config.tsx`,
|
||||
`apps/web/components/task/executor-environment-info.tsx`,
|
||||
`apps/web/components/task/port-forward-dialog.tsx`,
|
||||
`apps/web/components/task/simple/OfficeSimplePane.tsx`,
|
||||
`apps/web/components/task/inspector/annotations-panel.tsx`,
|
||||
`apps/web/components/task/share/share-dialog.tsx`,
|
||||
`apps/web/components/task/chat/messages/auth-methods-panel.tsx`,
|
||||
`apps/web/components/task/chat/messages/tool-edit-message.tsx`,
|
||||
`apps/web/components/editors/file-actions-dropdown.tsx`,
|
||||
`apps/web/components/editors/monaco/use-diff-viewer-comments.ts`,
|
||||
`apps/web/components/editors/monaco/diff-viewer-context-menu.tsx`,
|
||||
`apps/web/components/editors/monaco/monaco-diff-viewer.tsx`,
|
||||
`apps/web/components/diff/diff-header-toolbar.tsx`,
|
||||
`apps/web/components/review/review-diff-toolbar.tsx`,
|
||||
`apps/web/components/onboarding/step-agents.tsx`,
|
||||
`apps/web/components/settings/account/api-tokens.tsx`,
|
||||
`apps/web/components/settings/system/log-viewer.tsx`,
|
||||
`apps/web/components/settings/system/invite-dialog.tsx`, and
|
||||
`apps/web/components/settings/ssh-agent-readiness-card.tsx`.
|
||||
- Add `apps/web/lib/utils/copy-to-clipboard.test.ts` covering modern success,
|
||||
missing API, rejected API, and dialog focus containment. Update the hook test
|
||||
only where its implementation seam changes.
|
||||
|
||||
## Existing capability audit
|
||||
|
||||
- `apps/web/lib/utils/file-diff.ts` already guards `crypto.subtle` and falls
|
||||
back to `djb2Hash`.
|
||||
- Voice input gates its UI on secure-context/capability checks and catches
|
||||
capture failures; notification permission actions check `Notification`
|
||||
before use; sound creation checks `AudioContext` before construction.
|
||||
- No behavior change is planned for those already guarded paths; their
|
||||
existing tests remain part of focused frontend verification.
|
||||
|
||||
## Tests
|
||||
|
||||
- **UUID regression:** no `randomUUID` must not prevent workflow creation,
|
||||
step addition, or layout profile creation. Files listed above; Vitest hook and
|
||||
pure-function tests.
|
||||
- **Clipboard regression:** missing/rejected Clipboard API uses the DOM
|
||||
fallback without an uncaught rejection. New utility test plus existing hook
|
||||
tests.
|
||||
- **Existing fallbacks:** run the file-diff, voice, notification, and sound
|
||||
tests selected by the focused test command where applicable.
|
||||
|
||||
## E2E and mobile parity
|
||||
|
||||
This repair changes shared state/utility behavior, not layout, navigation,
|
||||
touch targets, scrolling, or mobile composition. The existing workflow settings
|
||||
desktop/mobile E2E coverage remains the rendered regression path; no new mobile
|
||||
scenario is required for this data-normalization change. A browser-level
|
||||
insecure-origin smoke run is optional if an isolated HTTP instance is already
|
||||
available.
|
||||
|
||||
## Risks and out of scope
|
||||
|
||||
- The DOM copy fallback depends on `document.execCommand("copy")`, which is
|
||||
deprecated but is the compatibility path already used by Kandev.
|
||||
- Fallback UUIDs are not cryptographically secure and must stay limited to
|
||||
client-only identifiers.
|
||||
- Backend/API contracts, TLS setup, and security-token generation are out of
|
||||
scope.
|
||||
|
||||
## Implementation waves
|
||||
|
||||
Wave 1 (sequential):
|
||||
|
||||
- [x] [Task 01: UUID fallback audit](task-01-uuid-fallback.md)
|
||||
|
||||
Wave 2 (sequential; depends on Task 01):
|
||||
|
||||
- [x] [Task 02: Clipboard fallback migration](task-02-clipboard-fallback.md)
|
||||
|
||||
## Verification Results
|
||||
|
||||
### Task 01 — UUID fallback audit
|
||||
|
||||
- Focused Vitest: 4 files, 78 tests passed.
|
||||
- Web typecheck passed.
|
||||
- Static search found only the guarded `generateUUID()` implementation.
|
||||
|
||||
### Task 02 — Clipboard fallback migration
|
||||
|
||||
- Focused utility/hook Vitest: 3 files, 21 tests passed.
|
||||
- Broader task-defined Vitest: 360 files, 2,647 tests passed, 4 skipped.
|
||||
- Web typecheck, targeted ESLint, Prettier, and `i18n:ratchet` passed.
|
||||
- Static search found no direct application Clipboard API calls outside the
|
||||
shared guarded utility.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: "01-uuid-fallback"
|
||||
title: "UUID fallback audit"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/secure-context-browser-fallbacks/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: UUID fallback audit
|
||||
|
||||
Route every browser-side client-only UUID through the guarded generator and
|
||||
prove the reported insecure-origin workflow path.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Add Workflow, Add Step, and layout profile creation succeed when
|
||||
`crypto.randomUUID` is absent and produce unique UUID-shaped client IDs.
|
||||
- Existing secure-context UUID behavior remains unchanged.
|
||||
- No unguarded `crypto.randomUUID()` calls remain in application source under
|
||||
`apps/web`.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/app/settings/workspace/use-workflow-creation.ts`
|
||||
- `apps/web/app/settings/workspace/use-workflow-creation.test.ts`
|
||||
- `apps/web/components/settings/workflow-card-actions.ts`
|
||||
- `apps/web/components/settings/workflow-card-actions.test.ts`
|
||||
- `apps/web/lib/layout/layout-profiles.ts`
|
||||
- `apps/web/lib/layout/layout-profiles.test.ts`
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run app/settings/workspace/use-workflow-creation.test.ts components/settings/workflow-card-actions.test.ts lib/layout/layout-profiles.test.ts lib/utils.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
rtk rg -n --glob '!**/*.test.*' --glob '!**/e2e/**' 'crypto\.randomUUID\s*\(' apps/web
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
The existing fallback uses `Math.random` and is suitable only for temporary
|
||||
client identities. Do not route security-sensitive identifiers through it.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: the three new insecure-context tests failed with
|
||||
`TypeError: crypto.randomUUID is not a function` at each original call site.
|
||||
- GREEN: `cd apps && pnpm --filter @kandev/web test -- --run app/settings/workspace/use-workflow-creation.test.ts components/settings/workflow-card-actions.test.ts lib/layout/layout-profiles.test.ts`
|
||||
passed (3 files, 49 tests).
|
||||
- `cd apps/web && pnpm run typecheck` passed.
|
||||
- Static audit leaves only the guarded call in `apps/web/lib/utils.ts`.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: "02-clipboard-fallback"
|
||||
title: "Clipboard fallback migration"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-uuid-fallback"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/secure-context-browser-fallbacks/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Clipboard fallback migration
|
||||
|
||||
Make every web copy action tolerate an unavailable or rejected Clipboard API
|
||||
by sharing the existing dialog-aware DOM fallback.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Modern `navigator.clipboard.writeText` is preferred when available.
|
||||
- Missing or rejected Clipboard API requests use the DOM fallback and do not
|
||||
produce uncaught promise rejections or `TypeError`s.
|
||||
- Existing copied indicators, toasts, and focus behavior remain intact across
|
||||
migrated call sites.
|
||||
- No direct `navigator.clipboard.writeText(...)` calls remain in application
|
||||
source outside the shared utility and its tests.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/utils/copy-to-clipboard.ts`
|
||||
- `apps/web/lib/utils/copy-to-clipboard.test.ts`
|
||||
- `apps/web/hooks/use-copy-to-clipboard.ts`
|
||||
- `apps/web/hooks/use-copy-to-clipboard.test.ts`
|
||||
- `apps/web/hooks/use-prompt-result-delivery.ts`
|
||||
- `apps/web/hooks/use-prompt-result-delivery.test.ts`
|
||||
- Direct-copy settings, onboarding, task, diff/editor, review, and share
|
||||
components listed in the clipboard section of `plan.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run lib/utils/copy-to-clipboard.test.ts hooks/use-copy-to-clipboard.test.ts components/settings components/task components/editors components/review components/diff app/settings
|
||||
cd apps/web && pnpm run typecheck
|
||||
rtk rg -n --glob '!**/*.test.*' --glob '!**/e2e/**' 'navigator\.clipboard(?:\?|\.)?\.writeText\s*\(' apps/web
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
The fallback is best effort on browsers that block `execCommand`; callers must
|
||||
retain their current failure/toast semantics and must not report success when
|
||||
the utility returns false.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: the prompt-result delivery regression initially reported an error and
|
||||
skipped the DOM fallback when `navigator.clipboard` was unavailable.
|
||||
- GREEN: `cd apps && pnpm --filter @kandev/web test -- --run lib/utils/copy-to-clipboard.test.ts hooks/use-copy-to-clipboard.test.ts hooks/use-prompt-result-delivery.test.ts`
|
||||
passed (3 files, 21 tests).
|
||||
- The task-defined broader command passed (360 files, 2,647 tests, 4 skipped):
|
||||
`cd apps && pnpm --filter @kandev/web test -- --run lib/utils/copy-to-clipboard.test.ts hooks/use-copy-to-clipboard.test.ts hooks/use-prompt-result-delivery.test.ts components/settings components/task components/editors components/review components/diff app/settings`.
|
||||
- `cd apps/web && pnpm run typecheck` passed.
|
||||
- Targeted ESLint, Prettier, and `i18n:ratchet` checks passed.
|
||||
- Static audit leaves direct Clipboard API access only in the shared utility and
|
||||
tests/E2E helpers.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Repair secure-context browser fallbacks
|
||||
status: shipped
|
||||
created: 2026-08-02
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Repair secure-context browser fallbacks
|
||||
|
||||
## Problem
|
||||
|
||||
Kandev advertises plain HTTP network URLs for remote browser access. Some Web
|
||||
APIs used by the web app, including `crypto.randomUUID()` and
|
||||
`navigator.clipboard`, are only exposed or usable in secure contexts. Direct
|
||||
calls currently make otherwise valid workflow and copy actions throw when the
|
||||
app is opened over a non-localhost HTTP origin.
|
||||
|
||||
## Desired behavior
|
||||
|
||||
- Client-only identifiers used by workflows, workflow steps, and layout
|
||||
profiles are generated successfully whether or not `crypto.randomUUID()` is
|
||||
available. The secure Web Crypto implementation remains preferred; the
|
||||
fallback is only for non-security identifiers.
|
||||
- Clipboard actions use the modern Clipboard API when it is available and
|
||||
fall back to the existing DOM copy mechanism when the API is unavailable or
|
||||
rejects the request. A missing clipboard capability never produces an
|
||||
uncaught browser API `TypeError`.
|
||||
- Existing capability-gated APIs remain graceful in insecure contexts:
|
||||
`crypto.subtle` keeps its content-hash fallback, voice input reports that it
|
||||
needs HTTPS or localhost, and notification/audio features continue to no-op
|
||||
or report unsupported instead of throwing.
|
||||
- These fallbacks preserve the current successful-origin behavior and do not
|
||||
change backend, WebSocket, or persistence contracts.
|
||||
|
||||
## Regression scenarios
|
||||
|
||||
- **GIVEN** the page has no `crypto.randomUUID`, **WHEN** the user confirms Add
|
||||
Workflow, **THEN** a client-only workflow draft and its steps are created
|
||||
without an exception and receive unique UUID-shaped IDs.
|
||||
- **GIVEN** the page has no `crypto.randomUUID`, **WHEN** the user adds a
|
||||
workflow step or creates a layout profile, **THEN** the client-only item is
|
||||
created without an exception and receives a unique ID.
|
||||
- **GIVEN** `navigator.clipboard` is missing or `writeText` rejects, **WHEN** a
|
||||
user activates any copy action, **THEN** the DOM fallback is attempted and no
|
||||
uncaught promise or `TypeError` escapes the action.
|
||||
- **GIVEN** `crypto.subtle` is missing, **WHEN** file content hashing is
|
||||
requested, **THEN** the existing non-cryptographic change-detection hash is
|
||||
returned.
|
||||
- **GIVEN** the page is an insecure HTTP origin, **WHEN** voice, notification,
|
||||
or notification-sound capability checks run, **THEN** the UI reports or
|
||||
applies the existing unsupported/no-op behavior without throwing.
|
||||
|
||||
## Constraints
|
||||
|
||||
- `crypto.randomUUID()` remains the preferred source when present.
|
||||
- Fallback UUIDs are for temporary/client identifiers only and MUST NOT be
|
||||
used for secrets, authentication tokens, or security decisions.
|
||||
- The modern Clipboard API remains preferred; fallback copy is best effort and
|
||||
must preserve focus behavior for dialogs.
|
||||
- Remote plain HTTP access remains supported; this repair does not require
|
||||
adding TLS or changing server binding.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Adding an HTTPS server or changing the advertised network URLs.
|
||||
- Replacing secure randomness for security-sensitive values.
|
||||
- Redesigning voice, notification, or sound settings beyond preventing
|
||||
capability-related exceptions.
|
||||
Reference in New Issue
Block a user