From e7d1bedab874b5ea47a468d944d7ce1b68335404 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:57:36 +0000 Subject: [PATCH 01/15] feat: add dedicated settings page replacing account dropdown menu - Create new /settings route with tab-based navigation - Add settings sections: Profile, Subscription, API Management, Team, Data & Privacy, About - Update Sidebar to show Settings button with plan badge and credit usage - Redirect team_setup/api_settings/credits_success params to settings page - Support deep linking via URL params (e.g., /settings?tab=team) - Maintain all existing functionality from AccountMenu dropdown - Responsive design with horizontal tabs on mobile, vertical sidebar on desktop Closes #380 Co-Authored-By: unknown <> --- frontend/src/components/Sidebar.tsx | 34 +- .../src/components/settings/AboutSection.tsx | 116 ++++++ .../settings/ApiManagementSection.tsx | 259 +++++++++++++ .../settings/DataPrivacySection.tsx | 116 ++++++ .../components/settings/ProfileSection.tsx | 241 ++++++++++++ .../src/components/settings/SettingsPage.tsx | 284 ++++++++++++++ .../settings/SubscriptionSection.tsx | 165 ++++++++ .../settings/TeamManagementSection.tsx | 361 ++++++++++++++++++ frontend/src/routeTree.gen.ts | 27 ++ frontend/src/routes/_auth.settings.tsx | 25 ++ frontend/src/routes/index.tsx | 63 +-- 11 files changed, 1634 insertions(+), 57 deletions(-) create mode 100644 frontend/src/components/settings/AboutSection.tsx create mode 100644 frontend/src/components/settings/ApiManagementSection.tsx create mode 100644 frontend/src/components/settings/DataPrivacySection.tsx create mode 100644 frontend/src/components/settings/ProfileSection.tsx create mode 100644 frontend/src/components/settings/SettingsPage.tsx create mode 100644 frontend/src/components/settings/SubscriptionSection.tsx create mode 100644 frontend/src/components/settings/TeamManagementSection.tsx create mode 100644 frontend/src/routes/_auth.settings.tsx diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7b4ccd19..cd0a0a25 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -5,12 +5,15 @@ import { PanelRightOpen, XCircle, Trash2, - X + X, + Settings } from "lucide-react"; import { Button } from "./ui/button"; -import { useLocation, useRouter } from "@tanstack/react-router"; +import { useLocation, useRouter, useNavigate } from "@tanstack/react-router"; import { ChatHistoryList } from "./ChatHistoryList"; -import { AccountMenu } from "./AccountMenu"; +import { CreditUsage } from "./CreditUsage"; +import { Badge } from "./ui/badge"; +import { Link } from "@tanstack/react-router"; import { useRef, useEffect, KeyboardEvent, useCallback, useLayoutEffect, useState } from "react"; import { cn, useClickOutside, useIsMobile } from "@/utils/utils"; import { Input } from "./ui/input"; @@ -267,13 +270,36 @@ export function Sidebar({ />
- +
); } +function SettingsButton() { + const navigate = useNavigate(); + const { billingStatus } = useLocalState(); + + return ( +
+ + + {billingStatus ? `${billingStatus.product_name} Plan` : "Loading..."} + + + + +
+ ); +} + export function SidebarToggle({ onToggle }: { onToggle: () => void }) { return ( + + + + + + + + {/* Built by */} +
+

+ Built by{" "} + +

+

+ Built in Austin. Living in Secure Enclaves. +

+
+ + ); +} diff --git a/frontend/src/components/settings/ApiManagementSection.tsx b/frontend/src/components/settings/ApiManagementSection.tsx new file mode 100644 index 00000000..6ec0a6da --- /dev/null +++ b/frontend/src/components/settings/ApiManagementSection.tsx @@ -0,0 +1,259 @@ +import { useState, useEffect } from "react"; +import { useOpenSecret } from "@opensecret/react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useLocalState } from "@/state/useLocalState"; +import { isTauriDesktop } from "@/utils/platform"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Plus, + Loader2, + Sparkles, + Zap, + Shield, + Rocket, + Server, + Key, + CreditCard +} from "lucide-react"; +import { CreateApiKeyDialog } from "@/components/apikeys/CreateApiKeyDialog"; +import { ApiKeysList } from "@/components/apikeys/ApiKeysList"; +import { ApiCreditsSection } from "@/components/apikeys/ApiCreditsSection"; +import { ProxyConfigSection } from "@/components/apikeys/ProxyConfigSection"; + +interface ApiKey { + name: string; + created_at: string; +} + +interface ApiManagementSectionProps { + creditsSuccess?: boolean; +} + +export function ApiManagementSection({ creditsSuccess = false }: ApiManagementSectionProps) { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [showCreditSuccess, setShowCreditSuccess] = useState(creditsSuccess); + const isTauriDesktopPlatform = isTauriDesktop(); + const { listApiKeys, auth, createApiKey } = useOpenSecret(); + const { billingStatus } = useLocalState(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + // Handle credits_success + useEffect(() => { + if (creditsSuccess) { + setShowCreditSuccess(true); + queryClient.invalidateQueries({ queryKey: ["apiCreditBalance"] }); + const timer = setTimeout(() => setShowCreditSuccess(false), 5000); + return () => clearTimeout(timer); + } + }, [creditsSuccess, queryClient]); + + // Check if user has API access + const isBillingLoading = billingStatus === null; + const productName = billingStatus?.product_name || ""; + const isPro = productName.toLowerCase().includes("pro"); + const isMax = productName.toLowerCase().includes("max"); + const isTeamPlan = productName.toLowerCase().includes("team"); + const hasApiAccess = isPro || isMax || isTeamPlan; + + // Fetch API keys + const { + data: apiKeys, + isLoading, + error, + refetch + } = useQuery({ + queryKey: ["apiKeys"], + queryFn: async () => { + const response = await listApiKeys(); + return response.keys.sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + }, + enabled: !!auth.user && !auth.loading + }); + + const handleKeyCreated = () => { + refetch(); + setIsCreateDialogOpen(false); + }; + + const handleKeyDeleted = () => { + refetch(); + }; + + const handleProxyApiKeyRequest = async (name: string): Promise => { + try { + const response = await createApiKey(name); + await refetch(); + return response.key; + } catch (error) { + console.error("Failed to create API key for proxy:", error); + throw error; + } + }; + + return ( +
+
+

