From 1928386de6d0e7d5aceb16e6196bff20242be411 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 9 Jan 2026 14:08:33 -0600 Subject: [PATCH 1/2] refactor: add settings page and move account menu flows (#380) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- frontend/src/components/AccountMenu.tsx | 419 +---------- .../components/apikeys/ApiCreditsSection.tsx | 4 +- frontend/src/routeTree.gen.ts | 27 + frontend/src/routes/_auth.settings.tsx | 711 ++++++++++++++++++ frontend/src/routes/index.tsx | 61 +- frontend/src/routes/payment-success.tsx | 2 +- frontend/src/routes/pricing.tsx | 4 +- 7 files changed, 782 insertions(+), 446 deletions(-) create mode 100644 frontend/src/routes/_auth.settings.tsx diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index a8bdb323..e733aff0 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -1,123 +1,20 @@ -import { - LogOut, - Trash, - User, - CreditCard, - ArrowUpCircle, - Mail, - Users, - AlertCircle, - Key, - Info, - Shield, - FileText, - ChevronLeft -} from "lucide-react"; -import { isMobile, isTauri } from "@/utils/platform"; - -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from "@/components/ui/dropdown-menu"; +import { AlertCircle, Settings } from "lucide-react"; import { useOpenSecret } from "@opensecret/react"; -import { useNavigate, useRouter } from "@tanstack/react-router"; -import { Dialog, DialogTrigger } from "./ui/dialog"; -import { AccountDialog } from "./AccountDialog"; -import { CreditUsage } from "./CreditUsage"; -import { Badge } from "./ui/badge"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger -} from "@/components/ui/alert-dialog"; -import { useLocalState } from "@/state/useLocalState"; -import { useQueryClient, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; + import { getBillingService } from "@/billing/billingService"; -import { useState } from "react"; +import { CreditUsage } from "@/components/CreditUsage"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useLocalState } from "@/state/useLocalState"; import type { TeamStatus } from "@/types/team"; -import { TeamManagementDialog } from "@/components/team/TeamManagementDialog"; -import { ApiKeyManagementDialog } from "@/components/apikeys/ApiKeyManagementDialog"; - -function ConfirmDeleteDialog() { - 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); - // Continue to delete server conversations even if this fails - } - - // 2. Delete server conversations (API) if any exist - try { - // Check if we have any conversations to delete - 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); - } - - // Always refresh UI and navigate home - queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); - queryClient.invalidateQueries({ queryKey: ["conversations"] }); - queryClient.invalidateQueries({ queryKey: ["archivedChats"] }); - navigate({ to: "/" }); - } - - return ( - - - Are you sure? - This will delete your entire chat history. - - - Cancel - Delete - - - ); -} export function AccountMenu() { const os = useOpenSecret(); - const router = useRouter(); const { billingStatus } = useLocalState(); - const [isPortalLoading, setIsPortalLoading] = useState(false); - const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false); - const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false); - const [showAboutMenu, setShowAboutMenu] = useState(false); - - const hasStripeAccount = billingStatus?.stripe_customer_id !== null; const productName = billingStatus?.product_name || ""; - const isPro = productName.toLowerCase().includes("pro"); - const isMax = productName.toLowerCase().includes("max"); - const isStarter = productName.toLowerCase().includes("starter"); const isTeamPlan = productName.toLowerCase().includes("team"); - const showUpgrade = !isMax && !isTeamPlan; - const showManage = (isPro || isMax || isStarter || isTeamPlan) && hasStripeAccount; // Fetch team status if user has team plan const { data: teamStatus } = useQuery({ @@ -133,286 +30,30 @@ export function AccountMenu() { const showTeamSetupAlert = isTeamPlan && teamStatus?.has_team_subscription && !teamStatus?.team_created; - const handleManageSubscription = async () => { - if (!hasStripeAccount) return; - - try { - setIsPortalLoading(true); - const billingService = getBillingService(); - const url = await billingService.getPortalUrl(); - - // Check if we're on a mobile platform - if (isMobile()) { - console.log( - "[Billing] Mobile platform detected, using opener plugin to launch external browser for portal" - ); - - const { invoke } = await import("@tauri-apps/api/core"); - - // Use the opener plugin directly - with NO fallback for mobile platforms - await invoke("plugin:opener|open_url", { url }) - .then(() => { - console.log("[Billing] Successfully opened portal URL in external browser"); - }) - .catch((err: Error) => { - console.error("[Billing] Failed to open external browser:", err); - alert("Failed to open browser. Please try again."); - }); - - // Add a small delay to ensure the browser has time to open - await new Promise((resolve) => setTimeout(resolve, 300)); - return; - } - - // Default browser opening for non-mobile platforms - window.open(url, "_blank"); - } catch (error) { - console.error("Error fetching portal URL:", error); - } finally { - setIsPortalLoading(false); - } - }; - - const handleOpenExternalUrl = async (url: string) => { - try { - // Check if we're on any Tauri platform (mobile or desktop) - const isInTauri = isTauri(); - - if (isInTauri) { - const { invoke } = await import("@tauri-apps/api/core"); - await invoke("plugin:opener|open_url", { url }) - .then(() => { - console.log("[External Link] Successfully opened URL with Tauri opener"); - }) - .catch((err: Error) => { - console.error("[External Link] Failed to open with Tauri opener:", err); - // Fallback to window.open on desktop (may work), alert on mobile - if (isMobile()) { - alert("Failed to open link. Please try again."); - } else { - window.open(url, "_blank", "noopener,noreferrer"); - } - }); - } else { - // Default browser opening for web platform - window.open(url, "_blank", "noopener,noreferrer"); - } - } catch (error) { - console.error("Error opening external URL:", error); - // Fallback to window.open - window.open(url, "_blank", "noopener,noreferrer"); - } - }; - - async function signOut() { - try { - // Try to clear billing token first - try { - getBillingService().clearToken(); - } catch (error) { - console.error("Error clearing billing token:", error); - // Fallback to direct session storage removal if billing service fails - sessionStorage.removeItem("maple_billing_token"); - } - - // Sign out from OpenSecret - await os.signOut(); - - // Navigate after everything is done - await router.invalidate(); - await router.navigate({ to: "/" }); - } catch (error) { - console.error("Error during sign out:", error); - // Force reload as last resort - window.location.href = "/"; - } - } + const settingsSearch = showTeamSetupAlert + ? ({ tab: "team", team_setup: true } as const) + : ({ tab: "account" } as const); return ( - - - !open && setShowAboutMenu(false)}> -
- - - {billingStatus ? `${billingStatus.product_name} Plan` : "Loading..."} - - - - - - -
- -
-
- {teamStatus?.team_name || "Maple AI"} - - - - - - Profile - - - {showUpgrade && ( - - - - Upgrade your plan - - - )} - {showManage && ( - - - {isPortalLoading ? "Loading..." : "Manage Subscription"} - - )} - {isTeamPlan && ( - setIsTeamDialogOpen(true)}> -
-
- - Manage Team -
- {showTeamSetupAlert && ( - - Setup Required - - )} -
-
- )} - {!isMobile() && ( - setIsApiKeyDialogOpen(true)}> - - API Management - - )} - - - - Delete History - - -
- - - { - e.preventDefault(); - setShowAboutMenu(true); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - About Us - - - - - - Log out - -
- - {/* About Us Submenu */} -
- - { - e.preventDefault(); - setShowAboutMenu(false); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - Back - - - - - - - - About Maple - - - { - e.preventDefault(); - handleOpenExternalUrl("https://opensecret.cloud/privacy"); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - Privacy Policy - - { - e.preventDefault(); - handleOpenExternalUrl("https://opensecret.cloud/terms"); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - Terms of Service - - { - e.preventDefault(); - handleOpenExternalUrl("mailto:support@opensecret.cloud"); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - Contact Us - - -
-
-
- - - - -
-
-
+
+ + + {billingStatus ? `${billingStatus.product_name} Plan` : "Loading..."} + + + + +
); } diff --git a/frontend/src/components/apikeys/ApiCreditsSection.tsx b/frontend/src/components/apikeys/ApiCreditsSection.tsx index a5438931..abbdc54a 100644 --- a/frontend/src/components/apikeys/ApiCreditsSection.tsx +++ b/frontend/src/components/apikeys/ApiCreditsSection.tsx @@ -108,8 +108,8 @@ export function ApiCreditsSection({ showSuccessMessage = false }: ApiCreditsSect } else { // For web or desktop, use regular URLs with query params const baseUrl = window.location.origin; - successUrl = `${baseUrl}/?credits_success=true`; - cancelUrl = method === "stripe" ? `${baseUrl}/` : undefined; + successUrl = `${baseUrl}/settings?tab=api&credits_success=true`; + cancelUrl = method === "stripe" ? `${baseUrl}/settings?tab=api` : undefined; } if (method === "stripe") { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index be5a585d..b203557a 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as AuthImport } from './routes/_auth' import { Route as IndexImport } from './routes/index' import { Route as VerifyCodeImport } from './routes/verify.$code' import { Route as PasswordResetConfirmImport } from './routes/password-reset.confirm' +import { Route as AuthSettingsImport } from './routes/_auth.settings' import { Route as TeamInviteInviteIdImport } from './routes/team.invite.$inviteId' import { Route as AuthProviderCallbackImport } from './routes/auth.$provider.callback' import { Route as AuthChatChatIdImport } from './routes/_auth.chat.$chatId' @@ -128,6 +129,12 @@ const PasswordResetConfirmRoute = PasswordResetConfirmImport.update({ getParentRoute: () => PasswordResetRoute, } as any) +const AuthSettingsRoute = AuthSettingsImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AuthRoute, +} as any) + const TeamInviteInviteIdRoute = TeamInviteInviteIdImport.update({ id: '/team/invite/$inviteId', path: '/team/invite/$inviteId', @@ -248,6 +255,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TeamsImport parentRoute: typeof rootRoute } + '/_auth/settings': { + id: '/_auth/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AuthSettingsImport + parentRoute: typeof AuthImport + } '/password-reset/confirm': { id: '/password-reset/confirm' path: '/confirm' @@ -289,10 +303,12 @@ declare module '@tanstack/react-router' { // Create and export the route tree interface AuthRouteChildren { + AuthSettingsRoute: typeof AuthSettingsRoute AuthChatChatIdRoute: typeof AuthChatChatIdRoute } const AuthRouteChildren: AuthRouteChildren = { + AuthSettingsRoute: AuthSettingsRoute, AuthChatChatIdRoute: AuthChatChatIdRoute, } @@ -325,6 +341,7 @@ export interface FileRoutesByFullPath { '/redeem': typeof RedeemRoute '/signup': typeof SignupRoute '/teams': typeof TeamsRoute + '/settings': typeof AuthSettingsRoute '/password-reset/confirm': typeof PasswordResetConfirmRoute '/verify/$code': typeof VerifyCodeRoute '/chat/$chatId': typeof AuthChatChatIdRoute @@ -347,6 +364,7 @@ export interface FileRoutesByTo { '/redeem': typeof RedeemRoute '/signup': typeof SignupRoute '/teams': typeof TeamsRoute + '/settings': typeof AuthSettingsRoute '/password-reset/confirm': typeof PasswordResetConfirmRoute '/verify/$code': typeof VerifyCodeRoute '/chat/$chatId': typeof AuthChatChatIdRoute @@ -370,6 +388,7 @@ export interface FileRoutesById { '/redeem': typeof RedeemRoute '/signup': typeof SignupRoute '/teams': typeof TeamsRoute + '/_auth/settings': typeof AuthSettingsRoute '/password-reset/confirm': typeof PasswordResetConfirmRoute '/verify/$code': typeof VerifyCodeRoute '/_auth/chat/$chatId': typeof AuthChatChatIdRoute @@ -394,6 +413,7 @@ export interface FileRouteTypes { | '/redeem' | '/signup' | '/teams' + | '/settings' | '/password-reset/confirm' | '/verify/$code' | '/chat/$chatId' @@ -415,6 +435,7 @@ export interface FileRouteTypes { | '/redeem' | '/signup' | '/teams' + | '/settings' | '/password-reset/confirm' | '/verify/$code' | '/chat/$chatId' @@ -436,6 +457,7 @@ export interface FileRouteTypes { | '/redeem' | '/signup' | '/teams' + | '/_auth/settings' | '/password-reset/confirm' | '/verify/$code' | '/_auth/chat/$chatId' @@ -519,6 +541,7 @@ export const routeTree = rootRoute "/_auth": { "filePath": "_auth.tsx", "children": [ + "/_auth/settings", "/_auth/chat/$chatId" ] }, @@ -561,6 +584,10 @@ export const routeTree = rootRoute "/teams": { "filePath": "teams.tsx" }, + "/_auth/settings": { + "filePath": "_auth.settings.tsx", + "parent": "/_auth" + }, "/password-reset/confirm": { "filePath": "password-reset.confirm.tsx", "parent": "/password-reset" diff --git a/frontend/src/routes/_auth.settings.tsx b/frontend/src/routes/_auth.settings.tsx new file mode 100644 index 00000000..fdd42ea5 --- /dev/null +++ b/frontend/src/routes/_auth.settings.tsx @@ -0,0 +1,711 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createFileRoute, Link, useNavigate, useRouter } from "@tanstack/react-router"; +import { useOpenSecret } from "@opensecret/react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeft, + CheckCircle, + CreditCard, + FileText, + Info, + Key, + LogOut, + Mail, + Shield, + Trash, + User, + Users +} from "lucide-react"; + +import { Sidebar, SidebarToggle } from "@/components/Sidebar"; +import { ApiKeyDashboard } from "@/components/apikeys/ApiKeyDashboard"; +import { ChangePasswordDialog } from "@/components/ChangePasswordDialog"; +import { DeleteAccountDialog } from "@/components/DeleteAccountDialog"; +import { PreferencesDialog } from "@/components/PreferencesDialog"; +import { TeamDashboard } from "@/components/team/TeamDashboard"; +import { TeamSetupDialog } from "@/components/team/TeamSetupDialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { getBillingService } from "@/billing/billingService"; +import { useLocalState } from "@/state/useLocalState"; +import type { TeamStatus } from "@/types/team"; +import { cn, useIsMobile } from "@/utils/utils"; +import { isMobile, isTauri } from "@/utils/platform"; +import { openExternalUrlWithConfirmation } from "@/utils/openUrl"; + +const SETTINGS_TABS = ["account", "billing", "team", "api", "history", "about"] as const; +type SettingsTab = (typeof SETTINGS_TABS)[number]; + +type SettingsSearchParams = { + tab?: SettingsTab; + team_setup?: boolean; + credits_success?: boolean; +}; + +function parseSettingsTab(value: unknown): SettingsTab | undefined { + if (typeof value !== "string") return undefined; + return (SETTINGS_TABS as readonly string[]).includes(value) ? (value as SettingsTab) : undefined; +} + +function validateSearch(search: Record): SettingsSearchParams { + return { + tab: parseSettingsTab(search.tab), + team_setup: search?.team_setup === true || search?.team_setup === "true" ? true : undefined, + credits_success: + search?.credits_success === true || search?.credits_success === "true" ? true : undefined + }; +} + +export const Route = createFileRoute("/_auth/settings")({ + component: SettingsPage, + validateSearch +}); + +function SettingsPage() { + const os = useOpenSecret(); + const router = useRouter(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const isMobileViewport = useIsMobile(); + const { setBillingStatus, billingStatus } = useLocalState(); + + const { tab, team_setup, credits_success } = Route.useSearch(); + const [isSidebarOpen, setIsSidebarOpen] = useState(!isMobileViewport); + const [showApiCreditSuccessMessage, setShowApiCreditSuccessMessage] = useState(false); + const [autoOpenTeamSetup, setAutoOpenTeamSetup] = useState(false); + + const toggleSidebar = useCallback(() => setIsSidebarOpen((prev) => !prev), []); + + // Proactively fetch billing status for authenticated users + useQuery({ + queryKey: ["billingStatus"], + queryFn: async () => { + const billingService = getBillingService(); + const status = await billingService.getBillingStatus(); + setBillingStatus(status); + return status; + }, + enabled: !!os.auth.user + }); + + const productName = billingStatus?.product_name || ""; + const isTeamPlan = productName.toLowerCase().includes("team"); + + const { data: teamStatus } = useQuery({ + queryKey: ["teamStatus"], + queryFn: async () => { + const billingService = getBillingService(); + return await billingService.getTeamStatus(); + }, + enabled: isTeamPlan && !!os.auth.user && !!billingStatus + }); + + const effectiveTab: SettingsTab = useMemo(() => { + if (team_setup) return "team"; + if (credits_success) return "api"; + return tab ?? "account"; + }, [tab, team_setup, credits_success]); + + // Support legacy/team flow query params by routing into the appropriate settings section. + useEffect(() => { + if (!team_setup || !os.auth.user) return; + setAutoOpenTeamSetup(true); + navigate({ to: "/settings", search: { tab: "team" }, replace: true }); + }, [team_setup, os.auth.user, navigate]); + + useEffect(() => { + if (!credits_success || !os.auth.user) return; + + setShowApiCreditSuccessMessage(true); + queryClient.invalidateQueries({ queryKey: ["apiCreditBalance"] }); + navigate({ to: "/settings", search: { tab: "api" }, replace: true }); + + // Let ApiCreditsSection manage its own 5s hide timer; we just prevent re-show on remounts. + const timer = setTimeout(() => setShowApiCreditSuccessMessage(false), 6000); + return () => clearTimeout(timer); + }, [credits_success, os.auth.user, navigate, queryClient]); + + const showTeamSetupAlert = + isTeamPlan && teamStatus?.has_team_subscription && teamStatus?.team_created === false; + + const isMobilePlatform = isMobile(); + + const signOut = useCallback(async () => { + try { + try { + getBillingService().clearToken(); + } catch (error) { + console.error("Error clearing billing token:", error); + sessionStorage.removeItem("maple_billing_token"); + } + + await os.signOut(); + await router.invalidate(); + await router.navigate({ to: "/" }); + } catch (error) { + console.error("Error during sign out:", error); + window.location.href = "/"; + } + }, [os, router]); + + const navItems: Array<{ + tab: SettingsTab; + label: string; + icon: React.ComponentType<{ className?: string }>; + hidden?: boolean; + badge?: React.ReactNode; + }> = [ + { tab: "account", label: "Account", icon: User }, + { tab: "billing", label: "Billing", icon: CreditCard }, + { + tab: "team", + label: "Team", + icon: Users, + badge: showTeamSetupAlert ? ( + + Setup + + ) : undefined + }, + { tab: "api", label: "API", icon: Key, hidden: isMobilePlatform }, + { tab: "history", label: "History", icon: Trash }, + { tab: "about", label: "About", icon: Info } + ]; + + return ( +
+ + +
+ {!isSidebarOpen && ( +
+ +
+ )} + +
+
+ + +

Settings

+
+ +
+ +
+
+ +
+
+
+ + +
+ {effectiveTab === "account" && } + {effectiveTab === "billing" && ( + + )} + {effectiveTab === "team" && ( + + )} + {effectiveTab === "api" && ( + + )} + {effectiveTab === "history" && } + {effectiveTab === "about" && } +
+
+
+
+
+
+ ); +} + +function AccountSection({ onSignOut }: { onSignOut: () => Promise }) { + const os = useOpenSecret(); + const { billingStatus } = useLocalState(); + const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); + const [isPreferencesOpen, setIsPreferencesOpen] = useState(false); + const [isDeleteAccountOpen, setIsDeleteAccountOpen] = useState(false); + const [verificationStatus, setVerificationStatus] = useState<"unverified" | "pending">( + "unverified" + ); + + const isGuestUser = os.auth.user?.user.login_method?.toLowerCase() === "guest"; + const isEmailUser = os.auth.user?.user.login_method === "email"; + const canChangePassword = isEmailUser || isGuestUser; + + const handleResendVerification = async () => { + try { + await os.requestNewVerificationEmail(); + setVerificationStatus("pending"); + } catch (error) { + console.error("Failed to resend verification email:", error); + } + }; + + return ( +
+ + + Profile + Your account details and login status. + + + {!isGuestUser && ( +
+ +
+ + {os.auth.user?.user.email_verified ? ( + + ) : ( + Unverified + )} +
+ {!os.auth.user?.user.email_verified && ( +
+ {verificationStatus === "unverified" ? ( + + ) : ( + "Pending — check your inbox" + )} +
+ )} +
+ )} + +
+ +
+ + {billingStatus ? `${billingStatus.product_name} Plan` : "Loading..."} + +
+
+
+
+ + + + Security + Password and personal preferences. + + + + {canChangePassword && ( + + )} + + + + + + + Session + Sign out of your account on this device. + + + + + + + {canChangePassword && ( + + )} + + +
+ ); +} + +function BillingSection({ + billingStatus, + isMobilePlatform +}: { + billingStatus: ReturnType["billingStatus"]; + isMobilePlatform: boolean; +}) { + const [isPortalLoading, setIsPortalLoading] = useState(false); + + const productName = billingStatus?.product_name || ""; + const hasStripeAccount = billingStatus?.stripe_customer_id !== null; + const isPro = productName.toLowerCase().includes("pro"); + const isMax = productName.toLowerCase().includes("max"); + const isStarter = productName.toLowerCase().includes("starter"); + const isTeamPlan = productName.toLowerCase().includes("team"); + const showUpgrade = !isMax && !isTeamPlan; + const showManage = (isPro || isMax || isStarter || isTeamPlan) && hasStripeAccount; + + const handleManageSubscription = async () => { + if (!hasStripeAccount) return; + + try { + setIsPortalLoading(true); + const billingService = getBillingService(); + const url = await billingService.getPortalUrl(); + + if (isMobilePlatform) { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("plugin:opener|open_url", { url }).catch((err: Error) => { + console.error("[Billing] Failed to open external browser:", err); + alert("Failed to open browser. Please try again."); + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + return; + } + + window.open(url, "_blank"); + } catch (error) { + console.error("Error fetching portal URL:", error); + } finally { + setIsPortalLoading(false); + } + }; + + return ( +
+ + + Plan + Manage your subscription and billing. + + +
+
+
+ {billingStatus ? `${billingStatus.product_name} Plan` : "Loading..."} +
+ {billingStatus?.current_period_end && ( +
+ {billingStatus.payment_provider === "subscription_pass" || + billingStatus.payment_provider === "zaprite" + ? "Expires on " + : "Renews on "} + {new Date(Number(billingStatus.current_period_end) * 1000).toLocaleDateString( + undefined, + { + year: "numeric", + month: "long", + day: "numeric" + } + )} +
+ )} +
+ {billingStatus && ( + + {billingStatus.payment_provider} + + )} +
+ +
+ {showUpgrade && ( + + )} + {showManage && ( + + )} +
+
+
+
+ ); +} + +function TeamSection({ + billingStatus, + teamStatus, + autoOpenSetup +}: { + billingStatus: ReturnType["billingStatus"]; + teamStatus?: TeamStatus; + autoOpenSetup: boolean; +}) { + const productName = billingStatus?.product_name || ""; + const isTeamPlan = productName.toLowerCase().includes("team"); + + const needsSetup = teamStatus?.has_team_subscription && teamStatus?.team_created === false; + const [isSetupOpen, setIsSetupOpen] = useState(false); + const [hasAutoOpened, setHasAutoOpened] = useState(false); + + useEffect(() => { + if (!autoOpenSetup || !needsSetup || hasAutoOpened) return; + setIsSetupOpen(true); + setHasAutoOpened(true); + }, [autoOpenSetup, needsSetup, hasAutoOpened]); + + if (!isTeamPlan) { + return ( + + + Teams + + Upgrade to a Team plan to manage members, seats, and shared usage. + + + + + + + ); + } + + return ( +
+ {needsSetup && ( + + + Set up your team + + You have an active team subscription. Create your team to start inviting members. + + + + + + + + )} + + {!needsSetup && ( + + + + )} +
+ ); +} + +function ApiSection({ + isMobilePlatform, + showCreditSuccessMessage +}: { + isMobilePlatform: boolean; + showCreditSuccessMessage: boolean; +}) { + if (isMobilePlatform) { + return ( + + + API + API management is currently available on desktop/web. + + + ); + } + + return ( + + + + ); +} + +function HistorySection() { + const { clearHistory } = useLocalState(); + const os = useOpenSecret(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const handleDeleteHistory = async () => { + try { + await clearHistory(); + } catch (error) { + console.error("Error clearing history:", error); + } + + try { + const conversations = await os.listConversations({ limit: 1 }); + if (conversations.data && conversations.data.length > 0) { + await os.deleteConversations(); + } + } catch (error) { + console.error("Error deleting conversations:", error); + } + + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + queryClient.invalidateQueries({ queryKey: ["conversations"] }); + queryClient.invalidateQueries({ queryKey: ["archivedChats"] }); + navigate({ to: "/" }); + }; + + return ( + + + History + Delete your local and server chat history. + + + + + + + + + Are you sure? + + This will delete your entire chat history (local + server). + + + + Cancel + Delete + + + + + + ); +} + +function AboutSection() { + return ( +
+ + + About Maple + Product information and legal links. + + + + + + + + + + {isTauri() && ( + + External links open in your system browser. + + )} +
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 013dbe39..751955b3 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -5,14 +5,11 @@ import { Marketing } from "@/components/Marketing"; import { TopNav } from "@/components/TopNav"; import { VerificationModal } from "@/components/VerificationModal"; import { GuestPaymentWarningDialog } from "@/components/GuestPaymentWarningDialog"; -import { TeamManagementDialog } from "@/components/team/TeamManagementDialog"; -import { ApiKeyManagementDialog } from "@/components/apikeys/ApiKeyManagementDialog"; import { PromoDialog, hasSeenPromo, markPromoAsSeen } from "@/components/PromoDialog"; import { useOpenSecret } from "@opensecret/react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { getBillingService } from "@/billing/billingService"; import { useLocalState } from "@/state/useLocalState"; -import type { TeamStatus } from "@/types/team"; import type { DiscountResponse } from "@/billing/billingApi"; type IndexSearchOptions = { @@ -40,15 +37,11 @@ export const Route = createFileRoute("/")({ function Index() { const navigate = useNavigate(); const os = useOpenSecret(); - const queryClient = useQueryClient(); const { setBillingStatus, billingStatus } = useLocalState(); const { login, next, team_setup, credits_success } = Route.useSearch(); // Modal states - const [teamDialogOpen, setTeamDialogOpen] = useState(false); - const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); - const [showCreditSuccess, setShowCreditSuccess] = useState(false); const [showGuestPaymentWarning, setShowGuestPaymentWarning] = useState(false); const [promoDialogOpen, setPromoDialogOpen] = useState(false); @@ -74,16 +67,6 @@ function Index() { } }, [login, next, navigate]); - // Fetch team status for the dialog - const { data: teamStatus } = useQuery({ - queryKey: ["teamStatus"], - queryFn: async () => { - const billingService = getBillingService(); - return await billingService.getTeamStatus(); - }, - enabled: !!os.auth.user - }); - // Fetch active discount/promotion for promo dialog const { data: discount } = useQuery({ queryKey: ["discount"], @@ -95,29 +78,17 @@ function Index() { enabled: !!os.auth.user }); - // Auto-open team dialog if team_setup is true + // Team setup flow: route into Settings useEffect(() => { - if (team_setup && os.auth.user && teamStatus) { - setTeamDialogOpen(true); - // Clear the query param to prevent re-opening on refresh - navigate({ to: "/", replace: true }); - } - }, [team_setup, os.auth.user, teamStatus, navigate]); + if (!team_setup || !os.auth.user) return; + navigate({ to: "/settings", search: { tab: "team", team_setup: true }, replace: true }); + }, [team_setup, os.auth.user, navigate]); - // Handle credits_success - open API key dialog and refresh balance + // API credits success flow: route into Settings useEffect(() => { - if (credits_success && os.auth.user) { - setApiKeyDialogOpen(true); - setShowCreditSuccess(true); - // Refresh the credit balance - queryClient.invalidateQueries({ queryKey: ["apiCreditBalance"] }); - // Clear the query param to prevent re-opening on refresh - navigate({ to: "/", replace: true }); - // Clear success message after 5 seconds - const timer = setTimeout(() => setShowCreditSuccess(false), 5000); - return () => clearTimeout(timer); - } - }, [credits_success, os.auth.user, navigate, queryClient]); + if (!credits_success || !os.auth.user) return; + navigate({ to: "/settings", search: { tab: "api", credits_success: true }, replace: true }); + }, [credits_success, os.auth.user, navigate]); // Check if guest user needs to pay const isGuestUser = os.auth.user?.user.login_method?.toLowerCase() === "guest"; @@ -183,20 +154,6 @@ function Index() { onOpenChange={setShowGuestPaymentWarning} /> - {/* Team Management Dialog */} - - - {/* API Key Management Dialog */} - - {/* Promo Dialog - shows once per promo for free users */} {discount?.active && ( diff --git a/frontend/src/routes/payment-success.tsx b/frontend/src/routes/payment-success.tsx index 51474c5a..8bac21f2 100644 --- a/frontend/src/routes/payment-success.tsx +++ b/frontend/src/routes/payment-success.tsx @@ -49,7 +49,7 @@ function PaymentSuccessPage() { // If team plan, redirect to home with team_setup param if (hasTeamPlan) { - return ; + return ; } // Otherwise, redirect to pricing page with success query parameter diff --git a/frontend/src/routes/pricing.tsx b/frontend/src/routes/pricing.tsx index cfd562f0..41df79a7 100644 --- a/frontend/src/routes/pricing.tsx +++ b/frontend/src/routes/pricing.tsx @@ -273,8 +273,8 @@ function PricingPage() { // Check if team plan purchase and redirect after success useEffect(() => { if (success && freshBillingStatus?.product_name?.toLowerCase().includes("team")) { - // Redirect to home with team_setup param - navigate({ to: "/", search: { team_setup: true }, replace: true }); + // Redirect to settings team setup + navigate({ to: "/settings", search: { tab: "team", team_setup: true }, replace: true }); } }, [success, freshBillingStatus, navigate]); From e9999cb1b0591fa33c0c5bfeb727d68275d428dd Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 9 Jan 2026 14:30:22 -0600 Subject: [PATCH 2/2] fix: polish settings page navigation and fix api tab error Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../components/apikeys/ApiKeyDashboard.tsx | 99 ++++++----- .../src/components/team/TeamDashboard.tsx | 33 ++-- frontend/src/routes/_auth.settings.tsx | 166 +++++++++++------- 3 files changed, 174 insertions(+), 124 deletions(-) diff --git a/frontend/src/components/apikeys/ApiKeyDashboard.tsx b/frontend/src/components/apikeys/ApiKeyDashboard.tsx index be7d18d5..8b600d23 100644 --- a/frontend/src/components/apikeys/ApiKeyDashboard.tsx +++ b/frontend/src/components/apikeys/ApiKeyDashboard.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import { DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { @@ -33,6 +32,21 @@ interface ApiKeyDashboardProps { showCreditSuccessMessage?: boolean; } +function DashboardHeader({ + title, + description +}: { + title: React.ReactNode; + description?: React.ReactNode; +}) { + return ( +
+

{title}

+ {description ?
{description}
: null} +
+ ); +} + export function ApiKeyDashboard({ showCreditSuccessMessage = false }: ApiKeyDashboardProps) { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const isTauriDesktopPlatform = isTauriDesktop(); @@ -91,46 +105,41 @@ export function ApiKeyDashboard({ showCreditSuccessMessage = false }: ApiKeyDash // Show loading state if billing status or API keys are loading if (isBillingLoading || isLoading) { return ( - <> - - API Management - Loading... - +
+
- +
); } if (error) { return ( - <> - - API Management - - Failed to load API keys. Please try again. - - - + Failed to load API keys. Please try again. + } + /> ); } // Show upgrade prompt for users without API access (Free and Starter plans) if (!hasApiAccess) { return ( - <> - - - - Unlock API Access - - - Upgrade to a paid plan to access powerful API features - - +
+ + + Unlock API Access + + } + description="Upgrade to a paid plan to access powerful API features" + /> -
+
@@ -187,28 +196,30 @@ export function ApiKeyDashboard({ showCreditSuccessMessage = false }: ApiKeyDash

- +
); } return ( - <> - - API Access - - Manage API keys and configure access to Maple services.{" "} - - Read more - - - +
+ + Manage API keys and configure access to Maple services.{" "} + + Read more + + + } + /> - + @@ -280,6 +291,6 @@ export function ApiKeyDashboard({ showCreditSuccessMessage = false }: ApiKeyDash onOpenChange={setIsCreateDialogOpen} onKeyCreated={handleKeyCreated} /> - +
); } diff --git a/frontend/src/components/team/TeamDashboard.tsx b/frontend/src/components/team/TeamDashboard.tsx index 2a6a224f..c23272e7 100644 --- a/frontend/src/components/team/TeamDashboard.tsx +++ b/frontend/src/components/team/TeamDashboard.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import { DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; @@ -14,6 +13,21 @@ interface TeamDashboardProps { teamStatus?: TeamStatus; } +function DashboardHeader({ + title, + description +}: { + title: React.ReactNode; + description?: React.ReactNode; +}) { + return ( +
+

{title}

+ {description ?
{description}
: null} +
+ ); +} + export function TeamDashboard({ teamStatus }: TeamDashboardProps) { const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); const [isEditingName, setIsEditingName] = useState(false); @@ -23,14 +37,7 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) { const queryClient = useQueryClient(); if (!teamStatus) { - return ( - <> - - Team Dashboard - Loading team information... - - - ); + return ; } const seatsUsed = teamStatus.seats_used || 0; @@ -94,9 +101,7 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) { if (!isAdmin) { return ( <> - - Team Information - +
{/* Compact team info */} @@ -132,9 +137,7 @@ export function TeamDashboard({ teamStatus }: TeamDashboardProps) { // Full admin view return ( <> - - Team Dashboard - +
{/* Seat limit exceeded warning */} diff --git a/frontend/src/routes/_auth.settings.tsx b/frontend/src/routes/_auth.settings.tsx index fdd42ea5..fd37959d 100644 --- a/frontend/src/routes/_auth.settings.tsx +++ b/frontend/src/routes/_auth.settings.tsx @@ -41,7 +41,13 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; import { getBillingService } from "@/billing/billingService"; import { useLocalState } from "@/state/useLocalState"; import type { TeamStatus } from "@/types/team"; @@ -204,74 +210,104 @@ function SettingsPage() {
)} -
-
- - -

Settings

-
+
+ -
- -
+
-
-
- - -
- {effectiveTab === "account" && } - {effectiveTab === "billing" && ( - - )} - {effectiveTab === "team" && ( - - )} - {effectiveTab === "api" && ( - - )} - {effectiveTab === "history" && } - {effectiveTab === "about" && } +
+
+
+

Settings

+

+ Manage your account, billing, and application preferences. +

+
+ +
+
+ +
+ + + +
+ {effectiveTab === "account" && } + {effectiveTab === "billing" && ( + + )} + {effectiveTab === "team" && ( + + )} + {effectiveTab === "api" && ( + + )} + {effectiveTab === "history" && } + {effectiveTab === "about" && } +