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/settings.tsx | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src/routes/settings.tsx')
| -rw-r--r-- | makima/frontend/src/routes/settings.tsx | 724 |
1 files changed, 724 insertions, 0 deletions
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> + ); +} |
