From 8b17a175c3e7e27b789812eba4e3cd760beadb10 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 6 Jan 2026 04:08:11 +0000 Subject: Initial Control system --- makima/frontend/src/routes/settings.tsx | 724 ++++++++++++++++++++++++++++++++ 1 file changed, 724 insertions(+) create mode 100644 makima/frontend/src/routes/settings.tsx (limited to 'makima/frontend/src/routes/settings.tsx') 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 ( +
+
+

{title}

+

{message}

+ {requireInput && ( +
+ + 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]" + /> +
+ )} +
+ + +
+
+
+ ); +} + +// ============================================================================= +// Section Header Component +// ============================================================================= + +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// ============================================================================= +// 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 ( +
+ + 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" + /> +
+ ); +} + +// ============================================================================= +// Alert Components +// ============================================================================= + +function ErrorAlert({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function SuccessAlert({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ============================================================================= +// Button Components +// ============================================================================= + +function PrimaryButton({ + children, + onClick, + disabled, + type = "button", +}: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + type?: "button" | "submit"; +}) { + return ( + + ); +} + +function SecondaryButton({ + children, + onClick, + disabled, +}: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +function DangerButton({ + children, + onClick, + disabled, +}: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ============================================================================= +// Main Settings Page +// ============================================================================= + +export default function SettingsPage() { + const { user, isAuthConfigured, signOut } = useAuth(); + const navigate = useNavigate(); + + // API Key state + const [apiKeyInfo, setApiKeyInfo] = useState(null); + const [newKey, setNewKey] = useState(null); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [error, setError] = useState(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(null); + const [passwordSuccess, setPasswordSuccess] = useState(null); + + // Email change state + const [emailForm, setEmailForm] = useState({ + password: "", + newEmail: "", + }); + const [emailLoading, setEmailLoading] = useState(false); + const [emailError, setEmailError] = useState(null); + const [emailSuccess, setEmailSuccess] = useState(null); + + // Account deletion state + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletePassword, setDeletePassword] = useState(""); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteError, setDeleteError] = useState(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 ( +
+ + +
+ {/* Page Header */} +
+

Settings

+
+
+ +
+ {/* Left Column */} +
+ {/* Account Info */} +
+ Account + {isAuthConfigured && user ? ( +
+
+ Email + {user.email} +
+
+ User ID + {user.id} +
+
+ ) : ( +

+ {isAuthConfigured + ? "Not signed in" + : "Authentication not configured (API key mode)"} +

+ )} +
+ + {/* API Key Section */} +
+ API Key +

+ Authenticate daemon and CLI tools. One active key at a time. +

+ + {error && {error}} + + {newKey && ( +
+

+ Key created. Copy now - won't be shown again. +

+
+ + {newKey} + + +
+
+ )} + + {loading ? ( +

Loading...

+ ) : apiKeyInfo ? ( +
+
+
+ Prefix + {apiKeyInfo.prefix}... +
+
+ Created + + {new Date(apiKeyInfo.createdAt).toLocaleDateString()} + +
+ {apiKeyInfo.lastUsedAt && ( +
+ Last used + + {new Date(apiKeyInfo.lastUsedAt).toLocaleDateString()} + +
+ )} +
+
+ + {actionLoading ? "..." : "Rotate"} + + + {actionLoading ? "..." : "Revoke"} + +
+
+ ) : ( +
+

No API key configured.

+ + {actionLoading ? "Creating..." : "Create API Key"} + +
+ )} +
+ + {/* Daemon Setup */} +
+ Daemon Setup +

+ Set your API key as an environment variable: +

+ + export MAKIMA_API_KEY="your-key" + +

+ Then run: makima-daemon +

+
+
+ + {/* Right Column */} +
+ {/* Password Change */} + {isAuthConfigured && user && ( +
+ Change Password + {passwordError && {passwordError}} + {passwordSuccess && {passwordSuccess}} +
+ setPasswordForm({ ...passwordForm, currentPassword: v })} + required + /> + setPasswordForm({ ...passwordForm, newPassword: v })} + required + /> + {passwordForm.newPassword && ( +
+
+
+
+
+ + {passwordStrength.label} + +
+
+ )} + setPasswordForm({ ...passwordForm, confirmPassword: v })} + required + /> + {passwordForm.confirmPassword && + passwordForm.newPassword !== passwordForm.confirmPassword && ( +

Passwords do not match

+ )} +
+ + {passwordLoading ? "Changing..." : "Change Password"} + +
+ +
+ )} + + {/* Email Change */} + {isAuthConfigured && user && ( +
+ Change Email + {emailError && {emailError}} + {emailSuccess && {emailSuccess}} +
+ setEmailForm({ ...emailForm, newEmail: v })} + placeholder="new@example.com" + required + /> + setEmailForm({ ...emailForm, password: v })} + required + /> +
+ + {emailLoading ? "Changing..." : "Change Email"} + +
+ +
+ )} + + {/* Danger Zone */} + {isAuthConfigured && user && ( +
+

+ Danger Zone +

+

+ Permanently delete your account and all data. This cannot be undone. +

+ {deleteError && {deleteError}} +
+ + setDeleteDialogOpen(true)} + disabled={!deletePassword || deleteLoading} + > + {deleteLoading ? "Deleting..." : "Delete Account"} + +
+
+ )} +
+
+
+ + {/* Delete Confirmation Dialog */} + { + setDeleteDialogOpen(false); + handleDeleteAccount(); + }} + onCancel={() => setDeleteDialogOpen(false)} + /> +
+ ); +} -- cgit v1.2.3