feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user