navigator.clipboard is secure-context only, like crypto.randomUUID before it —
over plain http on a tailnet address the object does not exist. Twenty call
sites across eighteen files, in three states that all looked fine in review:
bare calls that threw and killed the handler, optional-chained calls that
silently did nothing, and one carrying the comment "Officer is always behind
HTTPS", which it is not.
The optional-chained ones are the worst of the three: a copy button that reports
success and copies nothing is indistinguishable from a working one until someone
pastes.
helpers/clipboard.ts falls back to document.execCommand('copy') over an
off-screen textarea — deprecated, and it works on any origin because it predates
the secure-context rule. Off-screen rather than hidden, because display:none and
visibility:hidden elements cannot be selected and the copy fails silently.
Reading the clipboard has no equivalent: execCommand('paste') was never permitted
from script. The file browser's paste-a-file path now checks canReadClipboard()
and explains itself instead of throwing.
docs/http-secure-context-audit.md is the full sweep the owner asked for: what was
fixed, what cannot be, and what was checked and found clear. crypto.subtle is
used nowhere in the frontend, which was the one worth confirming since it has no
cheap fallback. Notification's six matches are type names, not the API.
geolocation and navigator.share are already guarded. getUserMedia is in four
files and is being removed — but QrTransfer uses it for the CAMERA, not a
microphone, so "remove audio" does not cover it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
200 lines
7.6 KiB
TypeScript
200 lines
7.6 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { copyToClipboard } from 'helpers/clipboard';
|
|
|
|
type RelayToken = {
|
|
token: string;
|
|
port: number;
|
|
};
|
|
|
|
export const BrowserRelay = () => {
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
|
|
|
const { data: status } = useQuery({
|
|
queryKey: ['browser-status'],
|
|
queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'),
|
|
refetchInterval: 5000,
|
|
});
|
|
|
|
const { data: tokenData } = useQuery({
|
|
queryKey: ['browser-relay-token'],
|
|
queryFn: () => client.get<RelayToken>('/browser/relay-token'),
|
|
});
|
|
|
|
const regenerate = useMutation({
|
|
mutationFn: () => client.post<RelayToken>('/browser/relay-token/regenerate'),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] });
|
|
toast.success('Token regenerated — update the extension with the new token');
|
|
},
|
|
onError: () => toast.error('Failed to regenerate token'),
|
|
});
|
|
|
|
const revoke = useMutation({
|
|
mutationFn: () => client.delete('/browser/relay-token'),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] });
|
|
toast.success('Token revoked');
|
|
},
|
|
onError: () => toast.error('Failed to revoke token'),
|
|
});
|
|
|
|
const handleCopy = async (value: string, field: string) => {
|
|
try {
|
|
await copyToClipboard(value);
|
|
setCopiedField(field);
|
|
toast.success('Copied to clipboard');
|
|
setTimeout(() => setCopiedField(null), 2000);
|
|
} catch {
|
|
toast.error('Failed to copy');
|
|
}
|
|
};
|
|
|
|
const isConnected = status?.extensionConnected ?? false;
|
|
const serverAddress = window.location.hostname;
|
|
|
|
return (
|
|
<div className="grid gap-4">
|
|
{isConnected ? (
|
|
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
|
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
|
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached
|
|
{' — '}
|
|
<a href="/browser" className="text-duck-teal underline">view tabs</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
|
Connect your Chrome browser to Officer so AI agents can view and interact with your tabs.
|
|
</p>
|
|
)}
|
|
|
|
{/* Step 1: Install */}
|
|
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">1. Install the extension</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
|
Download and unzip the extension, then load it in Chrome:
|
|
</p>
|
|
<a
|
|
href="/browser-relay-extension.zip"
|
|
download
|
|
className="flex items-center justify-center gap-2 w-full h-11 rounded-md border border-duck-dark/15 dark:border-foreground/15 text-sm font-medium hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Download Extension
|
|
</a>
|
|
<ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
|
<li>
|
|
Open{' '}
|
|
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">chrome://extensions</code>{' '}
|
|
in Chrome
|
|
</li>
|
|
<li>
|
|
Enable <strong>Developer mode</strong> (top-right toggle)
|
|
</li>
|
|
<li>
|
|
Click <strong>Load unpacked</strong> and select the unzipped folder
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
|
|
{/* Step 2: Configure */}
|
|
{tokenData && (
|
|
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">2. Configure the extension</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
|
Open the extension options (right-click icon → Options) and enter these values:
|
|
</p>
|
|
|
|
<div className="grid gap-2">
|
|
<CredentialRow
|
|
label="Server address"
|
|
value={serverAddress}
|
|
copied={copiedField === 'host'}
|
|
onCopy={() => handleCopy(serverAddress, 'host')}
|
|
/>
|
|
<CredentialRow
|
|
label="Port"
|
|
value={String(tokenData.port)}
|
|
copied={copiedField === 'port'}
|
|
onCopy={() => handleCopy(String(tokenData.port), 'port')}
|
|
/>
|
|
<CredentialRow
|
|
label="Relay token"
|
|
value={tokenData.token}
|
|
masked
|
|
copied={copiedField === 'token'}
|
|
onCopy={() => handleCopy(tokenData.token, 'token')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => regenerate.mutate()}
|
|
disabled={regenerate.isPending}
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
|
Regenerate
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => revoke.mutate()}
|
|
disabled={revoke.isPending}
|
|
className="text-red-500 hover:text-red-600"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
Revoke
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 3: Attach */}
|
|
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">3. Attach a tab</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
|
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge means
|
|
the tab is connected. Then go to{' '}
|
|
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
|
|
/browser <ExternalLink className="h-3 w-3" />
|
|
</a>{' '}
|
|
to view your tabs.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type CredentialRowProps = {
|
|
label: string;
|
|
value: string;
|
|
masked?: boolean;
|
|
copied: boolean;
|
|
onCopy: () => void;
|
|
};
|
|
|
|
const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (
|
|
<div className="flex items-center gap-2 rounded-md bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2">
|
|
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 shrink-0 w-24">{label}</span>
|
|
<code className="flex-1 text-xs truncate">
|
|
{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}
|
|
</code>
|
|
<button onClick={onCopy} className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
|
|
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
|
|
</button>
|
|
</div>
|
|
);
|