194 lines
4.8 KiB
React
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);
|
|
}
|