mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +03:00
feat(volcengine): phone/SMS auto-login for console with MFA + identity selection
- Session-based headless login service (volcengineConsoleAutoLogin)
- API: POST /connect {phone} + /code /status /cancel /resend /identity sub-routes
- Dashboard modal: phone → SMS code → MFA step-up → identity selection
- Falls back to the legacy headful manual flow on risk-control/TOTP-binding
- Route guard: connect subtree stays LOCAL_ONLY + spawn-capable
This commit is contained in:
@@ -57,6 +57,7 @@ import CustomModelsSection from "./components/CustomModelsSection";
|
||||
import ConnectionsListPanel from "./components/ConnectionsListPanel";
|
||||
import CoolingConnectionsPanel from "./components/CoolingConnectionsPanel";
|
||||
import ConnectionsHeaderToolbar from "./components/ConnectionsHeaderToolbar";
|
||||
import VolcengineConnectModal from "./components/VolcengineConnectModal";
|
||||
import ProviderAccountRoutingCard from "../../settings/components/ProviderAccountRoutingCard";
|
||||
import ZedImportCard from "./components/ZedImportCard";
|
||||
import CursorAgentNudge from "./components/CursorAgentNudge";
|
||||
@@ -79,6 +80,7 @@ export default function ProviderDetailPageClient() {
|
||||
const [showOAuthModal, _setShowOAuthModal] = useState(false);
|
||||
const [reauthConnection, setReauthConnection] = useState<ConnectionRowConnection | null>(null);
|
||||
const [showKimiAuthMethodModal, setShowKimiAuthMethodModal] = useState(false);
|
||||
const [showVolcengineConnectModal, setShowVolcengineConnectModal] = useState(false);
|
||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||
const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false);
|
||||
const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState<string | undefined>();
|
||||
@@ -382,7 +384,9 @@ export default function ProviderDetailPageClient() {
|
||||
openApiKeyAddFlow();
|
||||
}, [providerId, isOAuth, openApiKeyAddFlow]);
|
||||
|
||||
const connectVolcengineAccount = useCallback(async () => {
|
||||
// Legacy manual flow: headful browser login on the machine running OmniRoute.
|
||||
// Kept as the fallback for the phone/SMS auto-login modal.
|
||||
const connectVolcengineAccountManually = useCallback(async () => {
|
||||
setConnectingVolcengineAccount(true);
|
||||
try {
|
||||
const response = await fetch("/api/providers/volcengine-plan/connect", {
|
||||
@@ -413,6 +417,10 @@ export default function ProviderDetailPageClient() {
|
||||
}
|
||||
}, [fetchConnections, notify]);
|
||||
|
||||
const connectVolcengineAccount = useCallback(() => {
|
||||
setShowVolcengineConnectModal(true);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
commandCodeAuthState,
|
||||
handleCloseAddApiKeyModal,
|
||||
@@ -902,6 +910,16 @@ export default function ProviderDetailPageClient() {
|
||||
setShowTutorialModal={setShowTutorialModal}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Volcano Engine console phone/SMS auto-login (falls back to manual browser login) */}
|
||||
<VolcengineConnectModal
|
||||
isOpen={showVolcengineConnectModal}
|
||||
onClose={() => setShowVolcengineConnectModal(false)}
|
||||
onFallbackManual={connectVolcengineAccountManually}
|
||||
onConnected={fetchConnections}
|
||||
notify={notify}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Input, Modal } from "@/shared/components";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
/**
|
||||
* VolcengineConnectModal — phone/SMS-code login for the Volcano Engine console.
|
||||
*
|
||||
* Drives the session-based auto login API:
|
||||
* POST /api/providers/volcengine-plan/connect {phone}
|
||||
* POST /api/providers/volcengine-plan/connect/{id}/code {code, captcha?}
|
||||
* GET /api/providers/volcengine-plan/connect/{id}/status
|
||||
* POST /api/providers/volcengine-plan/connect/{id}/resend
|
||||
* POST /api/providers/volcengine-plan/connect/{id}/cancel
|
||||
*
|
||||
* Falls back to the legacy manual headful-browser flow (same POST /connect
|
||||
* endpoint without a phone) when risk control or a layout change degrades
|
||||
* the headless session.
|
||||
*/
|
||||
|
||||
type SessionPhase =
|
||||
| "starting"
|
||||
| "sending_code"
|
||||
| "waiting_code"
|
||||
| "captcha_required"
|
||||
| "submitting"
|
||||
| "mfa_waiting"
|
||||
| "identity_required"
|
||||
| "success"
|
||||
| "error"
|
||||
| "timeout"
|
||||
| "cancelled"
|
||||
| "fallback_manual";
|
||||
|
||||
interface SessionView {
|
||||
sessionId: string;
|
||||
phase: SessionPhase;
|
||||
phoneMasked: string;
|
||||
error: string | null;
|
||||
captchaImage: string | null;
|
||||
resendAvailableAt: number;
|
||||
mfaRequired?: boolean;
|
||||
identityOptions?: Array<{ index: number; label: string }>;
|
||||
binding?: {
|
||||
results?: Array<{
|
||||
plan: string;
|
||||
available: boolean;
|
||||
ok: boolean;
|
||||
error?: string | null;
|
||||
}>;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PHONE_STORAGE_KEY = "omniroute.volcengine.phone";
|
||||
const TERMINAL_PHASES: SessionPhase[] = [
|
||||
"success",
|
||||
"error",
|
||||
"timeout",
|
||||
"cancelled",
|
||||
"fallback_manual",
|
||||
];
|
||||
|
||||
function isTerminal(phase: SessionPhase | undefined): boolean {
|
||||
return !!phase && TERMINAL_PHASES.includes(phase);
|
||||
}
|
||||
|
||||
type VolcengineConnectModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Legacy headful-browser login (opens on the server machine) */
|
||||
onFallbackManual: () => void;
|
||||
/** Refresh connections after a successful bind */
|
||||
onConnected: () => void | Promise<void>;
|
||||
notify: {
|
||||
success: (message: string, title?: string) => void;
|
||||
error: (message: string, title?: string) => void;
|
||||
};
|
||||
t: ProviderMessageTranslator;
|
||||
};
|
||||
|
||||
export default function VolcengineConnectModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onFallbackManual,
|
||||
onConnected,
|
||||
notify,
|
||||
t,
|
||||
}: VolcengineConnectModalProps) {
|
||||
const [phone, setPhone] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [captcha, setCaptcha] = useState("");
|
||||
const [session, setSession] = useState<SessionView | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [submittingCode, setSubmittingCode] = useState(false);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [selectingIdentity, setSelectingIdentity] = useState(false);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
|
||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ── lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
const stopTimers = useCallback(() => {
|
||||
if (pollTimer.current) {
|
||||
clearInterval(pollTimer.current);
|
||||
pollTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopTimers();
|
||||
setSession(null);
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
setResendCountdown(0);
|
||||
}, [stopTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Leaving the modal cancels an in-flight session server-side.
|
||||
const active = session && !isTerminal(session.phase) ? session : null;
|
||||
if (active) {
|
||||
void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, {
|
||||
method: "POST",
|
||||
}).catch(() => {});
|
||||
}
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null;
|
||||
if (saved) setPhone(saved);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => stopTimers, [stopTimers]);
|
||||
|
||||
// resend countdown ticker
|
||||
const resendAvailableAt = session?.resendAvailableAt ?? 0;
|
||||
const sessionId = session?.sessionId;
|
||||
const sessionPhase = session?.phase;
|
||||
useEffect(() => {
|
||||
if (!sessionId || isTerminal(sessionPhase)) return;
|
||||
const tick = () => {
|
||||
setResendCountdown(Math.max(0, Math.ceil((resendAvailableAt - Date.now()) / 1000)));
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [sessionId, sessionPhase, resendAvailableAt]);
|
||||
|
||||
// ── status polling ──────────────────────────────────────────────────────
|
||||
|
||||
const startPolling = useCallback(
|
||||
(sessionId: string) => {
|
||||
stopTimers();
|
||||
pollTimer.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${sessionId}/status`
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
if (isTerminal(data.session.phase)) {
|
||||
stopTimers();
|
||||
if (data.session.phase === "success") void onConnected();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// transient network error — keep polling until phase resolves
|
||||
}
|
||||
}, 1500);
|
||||
},
|
||||
[stopTimers, onConnected]
|
||||
);
|
||||
|
||||
// ── actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
const trimmed = phone.trim();
|
||||
if (!trimmed) return;
|
||||
setStarting(true);
|
||||
try {
|
||||
const response = await fetch("/api/providers/volcengine-plan/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: trimmed }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data?.success || !data?.session) {
|
||||
throw new Error(data?.error || "Failed to start Volcano login");
|
||||
}
|
||||
setSession(data.session);
|
||||
setResendCountdown(
|
||||
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
|
||||
);
|
||||
localStorage.setItem(PHONE_STORAGE_KEY, trimmed);
|
||||
if (data.session.phase === "starting" || data.session.phase === "sending_code") {
|
||||
startPolling(data.session.sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to start Volcano login");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [phone, notify, startPolling]);
|
||||
|
||||
const handleSubmitCode = useCallback(async () => {
|
||||
if (!session) return;
|
||||
setSubmittingCode(true);
|
||||
try {
|
||||
const payload: { code: string; captcha?: string } = { code: code.trim() };
|
||||
if (session.phase === "captcha_required" && captcha.trim()) {
|
||||
payload.captcha = captcha.trim();
|
||||
}
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${session.sessionId}/code`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
if (data.session.phase === "mfa_waiting") {
|
||||
// A NEW code is required for the MFA step — clear the stale input.
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
}
|
||||
if (
|
||||
data.session.phase === "starting" ||
|
||||
data.session.phase === "sending_code" ||
|
||||
data.session.phase === "submitting"
|
||||
) {
|
||||
startPolling(data.session.sessionId);
|
||||
} else if (data.session.phase === "success") {
|
||||
void onConnected();
|
||||
}
|
||||
} else {
|
||||
throw new Error(data?.error || "Failed to submit verification code");
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to submit verification code");
|
||||
} finally {
|
||||
setSubmittingCode(false);
|
||||
}
|
||||
}, [session, code, captcha, notify, startPolling, onConnected]);
|
||||
|
||||
const handleSelectIdentity = useCallback(
|
||||
async (index: number) => {
|
||||
if (!session) return;
|
||||
setSelectingIdentity(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${session.sessionId}/identity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ index }),
|
||||
}
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
if (
|
||||
data.session.phase === "starting" ||
|
||||
data.session.phase === "sending_code" ||
|
||||
data.session.phase === "submitting"
|
||||
) {
|
||||
startPolling(data.session.sessionId);
|
||||
} else if (data.session.phase === "success") {
|
||||
void onConnected();
|
||||
}
|
||||
} else {
|
||||
throw new Error(data?.error || "Failed to select identity");
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to select identity");
|
||||
} finally {
|
||||
setSelectingIdentity(false);
|
||||
}
|
||||
},
|
||||
[session, notify, startPolling, onConnected]
|
||||
);
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
if (!session || resendCountdown > 0) return;
|
||||
setResending(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${session.sessionId}/resend`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
setResendCountdown(
|
||||
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
|
||||
);
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to resend verification code");
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}, [session, resendCountdown, notify]);
|
||||
|
||||
const handleCancelSession = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await fetch(`/api/providers/volcengine-plan/connect/${session.sessionId}/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
reset();
|
||||
}, [session, reset]);
|
||||
|
||||
// ── derived UI state ────────────────────────────────────────────────────
|
||||
|
||||
const phase = session?.phase;
|
||||
const showPhoneStep = !session;
|
||||
const showCodeStep =
|
||||
phase === "waiting_code" ||
|
||||
phase === "captcha_required" ||
|
||||
phase === "mfa_waiting" ||
|
||||
phase === "identity_required";
|
||||
const showPolling = phase === "starting" || phase === "sending_code" || phase === "submitting";
|
||||
const done = isTerminal(phase);
|
||||
const mfaStep = phase === "mfa_waiting";
|
||||
|
||||
const bindingResults = session?.binding?.results || [];
|
||||
const connectedPlans = bindingResults.filter((r) => r?.ok);
|
||||
const bindingError = session?.binding?.error;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={providerText(t, "connectVolcengineAccount", "Connect Volcano Account")}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{showPhoneStep && (
|
||||
<>
|
||||
<p className="text-sm text-text-muted">
|
||||
{providerText(
|
||||
t,
|
||||
"volcAutoLoginDesc",
|
||||
"Enter your phone number. OmniRoute sends a verification code via the Volcano Engine console and extracts the session cookies automatically — no browser interaction needed."
|
||||
)}
|
||||
</p>
|
||||
<Input
|
||||
label={providerText(t, "volcPhoneLabel", "Phone number")}
|
||||
placeholder="13800000000"
|
||||
value={phone}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPhone(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") void handleStart();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleClose}>
|
||||
{providerText(t, "cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button size="sm" loading={starting} disabled={!phone.trim()} onClick={handleStart}>
|
||||
{providerText(t, "volcSendCode", "Send verification code")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCodeStep && (
|
||||
<>
|
||||
<p className="text-sm text-text-muted">
|
||||
{mfaStep
|
||||
? providerText(
|
||||
t,
|
||||
"volcMfaDesc",
|
||||
"Additional verification required (MFA). A NEW 6-digit code was sent to {phone} — enter it below to finish login.",
|
||||
{ phone: session?.phoneMasked || "your phone" }
|
||||
)
|
||||
: phase === "identity_required"
|
||||
? providerText(
|
||||
t,
|
||||
"volcIdentityDesc",
|
||||
"Your phone number is linked to multiple Volcano Engine identities. Pick the one you want to log in with:"
|
||||
)
|
||||
: providerText(
|
||||
t,
|
||||
"volcCodeSent",
|
||||
"A verification code was sent to {phone}. Enter it below to finish login.",
|
||||
{ phone: session?.phoneMasked || "your phone" }
|
||||
)}
|
||||
</p>
|
||||
|
||||
{phase === "identity_required" && session?.identityOptions?.length ? (
|
||||
<div className="space-y-2">
|
||||
{session.identityOptions.map((option) => (
|
||||
<button
|
||||
key={option.index}
|
||||
type="button"
|
||||
disabled={selectingIdentity}
|
||||
onClick={() => handleSelectIdentity(option.index)}
|
||||
className="w-full rounded-lg border border-border p-3 text-left text-sm transition-colors hover:bg-sidebar disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{selectingIdentity ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
{option.label}
|
||||
</span>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{phase === "captcha_required" && session?.captchaImage && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{providerText(
|
||||
t,
|
||||
"volcCaptchaLabel",
|
||||
"Image captcha (required by the console)"
|
||||
)}
|
||||
</p>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={session.captchaImage}
|
||||
alt="captcha"
|
||||
className="max-h-40 rounded border border-border"
|
||||
/>
|
||||
<Input
|
||||
placeholder={providerText(t, "volcCaptchaPlaceholder", "Captcha characters")}
|
||||
value={captcha}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCaptcha(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={
|
||||
mfaStep
|
||||
? providerText(t, "volcMfaCodeLabel", "MFA verification code")
|
||||
: providerText(t, "volcCodeLabel", "Verification code")
|
||||
}
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCode(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") void handleSubmitCode();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
/>
|
||||
|
||||
{session?.error && <p className="text-sm text-red-500">{session.error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={resending}
|
||||
disabled={resendCountdown > 0}
|
||||
onClick={handleResend}
|
||||
>
|
||||
{resendCountdown > 0
|
||||
? providerText(t, "volcResendIn", "Resend in {s}s", { s: resendCountdown })
|
||||
: providerText(t, "volcResend", "Resend code")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleCancelSession}>
|
||||
{providerText(t, "back", "Back")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={submittingCode}
|
||||
disabled={code.trim().length < 4}
|
||||
onClick={handleSubmitCode}
|
||||
>
|
||||
{providerText(t, "volcLogin", "Log in")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showPolling && (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-text-muted">
|
||||
{phase === "submitting"
|
||||
? providerText(
|
||||
t,
|
||||
"volcSubmitting",
|
||||
"Submitting code and extracting console cookies..."
|
||||
)
|
||||
: providerText(t, "volcStarting", "Starting Volcano login...")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && phase === "success" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-green-600">
|
||||
{providerText(t, "volcLoginSuccess", "Logged in to the Volcano Engine console")}
|
||||
</p>
|
||||
{bindingError ? (
|
||||
<p className="text-sm text-red-500">
|
||||
{providerText(t, "volcBindError", "Plan binding failed: {error}", {
|
||||
error: bindingError,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 text-sm">
|
||||
{connectedPlans.length > 0 ? (
|
||||
connectedPlans.map((item) => (
|
||||
<p key={item.plan} className="text-green-600">
|
||||
✓ {item.plan} plan connected
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p className="text-text-muted">
|
||||
{providerText(
|
||||
t,
|
||||
"volcNoPlans",
|
||||
"No Agent/Coding plans were detected on this account."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleClose}>
|
||||
{providerText(t, "done", "Done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && phase !== "success" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-red-500">
|
||||
{session?.error ||
|
||||
(phase === "timeout"
|
||||
? providerText(t, "volcTimeout", "Login timed out")
|
||||
: phase === "cancelled"
|
||||
? providerText(t, "volcCancelled", "Login cancelled")
|
||||
: providerText(t, "volcFailed", "Login failed"))}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={reset}>
|
||||
{providerText(t, "retry", "Retry")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onFallbackManual();
|
||||
}}
|
||||
>
|
||||
{providerText(t, "volcManualLogin", "Manual browser login")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/cancel
|
||||
* Cancel an auto phone login session and close its headless browser.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
const session = await volcengineConsoleAutoLoginService.cancel(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, session });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "Cancel failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/code
|
||||
* Submit the SMS verification code (plus image captcha when required) for an
|
||||
* auto phone login session. Returns the session view; binding runs lazily on
|
||||
* the next status poll once credentials are extracted.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
|
||||
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const session = await volcengineConsoleAutoLoginService.submitCode(
|
||||
sessionId,
|
||||
String(body.code ?? ""),
|
||||
typeof body.captcha === "string" ? body.captcha : undefined,
|
||||
{ timeout }
|
||||
);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Credentials ready → bind immediately so the response carries the outcome.
|
||||
if (session.phase === "success") {
|
||||
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
|
||||
bindVolcenginePlansFromConsoleCredentials(credentials)
|
||||
);
|
||||
return NextResponse.json({ success: true, session: bound ?? session });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano code submission failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/identity
|
||||
* Pick an identity on the console's select_identity page (the phone maps to
|
||||
* multiple accounts) and finish the login + plan binding.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
|
||||
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const index = Number(body.index);
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid identity index" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const session = await volcengineConsoleAutoLoginService.selectIdentity(sessionId, index, {
|
||||
timeout,
|
||||
});
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Credentials ready → bind immediately so the response carries the outcome.
|
||||
if (session.phase === "success") {
|
||||
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
|
||||
bindVolcenginePlansFromConsoleCredentials(credentials)
|
||||
);
|
||||
return NextResponse.json({ success: true, session: bound ?? session });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano identity selection failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/resend
|
||||
* Re-trigger the SMS verification code for an active login session.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
const session = await volcengineConsoleAutoLoginService.resendCode(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, session });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "Resend failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* GET /api/providers/volcengine-plan/connect/[sessionId]/status
|
||||
* Poll an auto phone login session. When credentials have been extracted, the
|
||||
* plan binding runs lazily (deduped) and its result is attached to the view.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
|
||||
const session = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
|
||||
bindVolcenginePlansFromConsoleCredentials(credentials)
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: session.phase === "success", session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano login status failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,26 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
|
||||
// Auto flow: phone present → start a session-based headless phone/SMS login.
|
||||
if (typeof body.phone === "string" && body.phone.trim()) {
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
const started = await volcengineConsoleAutoLoginService.startLogin(body.phone, { timeout });
|
||||
if (!started.ok) {
|
||||
return NextResponse.json({ success: false, error: started.error }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ success: true, session: started.session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano auto login failed to start: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy manual flow: headful browser login on the server machine.
|
||||
try {
|
||||
const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts");
|
||||
const login = await inAppLoginService.startLogin("volcengine-console", { timeout });
|
||||
|
||||
@@ -95,7 +95,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
*/
|
||||
export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^\/api\/providers\/[^/]+\/login\/?$/,
|
||||
/^\/api\/providers\/volcengine-plan\/connect\/?$/,
|
||||
/^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // manual headful flow + session-based phone/SMS auto-login (both spawn Playwright)
|
||||
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/,
|
||||
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/,
|
||||
];
|
||||
|
||||
@@ -52,7 +52,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
|
||||
*/
|
||||
export const SPAWN_CAPABLE_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^\/api\/providers\/[^/]+\/login\/?$/, // pre-existing gap: in LOCAL_ONLY_API_PATTERNS today but never in a spawn-capable deny-list
|
||||
/^\/api\/providers\/volcengine-plan\/connect\/?$/, // launches Playwright to bind a Volcano Engine console session
|
||||
/^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // launches Playwright to bind a Volcano Engine console session — covers the manual headful flow AND the session-based phone/SMS auto-login sub-routes (/code, /status, /cancel, /resend)
|
||||
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, // spawns cursor-agent via renewal.ts (Hard Rules #15 + #17)
|
||||
/^\/api\/providers\/cursor\/agent-availability\/?$/, // static path (no dynamic segment), but kept in this array alongside its /api/providers/ siblings rather than the flat SPAWN_CAPABLE_PREFIXES array — spawns cursor-agent status via checkCursorAgentAvailability()/getCachedCursorAgentAvailability() (Hard Rules #15 + #17)
|
||||
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/, // spawns via getTunnelRuntimeStatus() → spawnSync("...","runtimes status") (open-sse/executors/chatgpt-web-codex/tunnelClient.ts). Mirrors LOCAL_ONLY_API_PATTERNS in routeGuard.ts; keep the two in sync (GHSA-9q3h-mjm5-f4gj).
|
||||
|
||||
Reference in New Issue
Block a user