diff options
Diffstat (limited to 'makima/frontend/src/routes/daemons.tsx')
| -rw-r--r-- | makima/frontend/src/routes/daemons.tsx | 363 |
1 files changed, 355 insertions, 8 deletions
diff --git a/makima/frontend/src/routes/daemons.tsx b/makima/frontend/src/routes/daemons.tsx index 0f55190..ca167fe 100644 --- a/makima/frontend/src/routes/daemons.tsx +++ b/makima/frontend/src/routes/daemons.tsx @@ -1,10 +1,13 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useAuth } from "../contexts/AuthContext"; import { useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { listDaemons, restartDaemon, + triggerDaemonReauth, + submitDaemonAuthCode, + getDaemonReauthStatus, type Daemon, type DaemonListResponse, } from "../lib/api"; @@ -34,6 +37,333 @@ function ErrorAlert({ children }: { children: React.ReactNode }) { } // ============================================================================= +// Reauth Modal Component +// ============================================================================= + +type ReauthState = + | { phase: "initiating" } + | { phase: "url_ready"; loginUrl: string; requestId: string } + | { phase: "submitting"; requestId: string } + | { phase: "success" } + | { phase: "error"; message: string }; + +function ReauthModal({ + daemon, + onClose, +}: { + daemon: Daemon; + onClose: () => void; +}) { + const [state, setState] = useState<ReauthState>({ phase: "initiating" }); + const [authCode, setAuthCode] = useState(""); + const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + }, []); + + // Trigger reauth on mount + useEffect(() => { + let cancelled = false; + const trigger = async () => { + try { + const res = await triggerDaemonReauth(daemon.id); + if (cancelled) return; + + // Start polling for status + const requestId = res.requestId; + pollingRef.current = setInterval(async () => { + try { + const status = await getDaemonReauthStatus(daemon.id, requestId); + if (cancelled) return; + + if (status.status === "url_ready" && status.loginUrl) { + setState({ + phase: "url_ready", + loginUrl: status.loginUrl, + requestId, + }); + // Stop polling once we have the URL + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } else if (status.status === "failed") { + setState({ + phase: "error", + message: status.error || "Reauth failed", + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } else if (status.status === "completed") { + setState({ phase: "success" }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + } catch { + // Polling errors are non-fatal, keep trying + } + }, 2000); + } catch (err) { + if (cancelled) return; + setState({ + phase: "error", + message: + err instanceof Error ? err.message : "Failed to trigger reauth", + }); + } + }; + trigger(); + return () => { + cancelled = true; + }; + }, [daemon.id]); + + const handleSubmitCode = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!authCode.trim() || state.phase !== "url_ready") return; + + const requestId = state.requestId; + setState({ phase: "submitting", requestId }); + + try { + await submitDaemonAuthCode(daemon.id, authCode.trim(), requestId); + + // Poll for completion + pollingRef.current = setInterval(async () => { + try { + const status = await getDaemonReauthStatus( + daemon.id, + requestId, + ); + if (status.status === "completed") { + setState({ phase: "success" }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } else if (status.status === "failed") { + setState({ + phase: "error", + message: status.error || "Auth code submission failed", + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + } catch { + // Keep polling + } + }, 2000); + + // Also set a timeout so we don't poll forever + setTimeout(() => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + // If still submitting after 30s, assume success (setup-token completed) + setState((prev) => + prev.phase === "submitting" ? { phase: "success" } : prev, + ); + }, 30000); + } catch (err) { + setState({ + phase: "error", + message: + err instanceof Error + ? err.message + : "Failed to submit auth code", + }); + } + }, + [authCode, daemon.id, state], + ); + + const handleRetry = useCallback(() => { + setAuthCode(""); + setState({ phase: "initiating" }); + // Re-trigger will happen via the useEffect dependency change + // We need to manually trigger since daemon.id hasn't changed + const trigger = async () => { + try { + const res = await triggerDaemonReauth(daemon.id); + const requestId = res.requestId; + pollingRef.current = setInterval(async () => { + try { + const status = await getDaemonReauthStatus( + daemon.id, + requestId, + ); + if (status.status === "url_ready" && status.loginUrl) { + setState({ + phase: "url_ready", + loginUrl: status.loginUrl, + requestId, + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } else if (status.status === "failed") { + setState({ + phase: "error", + message: status.error || "Reauth failed", + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + } catch { + // Keep polling + } + }, 2000); + } catch (err) { + setState({ + phase: "error", + message: + err instanceof Error + ? err.message + : "Failed to trigger reauth", + }); + } + }; + trigger(); + }, [daemon.id]); + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] p-6 max-w-md w-full mx-4 shadow-2xl"> + {/* Header */} + <div className="flex items-center justify-between mb-4"> + <h3 className="text-xs font-mono uppercase tracking-wide text-[#9bc3ff]"> + Reauthorize Daemon + </h3> + <button + onClick={onClose} + className="text-[#7788aa] hover:text-[#9bc3ff] text-sm font-mono" + > + ✕ + </button> + </div> + <p className="text-[10px] font-mono text-[#7788aa] mb-4"> + {daemon.hostname || "Unknown Host"} + </p> + + {/* Initiating */} + {state.phase === "initiating" && ( + <div className="flex items-center gap-2 py-4"> + <div className="w-3 h-3 border border-[#75aafc] border-t-transparent rounded-full animate-spin" /> + <span className="text-[10px] font-mono text-[#7788aa]"> + Initiating reauthorization... + </span> + </div> + )} + + {/* URL Ready */} + {state.phase === "url_ready" && ( + <div className="space-y-3"> + <p className="text-[10px] font-mono text-[#7788aa]"> + Click the button below to open the OAuth login page, then paste the code: + </p> + <a + href={state.loginUrl} + target="_blank" + rel="noopener noreferrer" + className="block text-center bg-amber-500 hover:bg-amber-400 text-black font-mono text-xs font-medium px-4 py-2 transition-colors" + > + 1. Login to Claude + </a> + <form onSubmit={handleSubmitCode} className="flex gap-2"> + <input + type="text" + value={authCode} + onChange={(e) => setAuthCode(e.target.value)} + placeholder="2. Paste authentication code" + className="flex-1 bg-[#0a1525] border border-amber-500/30 px-3 py-2 text-xs font-mono text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400" + /> + <button + type="submit" + disabled={!authCode.trim()} + className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-mono text-xs font-medium px-4 py-2 transition-colors" + > + Submit + </button> + </form> + </div> + )} + + {/* Submitting */} + {state.phase === "submitting" && ( + <div className="flex items-center gap-2 py-4"> + <div className="w-3 h-3 border border-amber-400 border-t-transparent rounded-full animate-spin" /> + <span className="text-[10px] font-mono text-[#7788aa]"> + Submitting auth code... + </span> + </div> + )} + + {/* Success */} + {state.phase === "success" && ( + <div className="py-4"> + <div className="flex items-center gap-2 text-green-400 mb-2"> + <span className="text-sm">✓</span> + <span className="text-xs font-mono font-medium"> + Authentication successful + </span> + </div> + <p className="text-[10px] font-mono text-[#7788aa] mb-3"> + The daemon's OAuth token has been refreshed. Tasks can now run normally. + </p> + <button + onClick={onClose} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]" + > + Close + </button> + </div> + )} + + {/* Error */} + {state.phase === "error" && ( + <div className="py-4"> + <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-3 font-mono text-xs"> + {state.message} + </div> + <div className="flex gap-2"> + <button + onClick={handleRetry} + className="text-[10px] font-mono text-amber-400 hover:text-amber-300 px-2 py-1 border border-amber-700/50 bg-amber-900/20" + > + Retry + </button> + <button + onClick={onClose} + className="text-[10px] font-mono text-[#7788aa] hover:text-[#9bc3ff] px-2 py-1" + > + Close + </button> + </div> + </div> + )} + </div> + </div> + ); +} + +// ============================================================================= // Daemons Page // ============================================================================= @@ -47,6 +377,7 @@ export default function DaemonsPage() { const [daemonsError, setDaemonsError] = useState<string | null>(null); const [restartingDaemonId, setRestartingDaemonId] = useState<string | null>(null); const [restartConfirmDaemonId, setRestartConfirmDaemonId] = useState<string | null>(null); + const [reauthDaemon, setReauthDaemon] = useState<Daemon | null>(null); // Redirect if not authenticated useEffect(() => { @@ -292,7 +623,7 @@ export default function DaemonsPage() { </div> )} </div> - {/* Restart Section */} + {/* Actions Section */} {daemon.status === "connected" && ( <div className="mt-3 pt-2 border-t border-[rgba(117,170,252,0.1)]"> {restartConfirmDaemonId === daemon.id ? ( @@ -318,12 +649,20 @@ export default function DaemonsPage() { </div> </div> ) : ( - <button - onClick={() => setRestartConfirmDaemonId(daemon.id)} - className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]" - > - ⟳ Restart Daemon - </button> + <div className="flex items-center gap-3"> + <button + onClick={() => setReauthDaemon(daemon)} + className="text-[10px] font-mono text-amber-400 hover:text-amber-300" + > + 🔑 Reauthorize + </button> + <button + onClick={() => setRestartConfirmDaemonId(daemon.id)} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]" + > + ⟳ Restart Daemon + </button> + </div> )} </div> )} @@ -335,6 +674,14 @@ export default function DaemonsPage() { </div> </div> </main> + + {/* Reauth Modal */} + {reauthDaemon && ( + <ReauthModal + daemon={reauthDaemon} + onClose={() => setReauthDaemon(null)} + /> + )} </div> ); } |
