Files
Agent-Tools/apps/desktop/src/components/AuthPanel.tsx
T

193 lines
6.0 KiB
TypeScript

import { useEffect, useState } from "react";
import { Copy, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react";
import { authorize, cancelAuthorize, onCliAuth } from "../ipc";
import type { AuthFlowEvent, AuthModeInfo } from "../ipc/types";
import { Modal } from "./Modal";
/** 官方授权方式中文名 */
function authModeLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "账号授权(浏览器)";
case "device_code":
return "设备码";
case "api_key":
return "API Key";
case "local_tui":
return "本机终端授权";
default:
return mode;
}
}
function modeButtonLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "开始授权";
case "device_code":
return "开始授权";
case "local_tui":
return "打开终端授权";
case "api_key":
return "用已保存的 Key 授权";
default:
return "去授权";
}
}
interface FlowState {
mode: string;
events: AuthFlowEvent[];
device: { code: string; url: string } | null;
done: boolean | null; // null=进行中
}
/** 软件内授权面板(Wave 2.2 Req 2):把「说明文字」升级为可操作授权流程。
* 覆盖 API Key / 浏览器 / 设备码 / 本机终端四类,事件流经 cli-auth-event 实时回传。 */
export function AuthPanel({
id,
authModes,
onAuthChanged,
}: {
id: string;
authModes: AuthModeInfo[];
onAuthChanged: () => void;
}) {
const [flow, setFlow] = useState<FlowState | null>(null);
useEffect(() => {
let unlisten: (() => void) | undefined;
onCliAuth((ev) => {
if (ev.cli_id !== id) return;
setFlow((f) => {
if (!f) return f;
const next: FlowState = { ...f, events: [...f.events, ev] };
if (ev.kind === "device_code" && ev.user_code && ev.verification_url) {
next.device = { code: ev.user_code, url: ev.verification_url };
}
if (ev.kind === "done") {
next.done = ev.authorized ?? false;
onAuthChanged();
}
if (ev.kind === "error" || ev.kind === "cancelled") {
next.done = false;
}
return next;
});
}).then((fn) => {
unlisten = fn;
});
return () => unlisten?.();
}, [id, onAuthChanged]);
function start(mode: string) {
setFlow({ mode, events: [], device: null, done: null });
void authorize(id, mode);
}
function cancel() {
if (flow) void cancelAuthorize(id, flow.mode);
}
function close() {
setFlow(null);
}
if (authModes.length === 0) return null;
return (
<>
<ul className="auth-modes">
{authModes.map((m) => (
<li key={m.mode} className="auth-mode">
<span className="auth-mode-name">{authModeLabel(m.mode)}</span>
{m.notes_zh && <span className="auth-mode-note">{m.notes_zh}</span>}
<button type="button" className="btn btn-secondary auth-mode-action" onClick={() => start(m.mode)}>
{modeButtonLabel(m.mode)}
</button>
</li>
))}
</ul>
{flow && (
<Modal
title={`${authModeLabel(flow.mode)}授权 · ${id}`}
onClose={flow.done == null ? undefined : close}
footer={
flow.done == null ? (
<button type="button" className="btn btn-secondary" onClick={cancel}>
<X size={14} strokeWidth={1.5} aria-hidden="true" /> 取消授权
</button>
) : (
<button type="button" className="btn btn-primary" onClick={close}>
完成
</button>
)
}
>
<AuthFlowBody flow={flow} />
</Modal>
)}
</>
);
}
function AuthFlowBody({ flow }: { flow: FlowState }) {
const { device, events, done } = flow;
return (
<div className="auth-flow">
{/* 设备码:大号验证码 + 一键复制 + 打开授权网页 */}
{device && (
<div className="auth-device">
<div className="auth-device-label">在浏览器打开验证链接,输入以下设备码</div>
<div className="auth-device-code">
<span className="auth-device-code-text">{device.code}</span>
<button
type="button"
className="btn btn-secondary auth-copy"
onClick={() => void navigator.clipboard.writeText(device.code)}
>
<Copy size={14} strokeWidth={1.5} aria-hidden="true" /> 复制
</button>
</div>
<a className="btn btn-primary auth-open" href={device.url} target="_blank" rel="noreferrer">
<ExternalLink size={14} strokeWidth={1.5} aria-hidden="true" /> 打开授权网页
</a>
<div className="auth-device-hint">请在弹出的浏览器里完成确认,本软件会轮询授权结果。</div>
</div>
)}
{/* 状态行 */}
<div className="auth-status">
{done == null ? (
<span className="auth-waiting">
<Loader2 size={14} strokeWidth={1.5} className="spin" aria-hidden="true" />
{device ? "等待浏览器确认…(可随时取消)" : "授权进行中,等待浏览器确认…(可随时取消)"}
</span>
) : done ? (
<span className="auth-done">
<ShieldCheck size={14} strokeWidth={1.5} aria-hidden="true" /> 已授权
</span>
) : (
<span className="auth-failed">
<X size={14} strokeWidth={1.5} aria-hidden="true" /> 授权未完成,请重试或改用其它方式
</span>
)}
</div>
{/* 实时输出(脱敏后) */}
{events.length > 0 && (
<div className="auth-log">
{events
.filter((e) => e.kind === "line" || e.kind === "waiting")
.map((e, i) => (
<div key={i} className={`auth-log-line ${e.kind}`}>
{e.message}
</div>
))}
</div>
)}
</div>
);
}