Files
maestro/ui/src/components/settings/GatewayKeyRawKeyDialog.tsx
T

164 lines
6.4 KiB
TypeScript

import { useEffect, useState } from 'react';
interface Props {
rawKey: string;
team: string;
reason: 'created' | 'rotated';
onClose: () => void;
}
/**
* One-time raw bearer reveal. The DB never stores the raw value — once
* this dialog closes the operator can never see it again, so we:
* - require an explicit "I've saved it" acknowledgement before close
* - show a copy-to-clipboard button as the obvious primary action
* - warn loudly in red
* - trap ESC, browser back, and tab-close (beforeunload) until
* acknowledged so a stray keypress can't lose the key (F10)
*
* The dialog is intentionally modal (overlay + focus trap via tabindex).
*/
export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props) {
const [copied, setCopied] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
// F10: while the raw key is on-screen and not acknowledged, block the
// common dismissal paths that would otherwise silently lose it:
// - ESC keypress (Escape closes most modals by convention)
// - browser back / forward (popstate)
// - tab close / refresh (beforeunload — best-effort browser warning)
// We intentionally do NOT block the dialog's own Close button (gated
// by the `acknowledged` checkbox) or the overlay click (which the
// current design already ignores).
useEffect(() => {
if (acknowledged) return;
const onKeydown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
}
};
document.addEventListener('keydown', onKeydown, { capture: true });
const onBeforeUnload = (e: BeforeUnloadEvent): string => {
e.preventDefault();
const msg = 'Gateway API key has not been saved. Closing this page will lose it forever.';
// Modern browsers ignore the returned string but require it set
// for the warning dialog to appear. Setting both for cross-browser
// safety (Chrome reads returnValue, some older Firefox reads return).
(e as BeforeUnloadEvent & { returnValue: string }).returnValue = msg;
return msg;
};
window.addEventListener('beforeunload', onBeforeUnload);
// Push a sentinel history entry so the next back-button press lands
// here (where we re-push it). Best-effort: doesn't fully prevent
// navigation in every browser, but turns a single back-tap into a
// visible alert + re-block.
let pushed = false;
try {
window.history.pushState({ aaoGatewayKeyTrap: true }, '', window.location.href);
pushed = true;
} catch { /* SSR / sandboxed iframes: skip */ }
const onPopState = (e: PopStateEvent): void => {
e.preventDefault?.();
try {
window.history.pushState({ aaoGatewayKeyTrap: true }, '', window.location.href);
} catch { /* ignore */ }
alert(
'API key has not been saved. Copy it and tick "保存しました" before navigating away.',
);
};
window.addEventListener('popstate', onPopState);
return () => {
document.removeEventListener('keydown', onKeydown, { capture: true });
window.removeEventListener('beforeunload', onBeforeUnload);
window.removeEventListener('popstate', onPopState);
// Drop the sentinel we pushed so the user's history isn't littered.
if (pushed) {
try {
if (window.history.state && (window.history.state as { aaoGatewayKeyTrap?: boolean }).aaoGatewayKeyTrap) {
window.history.back();
}
} catch { /* ignore */ }
}
};
}, [acknowledged]);
async function handleCopy() {
try {
await navigator.clipboard.writeText(rawKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Some browsers / contexts block clipboard access. The textarea
// is selectable as a fallback.
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 p-6">
<h3 className="text-lg font-semibold text-slate-800 mb-1">
{reason === 'created' ? '新しい Gateway Key を発行しました' : 'Gateway Key をローテーションしました'}
</h3>
<p className="text-xs text-slate-500 mb-4">team: {team}</p>
<div className="rounded border border-red-300 bg-red-50 p-3 mb-3">
<p className="text-sm text-red-800 font-medium">⚠️ このキーは今後二度と表示されません</p>
<p className="text-xs text-red-700 mt-1">
必ずパスワードマネージャや LLM クライアントの設定にコピー・保存してから閉じてください。
紛失した場合は Rotate で再発行する必要があります。
</p>
</div>
<label className="block text-xs font-medium text-slate-600 mb-1">Bearer Key (sk-aao-)</label>
<textarea
readOnly
value={rawKey}
rows={2}
className="w-full font-mono text-xs px-2 py-1.5 border border-slate-300 rounded bg-slate-50 select-all"
onFocus={(e) => e.target.select()}
/>
<div className="flex gap-2 mt-3">
<button
type="button"
onClick={handleCopy}
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong"
>
{copied ? '✓ Copied' : 'Copy to clipboard'}
</button>
</div>
<div className="border-t border-hairline mt-4 pt-4">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
className="mt-0.5"
/>
<span className="text-slate-700">
キーを安全に保存しました。今後このキーは表示できなくなることを理解しています。
</span>
</label>
<div className="flex justify-end gap-2 mt-4">
<button
type="button"
disabled={!acknowledged}
onClick={onClose}
className="px-3 py-1.5 text-sm rounded border border-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-50"
>
Close
</button>
</div>
</div>
</div>
</div>
);
}