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>
202 lines
7.7 KiB
TypeScript
202 lines
7.7 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { Copy, Ban, Plus, KeyRound, Loader2 } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import { copyToClipboard } from 'helpers/clipboard';
|
|
|
|
// Your own API keys: one per app or device, so a phone holds a credential you can revoke on its own
|
|
// instead of a session everything shares.
|
|
//
|
|
// The key is returned by POST and never again — the column holds a SHA-256, so there is nothing to read
|
|
// back. That drives the whole screen: the new key sits in a panel that stays put until dismissed, because
|
|
// the moment it disappears the only remedy is to revoke and mint another. Same reasoning as the DAV app
|
|
// passwords beside this.
|
|
//
|
|
// A key carries your full account authority. It is not more than you already had — it is what your
|
|
// password could already do — but it does mean a leaked key is a leaked account, so revoke is one click
|
|
// and "last used" is on every row: a key that has never been used is the tell that something was set up
|
|
// wrong, and one used from somewhere you did not expect is the tell that matters more.
|
|
|
|
type ApiKey = {
|
|
id: number;
|
|
name: string;
|
|
prefix: string;
|
|
lastUsedAt: string | null;
|
|
expiresAt: string | null;
|
|
revokedAt: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
type Minted = { key: string; name: string };
|
|
|
|
const API_KEYS_QUERY_KEY = ['API_KEYS'];
|
|
|
|
const formatDate = (value: string | null) =>
|
|
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
|
|
|
|
const copy = async (text: string) => {
|
|
try {
|
|
await copyToClipboard(text);
|
|
toast.success('Key copied');
|
|
} catch {
|
|
toast.error('Could not copy — select and copy manually');
|
|
}
|
|
};
|
|
|
|
export const ApiKeys = () => {
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const [name, setName] = useState('');
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [minted, setMinted] = useState<Minted | null>(null);
|
|
|
|
const { data, isLoading } = useQuery<{ keys: ApiKey[] }>({
|
|
queryKey: API_KEYS_QUERY_KEY,
|
|
queryFn: () => client.get<{ keys: ApiKey[] }>('/api-keys'),
|
|
});
|
|
|
|
const refresh = () => queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
|
|
|
const create = async () => {
|
|
const trimmed = name.trim();
|
|
if (!trimmed || isCreating) return;
|
|
setIsCreating(true);
|
|
try {
|
|
const res = await client.post<{ key: string }>('/api-keys', { name: trimmed });
|
|
setMinted({ key: res.key, name: trimmed });
|
|
setName('');
|
|
await refresh();
|
|
} catch (ex) {
|
|
toast.error(ex instanceof Error ? ex.message : 'Could not create the key');
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
const revoke = async (id: number) => {
|
|
try {
|
|
await client.delete(`/api-keys/${id}`);
|
|
toast.success('Revoked — anything using that key is signed out');
|
|
await refresh();
|
|
} catch {
|
|
toast.error('Could not revoke');
|
|
}
|
|
};
|
|
|
|
const keys = data?.keys ?? [];
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading keys…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-5">
|
|
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
|
An API key signs in as you, without a password and without expiring. Give each app or device its own, so you can
|
|
cut one off without touching the rest. Send it as{' '}
|
|
<code className="font-mono text-xs">Authorization: Bearer …</code>.
|
|
</p>
|
|
|
|
{minted && (
|
|
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
|
|
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
|
|
Key for “{minted.name}” — shown once
|
|
</div>
|
|
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
|
|
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
|
|
revoke this entry and make another.
|
|
</p>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
|
{minted.key}
|
|
</code>
|
|
<Button variant="ghost" size="sm" onClick={() => copy(minted.key)} title="Copy key">
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
|
|
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
|
|
I've saved it
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-end gap-2">
|
|
<Label className="grid flex-1 gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20"
|
|
value={name}
|
|
onChange={(ev) => setName(ev.target.value)}
|
|
onKeyDown={(ev) => ev.key === 'Enter' && create()}
|
|
placeholder="iPhone, Music app, laptop CLI…"
|
|
/>
|
|
</Label>
|
|
<Button onClick={create} disabled={!name.trim() || isCreating} className="h-11">
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
Create
|
|
</Button>
|
|
</div>
|
|
|
|
{keys.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
|
No keys yet.
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-2">
|
|
{keys.map((entry) => {
|
|
const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now();
|
|
const dead = !!entry.revokedAt || expired;
|
|
const lastUsed = formatDate(entry.lastUsedAt);
|
|
return (
|
|
<div
|
|
key={entry.id}
|
|
className={`flex items-center gap-3 rounded-lg border p-3 ${
|
|
dead
|
|
? 'border-duck-dark/10 dark:border-foreground/10 opacity-50'
|
|
: 'border-duck-dark/15 dark:border-foreground/15'
|
|
}`}
|
|
>
|
|
<KeyRound className="h-4 w-4 shrink-0 text-duck-teal/70" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm text-duck-dark dark:text-foreground">
|
|
{entry.name}
|
|
{entry.revokedAt && <span className="ml-2 text-xs text-red-500">revoked</span>}
|
|
{!entry.revokedAt && expired && <span className="ml-2 text-xs text-red-500">expired</span>}
|
|
</div>
|
|
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
|
<code className="font-mono">{entry.prefix}…</code>
|
|
{' · '}
|
|
{/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */}
|
|
{lastUsed ? `last used ${lastUsed}` : 'never used'}
|
|
</div>
|
|
</div>
|
|
{!dead && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => revoke(entry.id)}
|
|
title="Revoke"
|
|
className="hover:text-red-500"
|
|
>
|
|
<Ban className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|