diff options
| author | soryu <soryu@soryu.co> | 2026-01-06 04:08:11 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-11 03:01:13 +0000 |
| commit | 8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch) | |
| tree | 7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/routes | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src/routes')
| -rw-r--r-- | makima/frontend/src/routes/_index.tsx | 12 | ||||
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 350 | ||||
| -rw-r--r-- | makima/frontend/src/routes/login.tsx | 150 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 634 | ||||
| -rw-r--r-- | makima/frontend/src/routes/settings.tsx | 724 |
5 files changed, 1861 insertions, 9 deletions
diff --git a/makima/frontend/src/routes/_index.tsx b/makima/frontend/src/routes/_index.tsx index 4c3c2c0..7084c2e 100644 --- a/makima/frontend/src/routes/_index.tsx +++ b/makima/frontend/src/routes/_index.tsx @@ -13,18 +13,18 @@ export default function HomePage() { </div> <span className="inline-block px-2 py-1 border border-[#3f6fb3] bg-[#0f1c2f] text-[#9bc3ff] font-mono text-xs tracking-wide uppercase mb-3"> - Listening System + Control System </span> <h2 className="m-0 mb-3 text-xl text-[#f0f5ff] tracking-wide"> - Mesh Listening Lattice + Mesh Orchestration Platform </h2> <p className="my-2 text-[#e4edff]"> - Makima is a mesh listening lattice for contested domains, delivering - live audio surveillance, detection, and analysis in one persistent layer. + Makima is a control system for orchestrating distributed daemon meshes, + coordinating concurrent execution across distinct domains. </p> <p className="my-2 text-[#e4edff]"> - Dynamic telemetry for detection, orchestration, and mission-critical - decisions. Real-time transcription with speaker diarization. + Unified command interface for spawning, monitoring, and directing + worker daemons. Real-time task coordination with overlay management. </p> </section> </main> diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 0d870f7..0645b85 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useParams, useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { FileList } from "../components/files/FileList"; -import { FileDetail } from "../components/files/FileDetail"; +import { FileDetail, type FocusedElement } from "../components/files/FileDetail"; import { CliInput } from "../components/files/CliInput"; import { ConflictNotification } from "../components/files/ConflictNotification"; import { UpdateNotification } from "../components/files/UpdateNotification"; @@ -12,7 +12,8 @@ import { useFileSubscription, type FileUpdateEvent, } from "../hooks/useFileSubscription"; -import type { FileDetail as FileDetailType, BodyElement } from "../lib/api"; +import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api"; +import { createTask } from "../lib/api"; export default function FilesPage() { const { id } = useParams<{ id: string }>(); @@ -23,6 +24,9 @@ export default function FilesPage() { const [creating, setCreating] = useState(false); const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null); const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null); + const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null); + const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null); + const [createdTask, setCreatedTask] = useState<Task | null>(null); const pendingUpdateRef = useRef(false); // Track the last version we sent to detect our own updates const lastSentVersionRef = useRef<number | null>(null); @@ -85,6 +89,7 @@ export default function FilesPage() { currentVersionRef.current = null; setRemoteUpdate(null); setRemoteFileData(null); + setFocusedElement(null); fetchFile(id).then((detail) => { if (detail) { currentVersionRef.current = detail.version; @@ -285,6 +290,276 @@ export default function FilesPage() { [fileDetail, id, editFile, updateHasLocalChanges] ); + // Element action handlers for context menu + const handleBodyElementDelete = useCallback( + async (index: number) => { + if (fileDetail && id) { + const newBody = fileDetail.body.filter((_, i) => i !== index); + + // Update local state immediately + setFileDetail({ + ...fileDetail, + body: newBody, + }); + updateHasLocalChanges(true); + + // Clear focus if deleting focused element + if (focusedElement?.index === index) { + setFocusedElement(null); + } else if (focusedElement && focusedElement.index > index) { + // Adjust focus index if deleting an element before it + setFocusedElement({ + ...focusedElement, + index: focusedElement.index - 1, + }); + } + + // Save to backend + pendingUpdateRef.current = true; + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + } + } + }, + [fileDetail, id, editFile, updateHasLocalChanges, focusedElement] + ); + + const handleBodyElementDuplicate = useCallback( + async (index: number) => { + if (fileDetail && id) { + const elementToDuplicate = fileDetail.body[index]; + if (!elementToDuplicate) return; + + const newBody = [...fileDetail.body]; + // Insert duplicate after the original + newBody.splice(index + 1, 0, { ...elementToDuplicate }); + + // Update local state immediately + setFileDetail({ + ...fileDetail, + body: newBody, + }); + updateHasLocalChanges(true); + + // Adjust focus index if duplicating before focused element + if (focusedElement && focusedElement.index > index) { + setFocusedElement({ + ...focusedElement, + index: focusedElement.index + 1, + }); + } + + // Save to backend + pendingUpdateRef.current = true; + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + } + } + }, + [fileDetail, id, editFile, updateHasLocalChanges, focusedElement] + ); + + const handleFocusElement = useCallback((element: FocusedElement | null) => { + setFocusedElement(element); + }, []); + + const handleClearFocus = useCallback(() => { + setFocusedElement(null); + }, []); + + // Convert element to a different type + const handleConvertElement = useCallback( + async (index: number, toType: string) => { + if (!fileDetail || !id) return; + + const element = fileDetail.body[index]; + if (!element) return; + + // Extract text content from current element + let textContent = ""; + switch (element.type) { + case "heading": + case "paragraph": + textContent = element.text; + break; + case "code": + textContent = element.content; + break; + case "list": + textContent = element.items.join("\n"); + break; + default: + return; // Can't convert charts/images + } + + // Create new element based on target type + let newElement: BodyElement; + if (toType === "paragraph") { + newElement = { type: "paragraph", text: textContent }; + } else if (toType === "list_unordered") { + const items = textContent.split("\n").filter(line => line.trim()); + newElement = { type: "list", ordered: false, items }; + } else if (toType === "list_ordered") { + const items = textContent.split("\n").filter(line => line.trim()); + newElement = { type: "list", ordered: true, items }; + } else if (toType === "code") { + newElement = { type: "code", content: textContent }; + } else if (toType.startsWith("heading_")) { + const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6; + newElement = { type: "heading", level, text: textContent }; + } else { + return; // Unknown type + } + + const newBody = [...fileDetail.body]; + newBody[index] = newElement; + + // Update local state + setFileDetail({ ...fileDetail, body: newBody }); + updateHasLocalChanges(true); + + // Update focus if this element was focused + if (focusedElement?.index === index) { + setFocusedElement({ + index, + type: newElement.type, + preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""), + }); + } + + // Save to backend + pendingUpdateRef.current = true; + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + } + }, + [fileDetail, id, editFile, updateHasLocalChanges, focusedElement] + ); + + // Generate from element - focus on it and pre-fill a prompt + const handleGenerateFromElement = useCallback( + (index: number, action: string) => { + if (!fileDetail) return; + + const element = fileDetail.body[index]; + if (!element) return; + + // Get preview text + let preview = ""; + switch (element.type) { + case "heading": + case "paragraph": + preview = element.text.slice(0, 50); + break; + case "code": + preview = element.content.slice(0, 50); + break; + case "list": + preview = element.items[0]?.slice(0, 40) || ""; + break; + default: + preview = "Element"; + } + + // Focus on the element + setFocusedElement({ + index, + type: element.type, + preview: preview + (preview.length >= 50 ? "..." : ""), + }); + + // Set suggested prompt based on action + let prompt = ""; + switch (action) { + case "elaborate": + prompt = "Elaborate and expand on this content"; + break; + case "summarize": + prompt = "Summarize this content"; + break; + case "extract_actions": + prompt = "Extract action items from this content"; + break; + } + setSuggestedPrompt(prompt); + }, + [fileDetail] + ); + + // Create a mesh task from an element + const handleCreateTaskFromElement = useCallback( + async (index: number, selectedText?: string) => { + if (!fileDetail) return; + + const element = fileDetail.body[index]; + if (!element) return; + + // Get the content to use as task plan + let content = selectedText || ""; + if (!content) { + switch (element.type) { + case "heading": + case "paragraph": + content = element.text; + break; + case "code": + content = element.content; + break; + case "list": + content = element.items.join("\n"); + break; + default: + content = "Task from file element"; + } + } + + // Create a task name from the content + const name = content.slice(0, 60) + (content.length > 60 ? "..." : ""); + + try { + const task = await createTask({ + name, + plan: content, + description: `Created from ${fileDetail.name}`, + }); + setCreatedTask(task); + } catch (err) { + console.error("Failed to create task:", err); + } + }, + [fileDetail] + ); + const handleCreate = useCallback(async () => { if (creating) return; setCreating(true); @@ -301,6 +576,28 @@ export default function FilesPage() { } }, [creating, saveFile, navigate]); + const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => { + if (creating) return; + setCreating(true); + try { + const newFile = await saveFile({ + name, + transcript: [], + }); + if (newFile) { + // Update with the parsed body + const updated = await editFile(newFile.id, { body, version: newFile.version }); + if (updated) { + navigate(`/files/${updated.id}`); + } else { + navigate(`/files/${newFile.id}`); + } + } + } finally { + setCreating(false); + } + }, [creating, saveFile, editFile, navigate]); + // Conflict resolution handlers const handleConflictReload = useCallback(async () => { if (id) { @@ -381,9 +678,16 @@ export default function FilesPage() { onDelete={handleDelete} onBodyElementUpdate={handleBodyElementUpdate} onBodyReorder={handleBodyReorder} + onBodyElementDelete={handleBodyElementDelete} + onBodyElementDuplicate={handleBodyElementDuplicate} + onConvertElement={handleConvertElement} + onGenerateFromElement={handleGenerateFromElement} + onCreateTaskFromElement={handleCreateTaskFromElement} onEditingChange={updateIsActivelyEditing} hasPendingRemoteUpdate={!!remoteUpdate} onOverwrite={handleRemoteUpdateDismiss} + focusedElement={focusedElement} + onFocusElement={handleFocusElement} versions={versions} versionsLoading={versionsLoading} selectedVersion={selectedVersion} @@ -395,7 +699,14 @@ export default function FilesPage() { /> </div> <div className="shrink-0"> - <CliInput fileId={id} onUpdate={handleBodyUpdate} /> + <CliInput + fileId={id} + onUpdate={handleBodyUpdate} + focusedElement={focusedElement} + onClearFocus={handleClearFocus} + suggestedPrompt={suggestedPrompt} + onClearSuggestedPrompt={() => setSuggestedPrompt(null)} + /> </div> </div> ) : id && detailLoading ? ( @@ -409,6 +720,7 @@ export default function FilesPage() { onSelect={handleSelectFile} onDelete={handleDelete} onCreate={handleCreate} + onUploadMarkdown={handleUploadMarkdown} /> )} </main> @@ -432,6 +744,38 @@ export default function FilesPage() { onDismiss={handleRemoteUpdateDismiss} /> )} + + {/* Task created notification */} + {createdTask && ( + <div className="fixed bottom-4 right-4 z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] p-4 shadow-lg max-w-sm"> + <div className="flex items-start gap-3"> + <span className="text-[#75aafc] text-lg">@</span> + <div className="flex-1"> + <p className="font-mono text-xs text-[#9bc3ff] mb-1">Task created</p> + <p className="font-mono text-sm text-white truncate mb-3"> + {createdTask.name} + </p> + <div className="flex gap-2"> + <button + onClick={() => { + navigate(`/mesh/${createdTask.id}`); + setCreatedTask(null); + }} + className="px-3 py-1 font-mono text-xs text-[#0a1628] bg-[#75aafc] hover:bg-[#9bc3ff] transition-colors" + > + Go to task + </button> + <button + onClick={() => setCreatedTask(null)} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.3)] hover:border-[#75aafc] transition-colors" + > + Dismiss + </button> + </div> + </div> + </div> + </div> + )} </div> ); } diff --git a/makima/frontend/src/routes/login.tsx b/makima/frontend/src/routes/login.tsx new file mode 100644 index 0000000..63b3af3 --- /dev/null +++ b/makima/frontend/src/routes/login.tsx @@ -0,0 +1,150 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate } from "react-router"; +import { useAuth } from "../contexts/AuthContext"; +import { Masthead } from "../components/Masthead"; + +type AuthMode = "signin" | "signup"; + +export default function LoginPage() { + const navigate = useNavigate(); + const { signIn, signUp, isAuthConfigured, isAuthenticated } = useAuth(); + + const [mode, setMode] = useState<AuthMode>("signin"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState<string | null>(null); + + // Redirect if already authenticated + if (isAuthenticated && isAuthConfigured) { + navigate("/mesh"); + return null; + } + + const handleEmailAuth = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setMessage(null); + setLoading(true); + + try { + if (mode === "signin") { + const { error } = await signIn(email, password); + if (error) { + setError(error.message); + } else { + navigate("/mesh"); + } + } else if (mode === "signup") { + const { error } = await signUp(email, password); + if (error) { + setError(error.message); + } else { + setMessage("Check your email for a confirmation link."); + } + } + } finally { + setLoading(false); + } + }; + + // If auth is not configured, show a message + if (!isAuthConfigured) { + return ( + <div className="relative z-10 min-h-screen flex flex-col"> + <Masthead /> + <main className="flex-1 flex items-center justify-center p-4"> + <div className="w-full max-w-md text-center"> + <h1 className="text-2xl font-bold mb-4">Authentication Required</h1> + <p className="text-zinc-400 mb-4"> + Authentication is not configured. Please configure Supabase authentication to use this application. + </p> + <p className="text-zinc-500 text-sm"> + For API access, use an API key in request headers instead. + </p> + </div> + </main> + </div> + ); + } + + return ( + <div className="relative z-10 min-h-screen flex flex-col"> + <Masthead /> + <main className="flex-1 flex items-center justify-center p-4"> + <div className="w-full max-w-md"> + <div className="text-center mb-8"> + <h1 className="text-2xl font-bold mb-2">Sign In</h1> + <p className="text-zinc-400"> + {mode === "signin" && "Sign in to your account"} + {mode === "signup" && "Create a new account"} + </p> + </div> + + {/* Mode switcher */} + <div className="flex border-b border-zinc-800 mb-6"> + <button + onClick={() => setMode("signin")} + className={`flex-1 py-2 text-sm transition-colors ${ + mode === "signin" + ? "text-white border-b-2 border-white" + : "text-zinc-500 hover:text-zinc-300" + }`} + > + Sign In + </button> + <button + onClick={() => setMode("signup")} + className={`flex-1 py-2 text-sm transition-colors ${ + mode === "signup" + ? "text-white border-b-2 border-white" + : "text-zinc-500 hover:text-zinc-300" + }`} + > + Sign Up + </button> + </div> + + {/* Email/password form */} + <form onSubmit={handleEmailAuth} className="space-y-4"> + <div> + <label className="block text-sm text-zinc-400 mb-1">Email</label> + <input + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder="you@example.com" + className="w-full px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-600" + required + /> + </div> + <div> + <label className="block text-sm text-zinc-400 mb-1">Password</label> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder="********" + className="w-full px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-600" + required + minLength={6} + /> + </div> + + {error && <div className="text-red-400 text-sm">{error}</div>} + {message && <div className="text-green-400 text-sm">{message}</div>} + + <button + type="submit" + disabled={loading} + className="w-full py-2 bg-white text-black rounded font-medium hover:bg-zinc-200 transition-colors disabled:opacity-50" + > + {loading ? "Loading..." : mode === "signin" ? "Sign In" : "Sign Up"} + </button> + </form> + </div> + </main> + </div> + ); +} diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx new file mode 100644 index 0000000..852ce58 --- /dev/null +++ b/makima/frontend/src/routes/mesh.tsx @@ -0,0 +1,634 @@ +import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { TaskList } from "../components/mesh/TaskList"; +import { TaskDetail } from "../components/mesh/TaskDetail"; +import { TaskOutput } from "../components/mesh/TaskOutput"; +import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput"; +import { useTasks } from "../hooks/useTasks"; +import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; +import type { TaskWithSubtasks, MeshChatContext } from "../lib/api"; +import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api"; + +// View modes for the task detail page +type ViewMode = "split" | "task" | "output"; + +// Minimum panel widths (in pixels) +const MIN_TASK_WIDTH = 300; +const MIN_OUTPUT_WIDTH = 200; + +// TODO: Store task output in database for resuming from any device. +// Currently only persisted in localStorage which is device-specific. + +// LocalStorage key prefix for task output +const STORAGE_KEY_PREFIX_OUTPUT = "makima-task-output-"; + +// Load persisted output from localStorage with deduplication +function loadPersistedOutput(taskId: string): TaskOutputEvent[] { + try { + const stored = localStorage.getItem(STORAGE_KEY_PREFIX_OUTPUT + taskId); + if (!stored) return []; + const entries = JSON.parse(stored) as TaskOutputEvent[]; + + // Deduplicate consecutive identical entries (cleanup from previous bug) + const deduplicated: TaskOutputEvent[] = []; + for (const entry of entries) { + const last = deduplicated[deduplicated.length - 1]; + if ( + !last || + last.messageType !== entry.messageType || + last.content !== entry.content || + last.toolName !== entry.toolName + ) { + deduplicated.push(entry); + } + } + + // Save cleaned up version if we removed duplicates + if (deduplicated.length !== entries.length) { + savePersistedOutput(taskId, deduplicated); + } + + return deduplicated; + } catch { + return []; + } +} + +// Save output to localStorage +function savePersistedOutput(taskId: string, entries: TaskOutputEvent[]): void { + try { + localStorage.setItem(STORAGE_KEY_PREFIX_OUTPUT + taskId, JSON.stringify(entries)); + } catch { + // Ignore storage errors + } +} + +// Clear output from localStorage +function clearPersistedOutput(taskId: string): void { + try { + localStorage.removeItem(STORAGE_KEY_PREFIX_OUTPUT + taskId); + } catch { + // Ignore storage errors + } +} + +export default function MeshPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { tasks, loading, error, conflict, clearConflict, fetchTask, fetchTasks, editTask, removeTask, saveTask } = useTasks(); + const [taskDetail, setTaskDetail] = useState<TaskWithSubtasks | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]); + const [isStreaming, setIsStreaming] = useState(false); + // Track which subtask's output we're viewing (null = parent task) + const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null); + const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null); + // View mode for the split panel layout + const [viewMode, setViewMode] = useState<ViewMode>("split"); + // Width of the task panel as a percentage (0-100) + const [taskPanelPercent, setTaskPanelPercent] = useState(66.67); + // Track resizing state + const [isResizing, setIsResizing] = useState(false); + const containerRef = useRef<HTMLDivElement>(null); + // Track which task we've loaded output for to avoid stale saves + const loadedTaskIdRef = useRef<string | null>(null); + + // Handle task update events from WebSocket + const handleTaskUpdate = useCallback(async (event: TaskUpdateEvent) => { + // Refresh task list if we're viewing the list + if (!id) { + fetchTasks(); + return; + } + + // Check if this update is for the current task or one of its subtasks + const isCurrentTask = event.taskId === id; + const isSubtask = taskDetail?.subtasks.some((st) => st.id === event.taskId); + + // Refresh task detail if the update is for current task or any subtask + // This ensures subtask status changes (e.g., when orchestrator starts them) are reflected + if (isCurrentTask || isSubtask) { + const updated = await fetchTask(id); + if (updated) { + setTaskDetail(updated); + } + } + + // Update streaming state based on status for current task + if (isCurrentTask) { + setIsStreaming(event.status === "running"); + } + }, [id, fetchTask, fetchTasks, taskDetail?.subtasks]); + + // The task ID whose output we're currently viewing + const activeOutputTaskId = viewingSubtaskId || id; + + // Handle task output events from WebSocket + const handleTaskOutput = useCallback((event: TaskOutputEvent) => { + // Only process output for the task we're currently viewing + if (event.taskId === activeOutputTaskId) { + setTaskOutputEntries((prev) => { + // Deduplicate by checking if last entry is identical + // This prevents duplicates from React StrictMode or WebSocket reconnects + const lastEntry = prev[prev.length - 1]; + if ( + lastEntry && + lastEntry.messageType === event.messageType && + lastEntry.content === event.content && + lastEntry.toolName === event.toolName + ) { + return prev; // Skip duplicate + } + const newEntries = [...prev, event]; + // Persist to localStorage + savePersistedOutput(event.taskId, newEntries); + return newEntries; + }); + } + }, [activeOutputTaskId]); + + // Handle user input sent to task - show immediately in output + const handleUserInput = useCallback((message: string) => { + if (!activeOutputTaskId) return; + const userEntry: TaskOutputEvent = { + taskId: activeOutputTaskId, + messageType: "user_input", + content: message, + isPartial: false, + }; + setTaskOutputEntries((prev) => { + const newEntries = [...prev, userEntry]; + savePersistedOutput(activeOutputTaskId, newEntries); + return newEntries; + }); + }, [activeOutputTaskId]); + + // Subscribe to task updates and output + // When viewing a subtask's output, subscribe to that instead of the parent + // Always subscribe to all updates so we see subtask status changes + const { connected } = useTaskSubscription({ + taskId: id || null, + subscribeAll: true, // Always subscribe to all - needed to see subtask updates + subscribeOutput: !!activeOutputTaskId, // Subscribe to output when viewing a task + outputTaskId: activeOutputTaskId || undefined, // Which task's output to subscribe to + onUpdate: handleTaskUpdate, + onOutput: handleTaskOutput, + }); + + // Load persisted output when task or viewed subtask changes + useEffect(() => { + if (activeOutputTaskId) { + // First load from localStorage (instant, for local cache) + const persisted = loadPersistedOutput(activeOutputTaskId); + setTaskOutputEntries(persisted); + loadedTaskIdRef.current = activeOutputTaskId; + + // Then fetch from API to get any output we missed + // (e.g., subtask was running before we started viewing it) + getTaskOutput(activeOutputTaskId) + .then((response) => { + if (response.entries.length > 0) { + setTaskOutputEntries((prev) => { + // API returns all historical entries in chronological order + const apiEntries = response.entries.map(entry => ({ + taskId: entry.taskId, + messageType: entry.messageType, + content: entry.content, + toolName: entry.toolName, + toolInput: entry.toolInput, + isError: entry.isError, + costUsd: entry.costUsd, + durationMs: entry.durationMs, + isPartial: false, + })); + + // If localStorage is empty, just use API data + if (prev.length === 0) { + savePersistedOutput(activeOutputTaskId, apiEntries); + return apiEntries; + } + + // localStorage has user_input entries in correct positions - trust its order + // Only append API entries that we don't already have locally + const localKeys = new Set(prev.map(e => `${e.messageType}:${e.content}`)); + const newFromApi = apiEntries.filter(e => !localKeys.has(`${e.messageType}:${e.content}`)); + + // Keep local order (has user_input in correct spots), append new API data + const merged = [...prev, ...newFromApi]; + savePersistedOutput(activeOutputTaskId, merged); + return merged; + }); + } + }) + .catch((err) => { + console.error("Failed to fetch task output:", err); + }); + } else { + setTaskOutputEntries([]); + loadedTaskIdRef.current = null; + } + setIsStreaming(false); + }, [activeOutputTaskId]); + + // Reset subtask view when navigating to a different parent task + useEffect(() => { + setViewingSubtaskId(null); + setViewingSubtaskName(null); + }, [id]); + + // Toggle viewing a subtask's output (for running subtasks) + const handleToggleSubtaskOutput = useCallback( + (subtaskId: string, subtaskName: string) => { + if (viewingSubtaskId === subtaskId) { + // Already viewing this subtask, switch back to parent + setViewingSubtaskId(null); + setViewingSubtaskName(null); + } else { + // Switch to viewing this subtask's output + setViewingSubtaskId(subtaskId); + setViewingSubtaskName(subtaskName); + } + }, + [viewingSubtaskId] + ); + + // Load task detail when URL has an id + useEffect(() => { + if (id) { + setDetailLoading(true); + fetchTask(id).then((detail) => { + setTaskDetail(detail); + setDetailLoading(false); + }); + } else { + setTaskDetail(null); + } + }, [id, fetchTask]); + + const handleSelectTask = useCallback( + (taskId: string) => { + navigate(`/mesh/${taskId}`); + }, + [navigate] + ); + + const handleBack = useCallback(() => { + // If viewing a subtask, go back to parent + if (taskDetail?.parentTaskId) { + navigate(`/mesh/${taskDetail.parentTaskId}`); + } else { + navigate("/mesh"); + } + }, [navigate, taskDetail]); + + const handleDelete = useCallback( + async (taskId: string) => { + if (confirm("Are you sure you want to delete this task?")) { + const success = await removeTask(taskId); + if (success && id === taskId) { + // If deleting current task, go back + if (taskDetail?.parentTaskId) { + navigate(`/mesh/${taskDetail.parentTaskId}`); + } else { + navigate("/mesh"); + } + } + } + }, + [removeTask, id, taskDetail, navigate] + ); + + const handleStart = useCallback( + async (taskId: string) => { + try { + const updated = await startTaskApi(taskId); + setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev); + } catch (e) { + console.error("Failed to start task:", e); + alert(e instanceof Error ? e.message : "Failed to start task"); + } + }, + [] + ); + + const handleStop = useCallback( + async (taskId: string) => { + try { + const updated = await stopTaskApi(taskId); + setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev); + } catch (e) { + console.error("Failed to stop task:", e); + alert(e instanceof Error ? e.message : "Failed to stop task"); + } + }, + [] + ); + + const handleRestart = useCallback( + async (taskId: string) => { + try { + // First stop the task + await stopTaskApi(taskId); + // Then start it again + const updated = await startTaskApi(taskId); + setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev); + } catch (e) { + console.error("Failed to restart task:", e); + alert(e instanceof Error ? e.message : "Failed to restart task"); + } + }, + [] + ); + + const handleContinue = useCallback( + async (taskId: string) => { + try { + // Start the task again from terminal state + const updated = await startTaskApi(taskId); + setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev); + } catch (e) { + console.error("Failed to continue task:", e); + alert(e instanceof Error ? e.message : "Failed to continue task"); + } + }, + [] + ); + + const handleSave = useCallback( + async (taskId: string, name: string, description: string, plan: string, targetRepoPath?: string, completionAction?: string) => { + if (!taskDetail) return; + const result = await editTask(taskId, { + name, + description: description || undefined, + plan, + targetRepoPath: targetRepoPath || undefined, + completionAction: completionAction as import("../lib/api").CompletionAction | undefined, + version: taskDetail.version, + }); + if (result) { + setTaskDetail(result); + } + }, + [editTask, taskDetail] + ); + + const handleCreate = useCallback(async () => { + if (creating) return; + setCreating(true); + try { + const newTask = await saveTask({ + name: `Task ${new Date().toLocaleDateString()}`, + plan: "# Plan\n\nDescribe what this task should accomplish...", + }); + if (newTask) { + navigate(`/mesh/${newTask.id}`); + } + } finally { + setCreating(false); + } + }, [creating, saveTask, navigate]); + + const handleCreateSubtask = useCallback(async () => { + if (!taskDetail || creating) return; + setCreating(true); + try { + const newTask = await saveTask({ + name: `Subtask of ${taskDetail.name}`, + plan: "# Plan\n\nDescribe what this subtask should accomplish...", + parentTaskId: taskDetail.id, + }); + if (newTask) { + // Refresh current task to show new subtask + const refreshed = await fetchTask(taskDetail.id); + if (refreshed) { + setTaskDetail(refreshed); + } + } + } finally { + setCreating(false); + } + }, [creating, saveTask, taskDetail, fetchTask]); + + // Callback when task is updated via CLI + const handleTaskUpdatedFromCli = useCallback(async () => { + if (id) { + const updated = await fetchTask(id); + if (updated) { + setTaskDetail(updated); + } + } + // Also refresh the task list + fetchTasks(); + }, [id, fetchTask, fetchTasks]); + + // Calculate chat context based on current view + const chatContext: MeshChatContext = useMemo(() => { + if (!id) { + return { type: "mesh" }; + } + if (taskDetail?.parentTaskId) { + return { type: "subtask", taskId: id, parentTaskId: taskDetail.parentTaskId }; + } + return { type: "task", taskId: id }; + }, [id, taskDetail?.parentTaskId]); + + // Handle resizing of the split panel + const handleResizeStart = useCallback((e: MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + }, []); + + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: globalThis.MouseEvent) => { + if (!containerRef.current) return; + const containerRect = containerRef.current.getBoundingClientRect(); + const containerWidth = containerRect.width; + const mouseX = e.clientX - containerRect.left; + + // Calculate percentage, respecting minimum widths + const minTaskPercent = (MIN_TASK_WIDTH / containerWidth) * 100; + const maxTaskPercent = ((containerWidth - MIN_OUTPUT_WIDTH) / containerWidth) * 100; + const newPercent = Math.max(minTaskPercent, Math.min(maxTaskPercent, (mouseX / containerWidth) * 100)); + + setTaskPanelPercent(newPercent); + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizing]); + + // Cycle through view modes + const cycleViewMode = useCallback(() => { + setViewMode((current) => { + if (current === "split") return "task"; + if (current === "task") return "output"; + return "split"; + }); + }, []); + + // Get label for current view mode + const getViewModeLabel = (mode: ViewMode): string => { + switch (mode) { + case "split": return "Split"; + case "task": return "Task"; + case "output": return "Output"; + } + }; + + return ( + <div className="relative z-10 h-screen flex flex-col overflow-hidden"> + <Masthead showTicker={false} showNav /> + + <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col"> + {error && ( + <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0"> + {error} + </div> + )} + + {conflict?.hasConflict && ( + <div className="mb-4 p-3 border border-yellow-400/50 bg-yellow-400/10 text-yellow-400 font-mono text-sm shrink-0"> + <p>Version conflict detected. Please reload and try again.</p> + <button + onClick={clearConflict} + className="mt-2 px-3 py-1 border border-yellow-400/30 hover:border-yellow-400/50 text-xs uppercase" + > + Dismiss + </button> + </div> + )} + + {/* Main content area - conditional based on route */} + <div className="flex-1 flex flex-col min-h-0 overflow-hidden gap-4"> + {id && taskDetail ? ( + <> + {/* Header with connection status and view toggle */} + <div className="flex items-center justify-between shrink-0"> + <div className="flex items-center gap-2"> + <span + className={`w-2 h-2 rounded-full ${ + connected ? "bg-green-400" : "bg-yellow-400 animate-pulse" + }`} + /> + <span className="font-mono text-[10px] text-[#75aafc] uppercase"> + {connected ? "Connected" : "Connecting..."} + </span> + </div> + {/* View mode toggle */} + <button + onClick={cycleViewMode} + className="px-3 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + View: {getViewModeLabel(viewMode)} + </button> + </div> + + {/* Split panel layout */} + <div + ref={containerRef} + className={`flex-1 flex min-h-0 overflow-hidden ${isResizing ? "select-none" : ""}`} + > + {/* Task detail panel */} + {(viewMode === "split" || viewMode === "task") && ( + <div + className="min-h-0 overflow-hidden" + style={{ + width: viewMode === "split" ? `${taskPanelPercent}%` : "100%", + flexShrink: 0, + }} + > + <TaskDetail + task={taskDetail} + loading={detailLoading} + onBack={handleBack} + onSave={handleSave} + onDelete={handleDelete} + onStart={handleStart} + onStop={handleStop} + onRestart={handleRestart} + onContinue={handleContinue} + onSelectSubtask={handleSelectTask} + onCreateSubtask={handleCreateSubtask} + onToggleSubtaskOutput={handleToggleSubtaskOutput} + viewingSubtaskId={viewingSubtaskId} + /> + </div> + )} + + {/* Resizable divider */} + {viewMode === "split" && ( + <div + className="w-1 shrink-0 cursor-col-resize bg-[rgba(117,170,252,0.15)] hover:bg-[rgba(117,170,252,0.35)] transition-colors group flex items-center justify-center" + onMouseDown={handleResizeStart} + > + <div className="w-0.5 h-8 bg-[rgba(117,170,252,0.3)] group-hover:bg-[rgba(117,170,252,0.5)] rounded-full" /> + </div> + )} + + {/* Output panel */} + {(viewMode === "split" || viewMode === "output") && ( + <div + className="panel min-h-0 overflow-hidden flex-1" + > + <TaskOutput + entries={taskOutputEntries} + isStreaming={isStreaming || taskDetail.status === "running"} + viewingSubtaskName={viewingSubtaskName} + onClearSubtaskView={viewingSubtaskId ? () => { + setViewingSubtaskId(null); + setViewingSubtaskName(null); + } : undefined} + onClear={() => { + setTaskOutputEntries([]); + if (activeOutputTaskId) { + clearPersistedOutput(activeOutputTaskId); + } + }} + taskId={activeOutputTaskId} + onUserInput={handleUserInput} + /> + </div> + )} + </div> + </> + ) : id && detailLoading ? ( + <div className="panel flex-1 flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ) : ( + <div className="flex-1 min-h-0 overflow-hidden"> + <TaskList + tasks={tasks} + loading={loading || creating} + onSelect={handleSelectTask} + onDelete={handleDelete} + onCreate={handleCreate} + /> + </div> + )} + + {/* Mesh Chat Input - always rendered to persist state across navigation */} + <div className="shrink-0"> + <UnifiedMeshChatInput + context={chatContext} + onUpdate={id ? handleTaskUpdatedFromCli : fetchTasks} + /> + </div> + </div> + </main> + </div> + ); +} diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx new file mode 100644 index 0000000..6d56e67 --- /dev/null +++ b/makima/frontend/src/routes/settings.tsx @@ -0,0 +1,724 @@ +import { useState, useEffect, type FormEvent } from "react"; +import { useAuth } from "../contexts/AuthContext"; +import { useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { + getApiKey, + createApiKey, + refreshApiKey, + revokeApiKey, + changePassword, + changeEmail, + deleteAccount, + type ApiKeyInfo, + type CreateApiKeyResponse, +} from "../lib/api"; + +// ============================================================================= +// Password Strength Indicator +// ============================================================================= + +interface PasswordStrength { + score: number; + label: string; + color: string; + requirements: { met: boolean; text: string }[]; +} + +function getPasswordStrength(password: string): PasswordStrength { + const requirements = [ + { met: password.length >= 6, text: "At least 6 characters" }, + ]; + + const score = requirements.filter((r) => r.met).length; + + const label = score === 1 ? "Valid" : "Too short"; + const color = score === 1 ? "bg-green-500" : "bg-red-500"; + + return { score, label, color, requirements }; +} + +// ============================================================================= +// Confirmation Dialog Component +// ============================================================================= + +interface ConfirmDialogProps { + isOpen: boolean; + title: string; + message: string; + confirmText: string; + confirmButtonClass?: string; + requireInput?: string; + inputPlaceholder?: string; + onConfirm: () => void; + onCancel: () => void; +} + +function ConfirmDialog({ + isOpen, + title, + message, + confirmText, + confirmButtonClass = "bg-red-900/50 border-red-700 hover:bg-red-800/50", + requireInput, + inputPlaceholder, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const [inputValue, setInputValue] = useState(""); + + useEffect(() => { + if (!isOpen) { + setInputValue(""); + } + }, [isOpen]); + + if (!isOpen) return null; + + const canConfirm = !requireInput || inputValue === requireInput; + + return ( + <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"> + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] p-6 max-w-md w-full mx-4"> + <h3 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff] mb-3">{title}</h3> + <p className="text-[#75aafc] text-xs font-mono mb-4">{message}</p> + {requireInput && ( + <div className="mb-4"> + <label className="block text-xs font-mono text-[#8899aa] mb-2"> + Type <span className="text-[#9bc3ff]">{requireInput}</span> to confirm: + </label> + <input + type="text" + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + placeholder={inputPlaceholder} + className="w-full px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3]" + /> + </div> + )} + <div className="flex gap-3 justify-end"> + <button + onClick={onCancel} + className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors" + > + Cancel + </button> + <button + onClick={onConfirm} + disabled={!canConfirm} + className={`px-4 py-2 border font-mono text-xs uppercase tracking-wide transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${confirmButtonClass}`} + > + {confirmText} + </button> + </div> + </div> + </div> + ); +} + +// ============================================================================= +// Section Header Component +// ============================================================================= + +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( + <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa] mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]"> + {children} + </h2> + ); +} + +// ============================================================================= +// Form Input Component +// ============================================================================= + +function FormInput({ + label, + type = "text", + value, + onChange, + placeholder, + required, + disabled, +}: { + label: string; + type?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + required?: boolean; + disabled?: boolean; +}) { + return ( + <div> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa] mb-1"> + {label} + </label> + <input + type={type} + value={value} + onChange={(e) => onChange(e.target.value)} + placeholder={placeholder} + required={required} + disabled={disabled} + className="w-full px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3] disabled:opacity-50" + /> + </div> + ); +} + +// ============================================================================= +// Alert Components +// ============================================================================= + +function ErrorAlert({ children }: { children: React.ReactNode }) { + return ( + <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-4 font-mono text-xs"> + {children} + </div> + ); +} + +function SuccessAlert({ children }: { children: React.ReactNode }) { + return ( + <div className="border border-green-700/50 bg-green-900/20 text-green-400 px-3 py-2 mb-4 font-mono text-xs"> + {children} + </div> + ); +} + +// ============================================================================= +// Button Components +// ============================================================================= + +function PrimaryButton({ + children, + onClick, + disabled, + type = "button", +}: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + type?: "button" | "submit"; +}) { + return ( + <button + type={type} + onClick={onClick} + disabled={disabled} + className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {children} + </button> + ); +} + +function SecondaryButton({ + children, + onClick, + disabled, +}: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; +}) { + return ( + <button + type="button" + onClick={onClick} + disabled={disabled} + className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {children} + </button> + ); +} + +function DangerButton({ + children, + onClick, + disabled, +}: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; +}) { + return ( + <button + type="button" + onClick={onClick} + disabled={disabled} + className="px-4 py-2 bg-red-900/30 border border-red-700/50 text-red-400 font-mono text-xs uppercase tracking-wide hover:bg-red-800/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {children} + </button> + ); +} + +// ============================================================================= +// Main Settings Page +// ============================================================================= + +export default function SettingsPage() { + const { user, isAuthConfigured, signOut } = useAuth(); + const navigate = useNavigate(); + + // API Key state + const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null); + const [newKey, setNewKey] = useState<string | null>(null); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [copied, setCopied] = useState(false); + + // Password change state + const [passwordForm, setPasswordForm] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + const [passwordLoading, setPasswordLoading] = useState(false); + const [passwordError, setPasswordError] = useState<string | null>(null); + const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null); + + // Email change state + const [emailForm, setEmailForm] = useState({ + password: "", + newEmail: "", + }); + const [emailLoading, setEmailLoading] = useState(false); + const [emailError, setEmailError] = useState<string | null>(null); + const [emailSuccess, setEmailSuccess] = useState<string | null>(null); + + // Account deletion state + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletePassword, setDeletePassword] = useState(""); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteError, setDeleteError] = useState<string | null>(null); + + useEffect(() => { + loadApiKey(); + }, []); + + const loadApiKey = async () => { + try { + setLoading(true); + setError(null); + const key = await getApiKey(); + setApiKeyInfo(key); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load API key"); + } finally { + setLoading(false); + } + }; + + const handleCreate = async () => { + try { + setActionLoading(true); + setError(null); + setNewKey(null); + const response: CreateApiKeyResponse = await createApiKey("Web UI"); + setNewKey(response.key); + setApiKeyInfo({ + id: response.id, + prefix: response.prefix, + name: response.name, + lastUsedAt: null, + createdAt: response.createdAt, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create API key"); + } finally { + setActionLoading(false); + } + }; + + const handleRefresh = async () => { + try { + setActionLoading(true); + setError(null); + setNewKey(null); + const response = await refreshApiKey("Web UI (Refreshed)"); + setNewKey(response.key); + setApiKeyInfo({ + id: response.id, + prefix: response.prefix, + name: response.name, + lastUsedAt: null, + createdAt: response.createdAt, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to refresh API key"); + } finally { + setActionLoading(false); + } + }; + + const handleRevoke = async () => { + if (!confirm("Are you sure you want to revoke this API key? Any applications using it will stop working.")) { + return; + } + try { + setActionLoading(true); + setError(null); + setNewKey(null); + await revokeApiKey(); + setApiKeyInfo(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to revoke API key"); + } finally { + setActionLoading(false); + } + }; + + const copyToClipboard = async () => { + if (!newKey) return; + try { + await navigator.clipboard.writeText(newKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + }; + + // Password change handlers + const handlePasswordChange = async (e: FormEvent) => { + e.preventDefault(); + setPasswordError(null); + setPasswordSuccess(null); + + if (passwordForm.newPassword !== passwordForm.confirmPassword) { + setPasswordError("New passwords do not match"); + return; + } + + const strength = getPasswordStrength(passwordForm.newPassword); + if (strength.score < 1) { + setPasswordError("Password must be at least 6 characters"); + return; + } + + try { + setPasswordLoading(true); + await changePassword(passwordForm.currentPassword, passwordForm.newPassword); + setPasswordSuccess("Password changed successfully. Please sign in with your new password."); + setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" }); + setTimeout(async () => { + await signOut(); + navigate("/login"); + }, 1500); + } catch (err) { + setPasswordError(err instanceof Error ? err.message : "Failed to change password"); + } finally { + setPasswordLoading(false); + } + }; + + // Email change handlers + const handleEmailChange = async (e: FormEvent) => { + e.preventDefault(); + setEmailError(null); + setEmailSuccess(null); + + if (!emailForm.newEmail.includes("@")) { + setEmailError("Please enter a valid email address"); + return; + } + + try { + setEmailLoading(true); + await changeEmail(emailForm.password, emailForm.newEmail); + setEmailSuccess("Email changed successfully"); + setEmailForm({ password: "", newEmail: "" }); + } catch (err) { + setEmailError(err instanceof Error ? err.message : "Failed to change email"); + } finally { + setEmailLoading(false); + } + }; + + // Account deletion handlers + const DELETE_CONFIRMATION = "DELETE MY ACCOUNT"; + + const handleDeleteAccount = async () => { + try { + setDeleteLoading(true); + setDeleteError(null); + await deleteAccount(deletePassword, DELETE_CONFIRMATION); + await signOut(); + navigate("/login"); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Failed to delete account"); + setDeleteLoading(false); + } + }; + + const passwordStrength = getPasswordStrength(passwordForm.newPassword); + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + + <main className="flex-1 max-w-4xl mx-auto p-6 w-full"> + {/* Page Header */} + <div className="mb-8"> + <h1 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Settings</h1> + <div className="h-px bg-[rgba(117,170,252,0.35)] mt-2" /> + </div> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + {/* Left Column */} + <div className="space-y-6"> + {/* Account Info */} + <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4"> + <SectionHeader>Account</SectionHeader> + {isAuthConfigured && user ? ( + <div className="space-y-2 font-mono text-xs"> + <div className="flex justify-between"> + <span className="text-[#7788aa]">Email</span> + <span className="text-[#9bc3ff]">{user.email}</span> + </div> + <div className="flex justify-between"> + <span className="text-[#7788aa]">User ID</span> + <span className="text-[#75aafc] text-[10px]">{user.id}</span> + </div> + </div> + ) : ( + <p className="text-[#7788aa] font-mono text-xs"> + {isAuthConfigured + ? "Not signed in" + : "Authentication not configured (API key mode)"} + </p> + )} + </section> + + {/* API Key Section */} + <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4"> + <SectionHeader>API Key</SectionHeader> + <p className="text-[#7788aa] font-mono text-[10px] mb-4"> + Authenticate daemon and CLI tools. One active key at a time. + </p> + + {error && <ErrorAlert>{error}</ErrorAlert>} + + {newKey && ( + <div className="border border-green-700/50 bg-green-900/20 p-3 mb-4"> + <p className="text-green-400 font-mono text-[10px] mb-2"> + Key created. Copy now - won't be shown again. + </p> + <div className="flex items-center gap-2"> + <code className="flex-1 bg-black/50 px-2 py-1 text-[10px] font-mono text-green-400 break-all"> + {newKey} + </code> + <button + onClick={copyToClipboard} + className="px-2 py-1 bg-green-900/50 border border-green-700/50 text-green-400 font-mono text-[10px] uppercase hover:bg-green-800/50 transition-colors" + > + {copied ? "Copied" : "Copy"} + </button> + </div> + </div> + )} + + {loading ? ( + <p className="text-[#7788aa] font-mono text-xs">Loading...</p> + ) : apiKeyInfo ? ( + <div className="space-y-3"> + <div className="font-mono text-xs"> + <div className="flex justify-between mb-1"> + <span className="text-[#7788aa]">Prefix</span> + <code className="text-[#75aafc]">{apiKeyInfo.prefix}...</code> + </div> + <div className="flex justify-between mb-1"> + <span className="text-[#7788aa]">Created</span> + <span className="text-[#9bc3ff]"> + {new Date(apiKeyInfo.createdAt).toLocaleDateString()} + </span> + </div> + {apiKeyInfo.lastUsedAt && ( + <div className="flex justify-between"> + <span className="text-[#7788aa]">Last used</span> + <span className="text-[#9bc3ff]"> + {new Date(apiKeyInfo.lastUsedAt).toLocaleDateString()} + </span> + </div> + )} + </div> + <div className="flex gap-2 pt-2"> + <SecondaryButton onClick={handleRefresh} disabled={actionLoading}> + {actionLoading ? "..." : "Rotate"} + </SecondaryButton> + <DangerButton onClick={handleRevoke} disabled={actionLoading}> + {actionLoading ? "..." : "Revoke"} + </DangerButton> + </div> + </div> + ) : ( + <div> + <p className="text-[#7788aa] font-mono text-xs mb-3">No API key configured.</p> + <PrimaryButton onClick={handleCreate} disabled={actionLoading}> + {actionLoading ? "Creating..." : "Create API Key"} + </PrimaryButton> + </div> + )} + </section> + + {/* Daemon Setup */} + <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4"> + <SectionHeader>Daemon Setup</SectionHeader> + <p className="text-[#7788aa] font-mono text-[10px] mb-3"> + Set your API key as an environment variable: + </p> + <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 mb-3"> + export MAKIMA_API_KEY="your-key" + </code> + <p className="text-[#7788aa] font-mono text-[10px]"> + Then run: <code className="text-green-400">makima-daemon</code> + </p> + </section> + </div> + + {/* Right Column */} + <div className="space-y-6"> + {/* Password Change */} + {isAuthConfigured && user && ( + <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4"> + <SectionHeader>Change Password</SectionHeader> + {passwordError && <ErrorAlert>{passwordError}</ErrorAlert>} + {passwordSuccess && <SuccessAlert>{passwordSuccess}</SuccessAlert>} + <form onSubmit={handlePasswordChange} className="space-y-3"> + <FormInput + label="Current Password" + type="password" + value={passwordForm.currentPassword} + onChange={(v) => setPasswordForm({ ...passwordForm, currentPassword: v })} + required + /> + <FormInput + label="New Password" + type="password" + value={passwordForm.newPassword} + onChange={(v) => setPasswordForm({ ...passwordForm, newPassword: v })} + required + /> + {passwordForm.newPassword && ( + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <div className="flex-1 h-1 bg-[#1a2a3a]"> + <div + className={`h-full transition-all ${passwordStrength.color}`} + style={{ width: `${passwordStrength.score * 100}%` }} + /> + </div> + <span className="text-[10px] font-mono text-[#9bc3ff]"> + {passwordStrength.label} + </span> + </div> + </div> + )} + <FormInput + label="Confirm Password" + type="password" + value={passwordForm.confirmPassword} + onChange={(v) => setPasswordForm({ ...passwordForm, confirmPassword: v })} + required + /> + {passwordForm.confirmPassword && + passwordForm.newPassword !== passwordForm.confirmPassword && ( + <p className="text-red-400 font-mono text-[10px]">Passwords do not match</p> + )} + <div className="pt-2"> + <PrimaryButton + type="submit" + disabled={passwordLoading || passwordStrength.score < 1} + > + {passwordLoading ? "Changing..." : "Change Password"} + </PrimaryButton> + </div> + </form> + </section> + )} + + {/* Email Change */} + {isAuthConfigured && user && ( + <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4"> + <SectionHeader>Change Email</SectionHeader> + {emailError && <ErrorAlert>{emailError}</ErrorAlert>} + {emailSuccess && <SuccessAlert>{emailSuccess}</SuccessAlert>} + <form onSubmit={handleEmailChange} className="space-y-3"> + <FormInput + label="New Email" + type="email" + value={emailForm.newEmail} + onChange={(v) => setEmailForm({ ...emailForm, newEmail: v })} + placeholder="new@example.com" + required + /> + <FormInput + label="Password (to confirm)" + type="password" + value={emailForm.password} + onChange={(v) => setEmailForm({ ...emailForm, password: v })} + required + /> + <div className="pt-2"> + <PrimaryButton type="submit" disabled={emailLoading}> + {emailLoading ? "Changing..." : "Change Email"} + </PrimaryButton> + </div> + </form> + </section> + )} + + {/* Danger Zone */} + {isAuthConfigured && user && ( + <section className="border border-red-900/50 bg-[#0d1b2d] p-4"> + <h2 className="text-[11px] font-mono uppercase tracking-wide text-red-400 mb-3 pb-2 border-b border-red-900/30"> + Danger Zone + </h2> + <p className="text-[#7788aa] font-mono text-[10px] mb-3"> + Permanently delete your account and all data. This cannot be undone. + </p> + {deleteError && <ErrorAlert>{deleteError}</ErrorAlert>} + <div className="space-y-3"> + <FormInput + label="Password" + type="password" + value={deletePassword} + onChange={setDeletePassword} + placeholder="Enter password to continue" + /> + <DangerButton + onClick={() => setDeleteDialogOpen(true)} + disabled={!deletePassword || deleteLoading} + > + {deleteLoading ? "Deleting..." : "Delete Account"} + </DangerButton> + </div> + </section> + )} + </div> + </div> + </main> + + {/* Delete Confirmation Dialog */} + <ConfirmDialog + isOpen={deleteDialogOpen} + title="Delete Account" + message="This will permanently delete your account and all your data. This action cannot be undone." + confirmText="Delete" + confirmButtonClass="bg-red-900/50 border border-red-700 text-red-400 hover:bg-red-800/50" + requireInput={DELETE_CONFIRMATION} + inputPlaceholder={`Type "${DELETE_CONFIRMATION}" to confirm`} + onConfirm={() => { + setDeleteDialogOpen(false); + handleDeleteAccount(); + }} + onCancel={() => setDeleteDialogOpen(false)} + /> + </div> + ); +} |
