diff options
Diffstat (limited to 'makima/frontend/src/lib')
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 921 | ||||
| -rw-r--r-- | makima/frontend/src/lib/supabase.ts | 26 |
2 files changed, 933 insertions, 14 deletions
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 2657a95..a11f15e 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1,3 +1,5 @@ +import { supabase } from "./supabase"; + const API_CONFIG = { local: { http: "http://localhost:8080", @@ -33,8 +35,72 @@ const env = detectEnvironment(); export const API_BASE = API_CONFIG[env].http; export const WS_BASE = API_CONFIG[env].ws; + +// ============================================================================= +// Authentication helpers +// ============================================================================= + +/** Storage key for API key */ +const API_KEY_STORAGE_KEY = "makima_api_key"; + +/** Get stored API key from localStorage */ +export function getStoredApiKey(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(API_KEY_STORAGE_KEY); +} + +/** Store API key in localStorage */ +export function setStoredApiKey(key: string): void { + if (typeof window === "undefined") return; + localStorage.setItem(API_KEY_STORAGE_KEY, key); +} + +/** Remove stored API key */ +export function clearStoredApiKey(): void { + if (typeof window === "undefined") return; + localStorage.removeItem(API_KEY_STORAGE_KEY); +} + +/** Get auth headers for API requests */ +async function getAuthHeaders(): Promise<HeadersInit> { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + // Try Supabase session first + if (supabase) { + const { data: { session } } = await supabase.auth.getSession(); + if (session?.access_token) { + headers["Authorization"] = `Bearer ${session.access_token}`; + return headers; + } + } + + // Fall back to API key if available + const apiKey = getStoredApiKey(); + if (apiKey) { + headers["X-Makima-API-Key"] = apiKey; + } + + return headers; +} + +/** Fetch with authentication headers */ +async function authFetch(url: string, options: RequestInit = {}): Promise<Response> { + const authHeaders = await getAuthHeaders(); + const mergedHeaders = { + ...authHeaders, + ...options.headers, + }; + + return fetch(url, { + ...options, + headers: mergedHeaders, + }); +} export const LISTEN_ENDPOINT = `${WS_BASE}/api/v1/listen`; export const FILE_SUBSCRIBE_ENDPOINT = `${WS_BASE}/api/v1/files/subscribe`; +export const TASK_SUBSCRIBE_ENDPOINT = `${WS_BASE}/api/v1/mesh/tasks/subscribe`; export function getEnvironment(): Environment { return env; @@ -57,6 +123,8 @@ export type ChartType = "line" | "bar" | "pie" | "area"; export type BodyElement = | { type: "heading"; level: number; text: string } | { type: "paragraph"; text: string } + | { type: "code"; language?: string; content: string } + | { type: "list"; ordered: boolean; items: string[] } | { type: "chart"; chartType: ChartType; @@ -145,6 +213,7 @@ export interface ChatRequest { message: string; model?: LlmModel; history?: ChatMessage[]; + focusedElementIndex?: number; } export interface ToolCallInfo { @@ -179,7 +248,7 @@ export interface ChatResponse { // File API functions export async function listFiles(): Promise<FileListResponse> { - const res = await fetch(`${API_BASE}/api/v1/files`); + const res = await authFetch(`${API_BASE}/api/v1/files`); if (!res.ok) { throw new Error(`Failed to list files: ${res.statusText}`); } @@ -187,7 +256,7 @@ export async function listFiles(): Promise<FileListResponse> { } export async function getFile(id: string): Promise<FileDetail> { - const res = await fetch(`${API_BASE}/api/v1/files/${id}`); + const res = await authFetch(`${API_BASE}/api/v1/files/${id}`); if (!res.ok) { throw new Error(`Failed to get file: ${res.statusText}`); } @@ -195,9 +264,8 @@ export async function getFile(id: string): Promise<FileDetail> { } export async function createFile(data: CreateFileRequest): Promise<FileDetail> { - const res = await fetch(`${API_BASE}/api/v1/files`, { + const res = await authFetch(`${API_BASE}/api/v1/files`, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) { @@ -210,9 +278,8 @@ export async function updateFile( id: string, data: UpdateFileRequest ): Promise<FileDetail> { - const res = await fetch(`${API_BASE}/api/v1/files/${id}`, { + const res = await authFetch(`${API_BASE}/api/v1/files/${id}`, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); @@ -228,7 +295,7 @@ export async function updateFile( } export async function deleteFile(id: string): Promise<void> { - const res = await fetch(`${API_BASE}/api/v1/files/${id}`, { + const res = await authFetch(`${API_BASE}/api/v1/files/${id}`, { method: "DELETE", }); if (!res.ok) { @@ -241,7 +308,8 @@ export async function chatWithFile( id: string, message: string, model?: LlmModel, - history?: ChatMessage[] + history?: ChatMessage[], + focusedElementIndex?: number ): Promise<ChatResponse> { const body: ChatRequest = { message }; if (model) { @@ -250,9 +318,11 @@ export async function chatWithFile( if (history && history.length > 0) { body.history = history; } - const res = await fetch(`${API_BASE}/api/v1/files/${id}/chat`, { + if (focusedElementIndex !== undefined) { + body.focusedElementIndex = focusedElementIndex; + } + const res = await authFetch(`${API_BASE}/api/v1/files/${id}/chat`, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { @@ -294,7 +364,7 @@ export interface RestoreVersionRequest { // Version history API functions export async function listFileVersions(fileId: string): Promise<FileVersionListResponse> { - const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions`); + const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/versions`); if (!res.ok) { throw new Error(`Failed to list versions: ${res.statusText}`); } @@ -302,7 +372,7 @@ export async function listFileVersions(fileId: string): Promise<FileVersionListR } export async function getFileVersion(fileId: string, version: number): Promise<FileVersion> { - const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/${version}`); + const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/versions/${version}`); if (!res.ok) { throw new Error(`Failed to get version: ${res.statusText}`); } @@ -314,9 +384,8 @@ export async function restoreFileVersion( targetVersion: number, currentVersion: number ): Promise<FileDetail> { - const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/restore`, { + const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/versions/restore`, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ targetVersion, currentVersion }), }); @@ -396,3 +465,827 @@ export type LlmVersionToolResult = | { name: "read_version"; result: ReadVersionToolOutput } | { name: "list_versions"; result: ListVersionsToolOutput } | { name: "restore_version"; result: RestoreVersionToolOutput }; + +// ============================================================================= +// Mesh/Task Types for Claude Code Orchestration +// ============================================================================= + +export type TaskStatus = + | "pending" + | "initializing" + | "starting" + | "running" + | "paused" + | "blocked" + | "done" + | "failed" + | "merged"; + +export type MergeMode = "pr" | "auto" | "manual"; + +/** Action to perform when a task completes successfully */ +export type CompletionAction = "none" | "branch" | "merge" | "pr"; + +export type DaemonStatus = "connected" | "disconnected" | "unhealthy"; + +export interface TaskSummary { + id: string; + parentTaskId: string | null; + depth: number; + name: string; + status: TaskStatus; + priority: number; + progressSummary: string | null; + subtaskCount: number; + version: number; + createdAt: string; + updatedAt: string; +} + +export interface Task { + id: string; + ownerId: string; + parentTaskId: string | null; + depth: number; + name: string; + description: string | null; + status: TaskStatus; + priority: number; + plan: string; + + // Daemon/container info + daemonId: string | null; + containerId: string | null; + overlayPath: string | null; + + // Repository info + repositoryUrl: string | null; + baseBranch: string | null; + targetBranch: string | null; + + // Merge settings + mergeMode: MergeMode | null; + prUrl: string | null; + + // Completion action settings + /** Path to user's local repository for completion actions */ + targetRepoPath: string | null; + /** Action on completion: "none", "branch", "merge", "pr" */ + completionAction: CompletionAction | null; + + // Progress tracking + progressSummary: string | null; + lastOutput: string | null; + errorMessage: string | null; + + // Timestamps + startedAt: string | null; + completedAt: string | null; + version: number; + createdAt: string; + updatedAt: string; +} + +export interface TaskWithSubtasks extends Task { + subtasks: TaskSummary[]; +} + +export interface TaskListResponse { + tasks: TaskSummary[]; + total: number; +} + +export interface CreateTaskRequest { + name: string; + description?: string; + plan: string; + parentTaskId?: string; + priority?: number; + repositoryUrl?: string; + baseBranch?: string; + targetBranch?: string; + mergeMode?: MergeMode; + /** Path to user's local repository for completion actions */ + targetRepoPath?: string; + /** Action on completion: "none", "branch", "merge", "pr" */ + completionAction?: CompletionAction; +} + +export interface UpdateTaskRequest { + name?: string; + description?: string; + plan?: string; + status?: TaskStatus; + priority?: number; + progressSummary?: string; + lastOutput?: string; + errorMessage?: string; + mergeMode?: MergeMode; + prUrl?: string; + /** Path to user's local repository for completion actions */ + targetRepoPath?: string; + /** Action on completion: "none", "branch", "merge", "pr" */ + completionAction?: CompletionAction; + version?: number; +} + +export interface TaskEvent { + id: string; + taskId: string; + eventType: string; + previousStatus: string | null; + newStatus: string | null; + eventData: Record<string, unknown> | null; + createdAt: string; +} + +export interface TaskEventListResponse { + events: TaskEvent[]; + total: number; +} + +export interface Daemon { + id: string; + ownerId: string; + connectionId: string; + hostname: string | null; + machineId: string | null; + maxConcurrentTasks: number; + currentTaskCount: number; + status: DaemonStatus; + lastHeartbeatAt: string; + connectedAt: string; + disconnectedAt: string | null; +} + +export interface DaemonListResponse { + daemons: Daemon[]; + total: number; +} + +// Mesh API functions +export async function listTasks(): Promise<TaskListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks`); + if (!res.ok) { + throw new Error(`Failed to list tasks: ${res.statusText}`); + } + return res.json(); +} + +export async function getTask(id: string): Promise<TaskWithSubtasks> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}`); + if (!res.ok) { + throw new Error(`Failed to get task: ${res.statusText}`); + } + return res.json(); +} + +export async function createTask(data: CreateTaskRequest): Promise<Task> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks`, { + method: "POST", + body: JSON.stringify(data), + }); + if (!res.ok) { + throw new Error(`Failed to create task: ${res.statusText}`); + } + return res.json(); +} + +export async function updateTask( + id: string, + data: UpdateTaskRequest +): Promise<Task> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); + + if (res.status === 409) { + const conflict = (await res.json()) as ConflictErrorResponse; + throw new VersionConflictError(conflict); + } + + if (!res.ok) { + throw new Error(`Failed to update task: ${res.statusText}`); + } + return res.json(); +} + +export async function deleteTask(id: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to delete task: ${res.statusText}`); + } +} + +export async function startTask(id: string): Promise<Task> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}/start`, { + method: "POST", + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to start task: ${errorText || res.statusText}`); + } + return res.json(); +} + +export async function stopTask(id: string): Promise<Task> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}/stop`, { + method: "POST", + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to stop task: ${errorText || res.statusText}`); + } + return res.json(); +} + +export interface SendMessageResponse { + success: boolean; + taskId: string; + messageLength: number; +} + +/** + * Send a message to a running task's stdin. + * This can be used to provide input to Claude Code when it's waiting for user input, + * or to inject context/instructions into a running task. + */ +export async function sendTaskMessage( + taskId: string, + message: string +): Promise<SendMessageResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/message`, { + method: "POST", + body: JSON.stringify({ message }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to send message: ${errorText || res.statusText}`); + } + return res.json(); +} + +export interface RetryCompletionResponse { + success: boolean; + taskId: string; + action: string; + targetRepoPath: string; + message: string; +} + +/** + * Retry completion action for a completed task. + * This allows retrying a completion action (push branch, merge, create PR) + * after filling in the target_repo_path if it wasn't set when the task completed. + */ +export async function retryCompletionAction( + taskId: string +): Promise<RetryCompletionResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/retry-completion`, { + method: "POST", + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to retry completion action: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** A suggested directory from a connected daemon */ +export interface DaemonDirectory { + /** Path to the directory */ + path: string; + /** Display label for the directory */ + label: string; + /** Type of directory: "working", "makima", "worktrees" */ + directoryType: string; + /** Daemon hostname this directory is from */ + hostname: string | null; + /** Whether the directory already exists (for validation) */ + exists?: boolean; +} + +export interface DaemonDirectoriesResponse { + directories: DaemonDirectory[]; +} + +/** + * Get suggested directories from connected daemons. + * These can be used as target_repo_path suggestions for completion actions. + */ +export async function getDaemonDirectories(): Promise<DaemonDirectoriesResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/directories`); + if (!res.ok) { + throw new Error(`Failed to get daemon directories: ${res.statusText}`); + } + return res.json(); +} + +/** Request to clone a worktree */ +export interface CloneWorktreeRequest { + targetDir: string; +} + +/** Response from clone worktree */ +export interface CloneWorktreeResponse { + status: string; + taskId: string; + targetDir: string; +} + +/** + * Clone a task's worktree to a target directory. + */ +export async function cloneWorktree( + taskId: string, + targetDir: string +): Promise<CloneWorktreeResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/clone`, { + method: "POST", + body: JSON.stringify({ targetDir }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to clone worktree: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Request to check if target exists */ +export interface CheckTargetExistsRequest { + targetDir: string; +} + +/** Response from check target exists */ +export interface CheckTargetExistsResponse { + status: string; + taskId: string; + targetDir: string; +} + +/** + * Check if a target directory exists. + */ +export async function checkTargetExists( + taskId: string, + targetDir: string +): Promise<CheckTargetExistsResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/check-target`, { + method: "POST", + body: JSON.stringify({ targetDir }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to check target: ${errorText || res.statusText}`); + } + return res.json(); +} + +export async function listSubtasks(taskId: string): Promise<TaskListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/subtasks`); + if (!res.ok) { + throw new Error(`Failed to list subtasks: ${res.statusText}`); + } + return res.json(); +} + +export async function listTaskEvents( + taskId: string +): Promise<TaskEventListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/events`); + if (!res.ok) { + throw new Error(`Failed to list task events: ${res.statusText}`); + } + return res.json(); +} + +/** A single output entry from a Claude Code task */ +export interface TaskOutputEntry { + id: string; + taskId: string; + /** Message type: "assistant", "tool_use", "tool_result", "result", "system", "error", "raw" */ + messageType: string; + /** Main text content */ + content: string; + /** Tool name if tool_use message */ + toolName?: string; + /** Tool input JSON if tool_use message */ + toolInput?: Record<string, unknown>; + /** Whether tool result was an error */ + isError?: boolean; + /** Cost in USD if result message */ + costUsd?: number; + /** Duration in ms if result message */ + durationMs?: number; + /** Timestamp when this output was recorded */ + createdAt: string; +} + +/** Response from the task output endpoint */ +export interface TaskOutputResponse { + entries: TaskOutputEntry[]; + total: number; + taskId: string; +} + +/** + * Get task output history. + * Retrieves all recorded output from a task's Claude Code process. + * Use this to fetch missed output when subscribing late or reconnecting. + */ +export async function getTaskOutput( + taskId: string +): Promise<TaskOutputResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/output`); + if (!res.ok) { + throw new Error(`Failed to get task output: ${res.statusText}`); + } + return res.json(); +} + +export async function listDaemons(): Promise<DaemonListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons`); + if (!res.ok) { + throw new Error(`Failed to list daemons: ${res.statusText}`); + } + return res.json(); +} + +export async function getDaemon(id: string): Promise<Daemon> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/${id}`); + if (!res.ok) { + throw new Error(`Failed to get daemon: ${res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// Mesh Chat Types for Task Orchestration +// ============================================================================= + +export interface MeshChatMessage { + role: "user" | "assistant"; + content: string; +} + +export interface MeshChatRequest { + message: string; + model?: LlmModel; + history?: MeshChatMessage[]; +} + +export interface MeshToolCallInfo { + name: string; + result: { + success: boolean; + message: string; + }; +} + +export interface MeshChatResponse { + response: string; + toolCalls: MeshToolCallInfo[]; + pendingQuestions?: UserQuestion[]; +} + +// Mesh Chat API functions + +// Top-level mesh chat (no specific task context) +export async function chatWithMesh( + message: string, + model?: LlmModel, + history?: MeshChatMessage[] +): Promise<MeshChatResponse> { + const body: MeshChatRequest = { message }; + if (model) { + body.model = model; + } + if (history && history.length > 0) { + body.history = history; + } + const res = await authFetch(`${API_BASE}/api/v1/mesh/chat`, { + method: "POST", + body: JSON.stringify(body), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Mesh chat failed: ${errorText || res.statusText}`); + } + return res.json(); +} + +// Task-scoped mesh chat +export async function chatWithTask( + taskId: string, + message: string, + model?: LlmModel, + history?: MeshChatMessage[] +): Promise<MeshChatResponse> { + const body: MeshChatRequest = { message }; + if (model) { + body.model = model; + } + if (history && history.length > 0) { + body.history = history; + } + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/chat`, { + method: "POST", + body: JSON.stringify(body), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Mesh chat failed: ${errorText || res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// Mesh Chat History Types +// ============================================================================= + +export type MeshChatContextType = "mesh" | "task" | "subtask"; + +export interface MeshChatContext { + type: MeshChatContextType; + taskId?: string; + parentTaskId?: string; +} + +export interface MeshChatMessageRecord { + id: string; + conversationId: string; + role: "user" | "assistant" | "error"; + content: string; + contextType: MeshChatContextType; + contextTaskId: string | null; + toolCalls: MeshToolCallInfo[] | null; + pendingQuestions: UserQuestion[] | null; + createdAt: string; +} + +export interface MeshChatHistoryResponse { + conversationId: string; + messages: MeshChatMessageRecord[]; +} + +export interface MeshChatWithContextRequest { + message: string; + model?: LlmModel; + contextType?: MeshChatContextType; + contextTaskId?: string; +} + +// ============================================================================= +// Mesh Chat History API Functions +// ============================================================================= + +/** + * Get the current chat history from the database + */ +export async function getMeshChatHistory(): Promise<MeshChatHistoryResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/chat/history`); + if (!res.ok) { + throw new Error(`Failed to get chat history: ${res.statusText}`); + } + return res.json(); +} + +/** + * Clear chat history (archives current conversation, starts new one) + */ +export async function clearMeshChatHistory(): Promise<{ success: boolean; conversationId: string }> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/chat/history`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to clear chat history: ${res.statusText}`); + } + return res.json(); +} + +/** + * Chat with mesh using context (new approach with DB history) + */ +export async function chatWithMeshContext( + message: string, + context: MeshChatContext, + model?: LlmModel +): Promise<MeshChatResponse> { + const body: MeshChatWithContextRequest = { + message, + contextType: context.type, + }; + + if (model) { + body.model = model; + } + + // Set contextTaskId based on context type + if (context.type === "task" && context.taskId) { + body.contextTaskId = context.taskId; + } else if (context.type === "subtask" && context.taskId) { + body.contextTaskId = context.taskId; + } + + // Use top-level endpoint (it now loads history from DB) + const res = await authFetch(`${API_BASE}/api/v1/mesh/chat`, { + method: "POST", + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Mesh chat failed: ${errorText || res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// API Key Management +// ============================================================================= + +export interface ApiKeyInfo { + id: string; + prefix: string; + name: string | null; + lastUsedAt: string | null; + createdAt: string; +} + +export interface CreateApiKeyResponse { + id: string; + key: string; + prefix: string; + name: string | null; + createdAt: string; +} + +export interface RefreshApiKeyResponse { + id: string; + key: string; + prefix: string; + name: string | null; + createdAt: string; + previousKeyRevoked: boolean; +} + +export interface RevokeApiKeyResponse { + message: string; + revokedKeyPrefix: string; +} + +/** + * Get information about the current active API key. + */ +export async function getApiKey(): Promise<ApiKeyInfo | null> { + const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys`); + if (res.status === 404) { + return null; + } + if (!res.ok) { + throw new Error(`Failed to get API key: ${res.statusText}`); + } + return res.json(); +} + +/** + * Create a new API key. + */ +export async function createApiKey(name?: string): Promise<CreateApiKeyResponse> { + const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys`, { + method: "POST", + body: JSON.stringify({ name }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to create API key: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** + * Refresh (rotate) the current API key. + */ +export async function refreshApiKey(name?: string): Promise<RefreshApiKeyResponse> { + const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys/refresh`, { + method: "POST", + body: JSON.stringify({ name }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to refresh API key: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** + * Revoke the current API key. + */ +export async function revokeApiKey(): Promise<RevokeApiKeyResponse> { + const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys`, { + method: "DELETE", + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to revoke API key: ${errorText || res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// User Account Management +// ============================================================================= + +export interface ChangePasswordRequest { + currentPassword: string; + newPassword: string; +} + +export interface ChangePasswordResponse { + success: boolean; + message: string; +} + +export interface ChangeEmailRequest { + password: string; + newEmail: string; +} + +export interface ChangeEmailResponse { + success: boolean; + message: string; + verificationSent: boolean; +} + +export interface DeleteAccountRequest { + password: string; + confirmation: string; +} + +export interface DeleteAccountResponse { + success: boolean; + message: string; +} + +/** + * Change the current user's password. + * Requires current password verification. + */ +export async function changePassword( + currentPassword: string, + newPassword: string +): Promise<ChangePasswordResponse> { + const res = await authFetch(`${API_BASE}/api/v1/users/me/password`, { + method: "PUT", + body: JSON.stringify({ currentPassword, newPassword }), + }); + if (!res.ok) { + const errorData = await res.json().catch(() => null); + const errorMessage = errorData?.message || res.statusText; + throw new Error(errorMessage); + } + return res.json(); +} + +/** + * Change the current user's email address. + * Requires password verification. + */ +export async function changeEmail( + password: string, + newEmail: string +): Promise<ChangeEmailResponse> { + const res = await authFetch(`${API_BASE}/api/v1/users/me/email`, { + method: "PUT", + body: JSON.stringify({ password, newEmail }), + }); + if (!res.ok) { + const errorData = await res.json().catch(() => null); + const errorMessage = errorData?.message || res.statusText; + throw new Error(errorMessage); + } + return res.json(); +} + +/** + * Delete the current user's account. + * Requires password verification and email confirmation. + */ +export async function deleteAccount( + password: string, + confirmation: string +): Promise<DeleteAccountResponse> { + const res = await authFetch(`${API_BASE}/api/v1/users/me`, { + method: "DELETE", + body: JSON.stringify({ password, confirmation }), + }); + if (!res.ok) { + const errorData = await res.json().catch(() => null); + const errorMessage = errorData?.message || res.statusText; + throw new Error(errorMessage); + } + return res.json(); +} diff --git a/makima/frontend/src/lib/supabase.ts b/makima/frontend/src/lib/supabase.ts new file mode 100644 index 0000000..eedff10 --- /dev/null +++ b/makima/frontend/src/lib/supabase.ts @@ -0,0 +1,26 @@ +import { createClient, SupabaseClient, Session, User } from "@supabase/supabase-js"; + +// Supabase configuration from environment variables +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string | undefined; +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined; + +// Only create client if configuration is available +let supabaseClient: SupabaseClient | null = null; + +if (SUPABASE_URL && SUPABASE_ANON_KEY) { + supabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: true, + }, + }); +} + +export const supabase = supabaseClient; + +export function isAuthConfigured(): boolean { + return supabaseClient !== null; +} + +export type { Session, User }; |
