193 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|