summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/daemons.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/daemons.tsx')
-rw-r--r--makima/frontend/src/routes/daemons.tsx363
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"
+ >
+ &#x2715;
+ </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">&#x2713;</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]"
- >
- &#x27F3; 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"
+ >
+ &#x1F511; Reauthorize
+ </button>
+ <button
+ onClick={() => setRestartConfirmDaemonId(daemon.id)}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ &#x27F3; 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>
);
}