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,
listRepositoryHistory,
deleteRepositoryHistory,
type ApiKeyInfo,
type CreateApiKeyResponse,
type RepositoryHistoryEntry,
} 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);
// Repository history state
const [repoHistory, setRepoHistory] = useState<RepositoryHistoryEntry[]>([]);
const [repoHistoryLoading, setRepoHistoryLoading] = useState(true);
const [repoHistoryError, setRepoHistoryError] = useState<string | null>(null);
const [deletingRepoId, setDeletingRepoId] = useState<string | null>(null);
useEffect(() => {
loadApiKey();
loadRepoHistory();
}, []);
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 loadRepoHistory = async () => {
try {
setRepoHistoryError(null);
const response = await listRepositoryHistory();
setRepoHistory(response.entries);
} catch (err) {
setRepoHistoryError(err instanceof Error ? err.message : "Failed to load repository history");
} finally {
setRepoHistoryLoading(false);
}
};
const handleDeleteRepoHistory = async (id: string) => {
try {
setDeletingRepoId(id);
await deleteRepositoryHistory(id);
setRepoHistory((prev) => prev.filter((entry) => entry.id !== id));
} catch (err) {
setRepoHistoryError(err instanceof Error ? err.message : "Failed to delete entry");
} finally {
setDeletingRepoId(null);
}
};
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>
{/* Daemons Link */}
<section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
<SectionHeader>Daemons</SectionHeader>
<p className="text-[#7788aa] font-mono text-[10px] mb-3">
Daemon management has moved to its own page.
</p>
<a href="/daemons" className="text-[#75aafc] hover:text-[#9bc3ff] text-xs font-mono">
Go to Daemons →
</a>
</section>
{/* Repository History */}
<section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
<div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
<div className="flex items-center gap-2">
<h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]">
Repository History
</h2>
{repoHistory.length > 0 && (
<span className="text-[10px] font-mono text-[#556677]">
({repoHistory.length} saved)
</span>
)}
</div>
<button
onClick={loadRepoHistory}
disabled={repoHistoryLoading}
className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50"
title="Refresh"
>
{repoHistoryLoading ? "..." : "↻"}
</button>
</div>
<p className="text-[#7788aa] font-mono text-[10px] mb-3">
Previously used repositories will appear as suggestions when adding repos to contracts.
</p>
{repoHistoryError && <ErrorAlert>{repoHistoryError}</ErrorAlert>}
{repoHistoryLoading && repoHistory.length === 0 ? (
<p className="text-[#7788aa] font-mono text-xs">Loading...</p>
) : repoHistory.length === 0 ? (
<div className="text-center py-4">
<p className="text-[#7788aa] font-mono text-xs mb-2">No repository history</p>
<p className="text-[#556677] font-mono text-[10px]">
Add repositories to contracts to build history
</p>
</div>
) : (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{repoHistory.map((entry) => (
<div
key={entry.id}
className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs text-[#9bc3ff] truncate">
{entry.name}
</span>
<span
className={`text-[10px] font-mono uppercase px-1.5 py-0.5 border ${
entry.sourceType === "remote"
? "text-cyan-400 border-cyan-700/50 bg-cyan-900/20"
: "text-green-400 border-green-700/50 bg-green-900/20"
}`}
>
{entry.sourceType}
</span>
</div>
<div className="font-mono text-[10px] text-[#556677] truncate mb-1">
{entry.repositoryUrl || entry.localPath}
</div>
<div className="flex items-center gap-3 font-mono text-[10px] text-[#7788aa]">
<span>Used {entry.useCount}×</span>
<span>
Last: {new Date(entry.lastUsedAt).toLocaleDateString()}
</span>
</div>
</div>
<button
onClick={() => handleDeleteRepoHistory(entry.id)}
disabled={deletingRepoId === entry.id}
className="p-1 font-mono text-[10px] text-[#555] hover:text-red-400 transition-colors disabled:opacity-50"
title="Delete from history"
>
{deletingRepoId === entry.id ? "..." : "×"}
</button>
</div>
</div>
))}
</div>
)}
</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>
);
}