summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/settings.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/settings.tsx')
-rw-r--r--makima/frontend/src/routes/settings.tsx724
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>
+ );
+}