API Management

+

+ Manage API keys and configure access to Maple services.{" "} + + Read more + +

+
+ + {/* Loading state */} + {(isBillingLoading || isLoading) && ( +
+ +
+ )} + + {/* Error state */} + {error && !isLoading && ( +
Failed to load API keys. Please try again.
+ )} + + {/* Upgrade prompt for users without API access */} + {!isBillingLoading && !isLoading && !hasApiAccess && ( + +
+
+ + Unlock API Access +
+

+ Upgrade to a paid plan to access powerful API features +

+
+
+
+ +
+
+

Programmatic Access

+

+ Integrate Maple directly into your applications +

+
+
+
+
+ +
+
+

Secure API Keys

+

+ Create and manage multiple API keys with granular control +

+
+
+
+
+ +
+
+

Extend Your Subscription

+

+ Purchase extra credits to extend your usage +

+
+
+
+
+

+ Starting at just $20/month{" "} + with the Pro plan +

+ +
+
+
+ )} + + {/* Full API dashboard for users with access */} + {!isBillingLoading && !isLoading && hasApiAccess && !error && ( + + + + + Credits + + + + API Keys + + {isTauriDesktopPlatform && ( + + + Local Proxy + + )} + + + +
+ +
+
+ + +
+
+

API Keys

+ + {apiKeys && apiKeys.length > 0 && ( + + )} +
+

API keys allow you to integrate Maple into your applications and workflows.

+

+ Keep your API keys secure and never share them publicly. Treat them like + passwords. +

+
+
+
+
+ + {isTauriDesktopPlatform && ( + +
+ +
+
+ )} +
+ )} + + {/* Create dialog */} + +
+ ); +} diff --git a/frontend/src/components/settings/DataPrivacySection.tsx b/frontend/src/components/settings/DataPrivacySection.tsx new file mode 100644 index 00000000..4017751c --- /dev/null +++ b/frontend/src/components/settings/DataPrivacySection.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useOpenSecret } from "@opensecret/react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from "@/components/ui/alert-dialog"; +import { Trash2, AlertTriangle } from "lucide-react"; +import { useLocalState } from "@/state/useLocalState"; + +export function DataPrivacySection() { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const { clearHistory } = useLocalState(); + const os = useOpenSecret(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + async function handleDeleteHistory() { + // 1. Delete archived chats (KV) + try { + await clearHistory(); + console.log("History (KV) cleared"); + } catch (error) { + console.error("Error clearing history:", error); + } + + // 2. Delete server conversations (API) + try { + const conversations = await os.listConversations({ limit: 1 }); + if (conversations.data && conversations.data.length > 0) { + await os.deleteConversations(); + console.log("Server conversations deleted"); + } + } catch (e) { + console.error("Error deleting conversations:", e); + } + + // Refresh UI + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + queryClient.invalidateQueries({ queryKey: ["conversations"] }); + queryClient.invalidateQueries({ queryKey: ["archivedChats"] }); + setIsDeleteDialogOpen(false); + navigate({ to: "/" }); + } + + return ( +
+
+

Data & Privacy

+

Manage your data and privacy settings.

+
+ + {/* Privacy Info */} +
+

Your Privacy

+
+

