Files
2026-07-13 13:39:12 +08:00

194 lines
4.8 KiB
React

import React, { useState, useEffect } from "react";
import { render, Box, Text, useInput } from "ink";
import Spinner from "ink-spinner";
import { StatusBadge } from "../tui-components/StatusBadge.jsx";
import { ConfirmDialog } from "../tui-components/ConfirmDialog.jsx";
const PHASE = {
WAITING: "waiting",
POLLING: "polling",
DONE: "done",
FAILED: "failed",
CANCELLED: "cancelled",
};
let _globalDone = null;
let _globalFail = null;
function OAuthFlowApp({ provider, url, deviceCode, onCancel, onDone, onFail }) {
const [phase, setPhase] = useState(PHASE.WAITING);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [elapsed, setElapsed] = useState(0);
const [confirmCancel, setConfirmCancel] = useState(false);
useEffect(() => {
const id = setInterval(() => setElapsed((e) => e + 1), 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
_globalDone = (res) => {
setResult(res);
setPhase(PHASE.DONE);
onDone?.(res);
};
_globalFail = (err) => {
setError(typeof err === "string" ? err : (err?.message ?? String(err)));
setPhase(PHASE.FAILED);
onFail?.(err);
};
setPhase(PHASE.POLLING);
return () => {
_globalDone = null;
_globalFail = null;
};
}, []);
useInput((input, key) => {
if (phase === PHASE.DONE || phase === PHASE.FAILED || phase === PHASE.CANCELLED) return;
if (input === "q" || (key.ctrl && input === "c")) {
setConfirmCancel(true);
}
});
function handleCancelConfirm(yes) {
setConfirmCancel(false);
if (yes) {
setPhase(PHASE.CANCELLED);
onCancel?.();
}
}
const elapsed_str = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, "0")}`;
if (confirmCancel) {
return (
<Box flexDirection="column" padding={1}>
<ConfirmDialog
message="Cancel OAuth authorization?"
onConfirm={handleCancelConfirm}
defaultNo
/>
</Box>
);
}
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={2} paddingY={1}>
<Box marginBottom={1}>
<Text bold color="cyan">
OmniRoute OAuth {provider}
</Text>
</Box>
{url && (
<Box flexDirection="column" marginBottom={1}>
<Text>Open this URL in your browser to authorize:</Text>
<Box marginTop={0}>
<Text bold color="yellow">
{url}
</Text>
</Box>
</Box>
)}
{deviceCode && (
<Box flexDirection="column" marginBottom={1}>
<Text>
Device code:{" "}
<Text bold color="yellow">
{deviceCode}
</Text>
</Text>
</Box>
)}
<Box marginTop={1}>
{phase === PHASE.POLLING || phase === PHASE.WAITING ? (
<Box>
<Text color="green">
<Spinner type="dots" />
</Text>
<Text> Waiting for authorization... </Text>
<Text dimColor>({elapsed_str})</Text>
</Box>
) : phase === PHASE.DONE ? (
<Box>
<StatusBadge status="ok" />
<Text> Authorized: {result?.email ?? result?.account ?? "connected"}</Text>
</Box>
) : phase === PHASE.FAILED ? (
<Box>
<StatusBadge status="error" />
<Text> Failed: {error}</Text>
</Box>
) : (
<Box>
<StatusBadge status="warn" />
<Text> Cancelled.</Text>
</Box>
)}
</Box>
{(phase === PHASE.POLLING || phase === PHASE.WAITING) && (
<Box marginTop={1}>
<Text dimColor>[q] cancel</Text>
</Box>
)}
</Box>
);
}
export async function startOAuthTui({ provider, url, deviceCode }) {
return new Promise((resolve, reject) => {
let resolved = false;
function onDone(result) {
if (resolved) return;
resolved = true;
unmount();
resolve({ status: "authorized", result });
}
function onFail(err) {
if (resolved) return;
resolved = true;
unmount();
resolve({ status: "failed", error: err });
}
function onCancel() {
if (resolved) return;
resolved = true;
unmount();
resolve({ status: "cancelled" });
}
const { unmount, waitUntilExit } = render(
<OAuthFlowApp
provider={provider}
url={url}
deviceCode={deviceCode}
onDone={onDone}
onFail={onFail}
onCancel={onCancel}
/>
);
waitUntilExit()
.then(() => {
if (!resolved) resolve({ status: "exited" });
})
.catch(reject);
});
}
export function markOAuthDone(result) {
_globalDone?.(result);
}
export function markOAuthFailed(error) {
_globalFail?.(error);
}