+ Maple uses end-to-end encryption for all your conversations. Your data is encrypted on + your device and only decrypted inside secure enclaves. +

+

+ No one — not even Maple — can access your plaintext data. Your conversations are never + used for training AI models. +

+
+
+ + {/* Delete History */} +
+

Danger Zone

+
+

+ Permanently delete your entire chat history. This includes all conversations stored + locally and on the server. This action cannot be undone. +

+ +
+
+ + {/* Confirm Dialog */} + + + + + + Are you sure? + + + This will permanently delete your entire chat history, including all conversations + stored locally and on the server. This action cannot be undone. + + + + Cancel + Delete All History + + + +
+ ); +} diff --git a/frontend/src/components/settings/ProfileSection.tsx b/frontend/src/components/settings/ProfileSection.tsx new file mode 100644 index 00000000..11d475a8 --- /dev/null +++ b/frontend/src/components/settings/ProfileSection.tsx @@ -0,0 +1,241 @@ +import { useState, useEffect } from "react"; +import { useOpenSecret } from "@opensecret/react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { CheckCircle, XCircle, Trash, KeyRound, Save, Loader2 } from "lucide-react"; +import { ChangePasswordDialog } from "@/components/ChangePasswordDialog"; +import { DeleteAccountDialog } from "@/components/DeleteAccountDialog"; + +export function ProfileSection() { + const os = useOpenSecret(); + const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); + const [isDeleteAccountOpen, setIsDeleteAccountOpen] = useState(false); + const [verificationStatus, setVerificationStatus] = useState<"unverified" | "pending">( + "unverified" + ); + + // Preferences state + const [prompt, setPrompt] = useState(""); + const [instructionId, setInstructionId] = useState(null); + const [isLoadingPrefs, setIsLoadingPrefs] = useState(true); + const [isSavingPrefs, setIsSavingPrefs] = useState(false); + const [prefsError, setPrefsError] = useState(null); + const [prefsSuccess, setPrefsSuccess] = useState(false); + + const isEmailUser = os.auth.user?.user.login_method === "email"; + const isGuestUser = os.auth.user?.user.login_method?.toLowerCase() === "guest"; + + // Load preferences on mount + useEffect(() => { + loadPreferences(); + }, []); + + const loadPreferences = async () => { + setIsLoadingPrefs(true); + setPrefsError(null); + try { + const response = await os.listInstructions({ limit: 100 }); + const defaultInstruction = response.data.find((inst) => inst.is_default); + if (defaultInstruction) { + setInstructionId(defaultInstruction.id); + setPrompt(defaultInstruction.prompt); + } else { + setInstructionId(null); + setPrompt(""); + } + } catch (error) { + console.error("Failed to load preferences:", error); + setPrefsError("Failed to load preferences. Please try again."); + } finally { + setIsLoadingPrefs(false); + } + }; + + const handleSavePreferences = async (e: React.FormEvent) => { + e.preventDefault(); + setPrefsError(null); + setPrefsSuccess(false); + setIsSavingPrefs(true); + + try { + if (instructionId) { + if (prompt.trim() === "") { + await os.deleteInstruction(instructionId); + setInstructionId(null); + setPrompt(""); + } else { + await os.updateInstruction(instructionId, { prompt }); + } + } else { + if (prompt.trim() !== "") { + const newInstruction = await os.createInstruction({ + name: "User Preferences", + prompt, + is_default: true + }); + setInstructionId(newInstruction.id); + } + } + setPrefsSuccess(true); + } catch (error) { + console.error("Failed to save preferences:", error); + setPrefsError("Failed to save preferences. Please try again."); + } finally { + setIsSavingPrefs(false); + } + }; + + const handleResendVerification = async () => { + try { + await os.requestNewVerificationEmail(); + setVerificationStatus("pending"); + } catch (error) { + console.error("Failed to resend verification email:", error); + } + }; + + return ( +
+
+

Profile

+

+ Manage your account information and preferences. +

+
+ + {/* Email Section */} + {!isGuestUser && ( +
+

Email

+
+ +
+ + {os.auth.user?.user.email_verified ? ( + + ) : ( + + )} +
+ {!os.auth.user?.user.email_verified && ( +

+ {verificationStatus === "unverified" ? ( + <> + Your email is not verified.{" "} + + + ) : ( + "Verification email sent. Check your inbox." + )} +

+ )} +
+
+ )} + + {/* Password Section */} + {(isEmailUser || isGuestUser) && ( +
+

Password

+

+ Update your password to keep your account secure. +

+ +
+ )} + + {/* User Preferences (System Prompt) */} +
+

User Preferences

+

+ Customize your default system prompt for AI conversations. +

+
+ {prefsError && ( + + {prefsError} + + )} + {prefsSuccess && ( + + Preferences saved successfully. + + )} +