diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 1289cf141..8a08442f3 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -47,7 +47,8 @@ fn handle_desktop_run_event(app_handle: &tauri::AppHandle, event: tauri::RunEven // This handles incoming deep links fn handle_deep_link_event(url: &str, app: &tauri::AppHandle) { - log::info!("[Deep Link] Received: {url}"); + // OAuth callbacks carry bearer tokens in the query string, so never log the raw URL. + log::info!("[Deep Link] Received callback"); // Forward the URL to the frontend match app.emit_to("main", "deep-link-received", url.to_string()) { Ok(_) => log::info!("[Deep Link] Event emitted successfully"), @@ -59,8 +60,9 @@ fn handle_deep_link_event(url: &str, app: &tauri::AppHandle) { pub fn run() { #[cfg(desktop)] let app = tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| { - log::info!("Single instance detected: {}, {argv:?}, {cwd}", app.package_info().name); + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + // argv can contain a custom-scheme callback URL with OAuth bearer tokens. + log::info!("Single instance detected for {}", app.package_info().name); })) .plugin(tauri_plugin_log::Builder::default().level(log::LevelFilter::Info).build()) .plugin(tauri_plugin_deep_link::init()) diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 00ab878a5..d23abc81a 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -15,7 +15,6 @@ import { NotificationProvider } from "./contexts/NotificationContext"; import { ThemeProvider } from "./contexts/ThemeContext"; import { ProxyEventListener } from "./components/ProxyEventListener"; import { UpdateEventListener } from "./components/UpdateEventListener"; -import { TeamSeatMismatchAlert } from "./components/team/TeamSeatMismatchAlert"; import { TTSProvider } from "./services/tts/TTSContext"; const DEFAULT_OPEN_SECRET_CLIENT_ID = "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6"; @@ -109,7 +108,6 @@ export default function App() { - diff --git a/frontend/src/components/AccountDialog.tsx b/frontend/src/components/AccountDialog.tsx deleted file mode 100644 index 9f2066f93..000000000 --- a/frontend/src/components/AccountDialog.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger -} from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { useOpenSecret } from "@opensecret/react"; -import { CheckCircle, XCircle, Trash, Sun, Moon, Monitor } from "lucide-react"; -import { ChangePasswordDialog } from "./ChangePasswordDialog"; -import { useLocalState } from "@/state/useLocalState"; -import { DeleteAccountDialog } from "./DeleteAccountDialog"; -import { PreferencesDialog } from "./PreferencesDialog"; -import { useTheme } from "@/contexts/ThemeContext"; - -export function AccountDialog() { - const os = useOpenSecret(); - const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); - const [isDeleteAccountOpen, setIsDeleteAccountOpen] = useState(false); - const [isPreferencesOpen, setIsPreferencesOpen] = useState(false); - const [verificationStatus, setVerificationStatus] = useState<"unverified" | "pending">( - "unverified" - ); - const { billingStatus } = useLocalState(); - const { theme, setTheme } = useTheme(); - - // Check user login method - const isEmailUser = os.auth.user?.user.login_method === "email"; - const isGuestUser = os.auth.user?.user.login_method?.toLowerCase() === "guest"; - - const handleResendVerification = async () => { - try { - await os.requestNewVerificationEmail(); - setVerificationStatus("pending"); - } catch (error) { - console.error("Failed to resend verification email:", error); - } - }; - - return ( - <> - - - Update Your Account - Change your email or upgrade your plan. - -
- {!isGuestUser && ( -
- -
- - {os.auth.user?.user.email_verified ? ( - - ) : ( - - )} -
- {!os.auth.user?.user.email_verified && ( -
- {verificationStatus === "unverified" ? ( - <> - Unverified -{" "} - - - ) : ( - "Pending - Check your email for verification link" - )} -
- )} -
- )} -
- - - {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" - } - )} -
- )} -
-
- -
- - - -
-
-
- - {(isEmailUser || isGuestUser) && ( - - - - )} - -
-
- - - -
- {(isEmailUser || isGuestUser) && ( - - )} - - - - ); -} diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index 8fe2a03b0..3614b4a91 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -1,578 +1,70 @@ -import { - LogOut, - Trash, - User, - CreditCard, - ArrowUpCircle, - Mail, - Users, - AlertCircle, - Key, - Info, - Shield, - FileText, - ChevronLeft -} from "lucide-react"; -import { isMobile, isTauri, isIOS } from "@/utils/platform"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from "@/components/ui/dropdown-menu"; -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 "@/components/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 { useOpenSecret } from "@opensecret/react"; +import { AlertCircle, Settings } from "lucide-react"; import { getBillingService } from "@/billing/billingService"; -import { useState } from "react"; +import { CreditUsage } from "@/components/CreditUsage"; +import { useCompactSettingsLayout } from "@/components/settings/useCompactSettingsLayout"; +import { useLocalState } from "@/state/useLocalState"; import type { TeamStatus } from "@/types/team"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { TeamManagementDialog } from "@/components/team/TeamManagementDialog"; -import { ApiKeyManagementDialog } from "@/components/apikeys/ApiKeyManagementDialog"; import { getTeamSeatMismatch } from "@/utils/teamSeats"; -import packageJson from "../../package.json"; -import { SIDEBAR_ACCOUNT_MENU_WIDTH_CLASS, SIDEBAR_LAYOUT_STYLE } from "@/constants/layout"; -import { clearAgentHistoryForUser, stopAgentRuntimeForUser } from "@/services/agentRuntimeService"; - -function ConfirmDeleteDialog({ onDeleted }: { onDeleted: () => void }) { - const os = useOpenSecret(); - const queryClient = useQueryClient(); - const navigate = useNavigate(); - const [deleteError, setDeleteError] = useState(null); - const [isDeleting, setIsDeleting] = useState(false); - - async function handleDeleteHistory() { - setDeleteError(null); - setIsDeleting(true); - let operationBlock: Awaited> | null = null; - try { - const conversations = await os.listConversations({ limit: 1 }); - if (conversations.data && conversations.data.length > 0) { - await os.deleteConversations(); - console.log("Server conversations deleted"); - } - - operationBlock = await clearAgentHistoryForUser(os.auth.user?.user.id); - - // Refresh UI only after both hosted and local Agent Mode history are gone. - queryClient.invalidateQueries({ queryKey: ["conversations"] }); - queryClient.invalidateQueries({ queryKey: ["pinnedConversations"] }); - queryClient.invalidateQueries({ queryKey: ["projectConversations"] }); - queryClient.invalidateQueries({ queryKey: ["conversationProjects"] }); - queryClient.invalidateQueries({ queryKey: ["conversationProject"] }); - onDeleted(); - try { - await navigate({ to: "/" }); - } catch (navigationError) { - console.error("Chat history was deleted, but navigation failed:", navigationError); - window.location.href = "/"; - return; - } - window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId: null } })); - } catch (e) { - console.error("Error deleting chat history:", e); - setDeleteError("Maple couldn't delete all chat history. Please try again."); - } finally { - operationBlock?.release(); - setIsDeleting(false); - } - } - - return ( - - - Are you sure? - This will delete your entire chat history. - - {deleteError ? ( - - - {deleteError} - - ) : null} - - Cancel - { - event.preventDefault(); - void handleDeleteHistory(); - }} - > - {isDeleting ? "Deleting..." : "Delete"} - - - - ); -} export function AccountMenu() { const os = useOpenSecret(); - const queryClient = useQueryClient(); - const router = useRouter(); const { billingStatus, setBillingStatus } = useLocalState(); - const [isPortalLoading, setIsPortalLoading] = useState(false); - const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false); - const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false); - const [isDeleteHistoryOpen, setIsDeleteHistoryOpen] = useState(false); - const [isSigningOut, setIsSigningOut] = useState(false); - const [showAboutMenu, setShowAboutMenu] = useState(false); - const [portalError, setPortalError] = useState(null); - - const hasStripeAccount = !!billingStatus?.stripe_customer_id; - 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; + const isCompactSettingsLayout = useCompactSettingsLayout(); + const isTeamPlan = billingStatus?.product_name?.toLowerCase().includes("team") ?? false; // Keep the shared sidebar billing badge current on every authenticated route, // including Agent Mode. Some routes do not own a route-level billing refresh. useQuery({ queryKey: ["billingStatus"], queryFn: async () => { - const billingService = getBillingService(); - const status = await billingService.getBillingStatus(); + const status = await getBillingService().getBillingStatus(); setBillingStatus(status); return status; }, enabled: !!os.auth.user }); - // Fetch team status if user has team plan const { data: teamStatus } = useQuery({ queryKey: ["teamStatus"], - queryFn: async () => { - const billingService = getBillingService(); - return await billingService.getTeamStatus(); - }, + queryFn: () => getBillingService().getTeamStatus(), enabled: isTeamPlan && !!os.auth.user && !!billingStatus }); - // Fetch products with version check for iOS to determine API Management availability - const isIOSPlatform = isIOS(); - const { data: products } = useQuery({ - queryKey: ["products-version-check", isIOSPlatform], - queryFn: async () => { - try { - const billingService = getBillingService(); - // Send version for iOS builds (App Store restrictions) - if (isIOSPlatform) { - const version = `v${packageJson.version}`; - return await billingService.getProducts(version); - } - return await billingService.getProducts(); - } catch (error) { - console.error("Error fetching products for version check:", error); - return null; - } - }, - enabled: isIOSPlatform - }); - - // Show alert badge if user has team plan but hasn't created team yet - const showTeamSetupAlert = - isTeamPlan && teamStatus?.has_team_subscription && !teamStatus?.team_created; + const needsTeamSetup = !!teamStatus?.has_team_subscription && teamStatus.team_created === false; const teamSeatMismatch = getTeamSeatMismatch(teamStatus); - const showTeamAttentionAlert = showTeamSetupAlert || !!teamSeatMismatch; - - // Determine if API Management should be shown - // On desktop/web/Android: always show - // On iOS only: only show if version is approved (at least one product is available) - const showApiManagement = (() => { - if (!isIOSPlatform) { - return true; // Always show on desktop/web/Android - } - // On iOS, check if version is approved - // If products is null/undefined, default to false (hide until we know) - if (!products) { - return false; - } - // Show if at least one product is available (meaning version is approved) - return products.some((product) => product.is_available !== false); - })(); - - const handleManageSubscription = async () => { - if (!hasStripeAccount) return; - - try { - setIsPortalLoading(true); - setPortalError(null); - const billingService = getBillingService(); - const url = await billingService.getPortalUrl(); - - // Check if we're on any Tauri platform (mobile or desktop) - if (isTauri()) { - console.log( - "[Billing] Tauri platform detected, using opener plugin to launch external browser for portal" - ); - - const { invoke } = await import("@tauri-apps/api/core"); - - // Use the opener plugin directly for all Tauri 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); - if (isMobile()) { - alert("Failed to open browser. Please try again."); - } else { - // Fallback to window.open on desktop - window.open(url, "_blank"); - } - }); - - // Add a small delay to ensure the browser has time to open - await new Promise((resolve) => setTimeout(resolve, 300)); - return; - } - - // Default browser opening for web platforms - window.open(url, "_blank"); - } catch (error) { - console.error("Error fetching portal URL:", error); - setPortalError( - "Unable to open subscription management. Please try again or contact support@trymaple.ai." - ); - } 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() { - setPortalError(null); - setIsSigningOut(true); - let operationBlock: Awaited> | null = null; - let signedOut = false; - - // Never sign out while this account may still have tools executing. - try { - operationBlock = await stopAgentRuntimeForUser(os.auth.user?.user.id); - } catch (error) { - console.error("Error stopping Agent Mode:", error); - setPortalError("Maple couldn't stop Agent Mode. Please try logging out again."); - setIsSigningOut(false); - return; - } - - try { - // Credential reset is a required part of logout. - const { proxyService } = await import("@/services/proxyService"); - await proxyService.stopAndResetProxy(os.auth.user?.user.id, os.deleteApiKey); - - // 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(); - signedOut = true; - queryClient.clear(); - - // Navigate after everything is done - await router.invalidate(); - await router.navigate({ to: "/" }); - } catch (error) { - console.error("Error during sign out:", error); - if (signedOut) { - window.location.href = "/"; - return; - } - setPortalError( - "Maple couldn't securely reset Agent Mode or finish logging out. Please try again." - ); - } finally { - if (!signedOut) { - operationBlock.release(); - setIsSigningOut(false); - } else { - operationBlock.retainUntilNextSession(); - } - } - } + const attentionLabel = teamSeatMismatch + ? "Team usage paused" + : needsTeamSetup + ? "Team setup required" + : undefined; return ( -
- - - !open && setShowAboutMenu(false)}> -
-
- - - -
- - - -
- {/* align=start: panel aligns to sidebar content edge; center was relative to the small icon */} - -
-
- {teamStatus?.team_name || "Maple Research"} - - - - - - Profile - - - {showUpgrade && ( - - - - Upgrade your plan - - - )} - {showManage && ( - - - {isPortalLoading ? "Loading..." : "Manage Subscription"} - - )} - {isTeamPlan && ( - setIsTeamDialogOpen(true)}> -
-
- - Manage Team -
- {teamSeatMismatch ? ( - - Paused - - ) : showTeamSetupAlert ? ( - - Setup Required - - ) : null} -
-
- )} - {showApiManagement && ( - setIsApiKeyDialogOpen(true)}> - - API Management - - )} - - - - Delete History - - -
- - - { - e.preventDefault(); - setShowAboutMenu(true); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - About Us - - - - - - {isSigningOut ? "Logging out..." : "Log out"} - -
- - {/* About Us Submenu */} -
- - { - e.preventDefault(); - setShowAboutMenu(false); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - Back - - - - - - - - About Maple - - - { - e.preventDefault(); - handleOpenExternalUrl("https://trymaple.ai/privacy"); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - Privacy Policy - - { - e.preventDefault(); - handleOpenExternalUrl("https://trymaple.ai/terms"); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - Terms of Service - - { - e.preventDefault(); - handleOpenExternalUrl("mailto:support@trymaple.ai"); - }} - onSelect={(e) => { - e.preventDefault(); - }} - > - - Contact Us - - -
-
-
- - setIsDeleteHistoryOpen(false)} /> - {portalError && ( - - - {portalError} - - )} - - -
-
-
+
+ + + {(teamSeatMismatch || needsTeamSetup) && ( + + )} + + + +
); } diff --git a/frontend/src/components/AppleAuthProvider.tsx b/frontend/src/components/AppleAuthProvider.tsx index d3f6e607e..d6ff157e4 100644 --- a/frontend/src/components/AppleAuthProvider.tsx +++ b/frontend/src/components/AppleAuthProvider.tsx @@ -6,6 +6,7 @@ import { bytesToHex } from "@noble/hashes/utils"; import { Button, type ButtonProps } from "./ui/button"; import { Apple } from "./icons/Apple"; import { getBillingService } from "@/billing/billingService"; +import { getSafeInternalRedirect } from "@/utils/internalRedirect"; // Define the props interface interface AppleAuthProviderProps { @@ -249,6 +250,14 @@ export function AppleAuthProvider({ deepLinkUrl += `&refresh_token=${encodeURIComponent(refreshToken)}`; } + const postAuthRedirect = sessionStorage.getItem("post_auth_redirect"); + sessionStorage.removeItem("post_auth_redirect"); + const safePostAuthRedirect = getSafeInternalRedirect(postAuthRedirect); + + if (!selectedPlan && safePostAuthRedirect) { + deepLinkUrl += `&next=${encodeURIComponent(safePostAuthRedirect)}`; + } + setTimeout(() => { window.location.href = deepLinkUrl; }, 1000); @@ -361,6 +370,14 @@ export function AppleAuthProvider({ deepLinkUrl += `&refresh_token=${encodeURIComponent(refreshToken)}`; } + const postAuthRedirect = sessionStorage.getItem("post_auth_redirect"); + sessionStorage.removeItem("post_auth_redirect"); + const safePostAuthRedirect = getSafeInternalRedirect(postAuthRedirect); + + if (!selectedPlan && safePostAuthRedirect) { + deepLinkUrl += `&next=${encodeURIComponent(safePostAuthRedirect)}`; + } + setTimeout(() => { window.location.href = deepLinkUrl; }, 1000); diff --git a/frontend/src/components/AuthenticatedHomeContent.tsx b/frontend/src/components/AuthenticatedHomeContent.tsx new file mode 100644 index 000000000..35ef5d77f --- /dev/null +++ b/frontend/src/components/AuthenticatedHomeContent.tsx @@ -0,0 +1,160 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode +} from "react"; +import { useLocation, useRouter } from "@tanstack/react-router"; +import { ProjectDetailView } from "@/components/ProjectDetailView"; +import { UnifiedChat } from "@/components/UnifiedChat"; +import { PersistentHomeNavigationContext } from "@/contexts/PersistentHomeNavigationContext"; + +const TRANSIENT_HOME_SEARCH_PARAMS = ["team_setup", "credits_success", "api_settings"]; + +type HomeSelection = { + projectId: string | null; + hasConversationId: boolean; +}; + +function readHomeSelection(): HomeSelection { + if (typeof window === "undefined" || window.location.pathname !== "/") { + return { projectId: null, hasConversationId: false }; + } + + const search = new URLSearchParams(window.location.search); + return { + projectId: search.get("project_id"), + hasConversationId: search.has("conversation_id") + }; +} + +function readSafeHomeHref(): string { + if (typeof window === "undefined" || window.location.pathname !== "/") { + return "/"; + } + + const search = new URLSearchParams(window.location.search); + for (const key of TRANSIENT_HOME_SEARCH_PARAMS) { + search.delete(key); + } + + const searchString = search.toString(); + return `/${searchString ? `?${searchString}` : ""}${window.location.hash}`; +} + +export function PersistentHomeNavigationProvider({ children }: { children: ReactNode }) { + const location = useLocation(); + const router = useRouter(); + const initialHomeHref = readSafeHomeHref(); + const homeHrefRef = useRef(initialHomeHref); + const [homeHref, setHomeHref] = useState(initialHomeHref); + + const captureHomeHref = useCallback(() => { + if (window.location.pathname !== "/") return; + + const nextHomeHref = readSafeHomeHref(); + homeHrefRef.current = nextHomeHref; + setHomeHref((current) => (current === nextHomeHref ? current : nextHomeHref)); + }, []); + + // TanStack navigations update location.href. Maple also updates the home URL with the native + // History API, so the app events below keep the snapshot exact for those transitions too. + useLayoutEffect(() => { + captureHomeHref(); + }, [captureHomeHref, location.href]); + + useEffect(() => { + const events = [ + "conversationcreated", + "conversationselected", + "projectselected", + "newchat", + "popstate" + ] as const; + + for (const event of events) { + window.addEventListener(event, captureHomeHref); + } + + return () => { + for (const event of events) { + window.removeEventListener(event, captureHomeHref); + } + }; + }, [captureHomeHref]); + + const returnToHome = useCallback( + ({ replace = true }: { replace?: boolean } = {}) => { + const href = homeHrefRef.current; + if (replace) { + router.history.replace(href); + } else { + router.history.push(href); + } + }, + [router] + ); + + const value = useMemo( + () => ({ + homeHref, + returnToHome + }), + [homeHref, returnToHome] + ); + + return ( + + {children} + + ); +} + +export function AuthenticatedHomeContent({ + homeLocationHref +}: { + homeLocationHref: string | null; +}) { + const [selection, setSelection] = useState(readHomeSelection); + + const syncFromHomeLocation = useCallback(() => { + if (window.location.pathname !== "/") return; + + const nextSelection = readHomeSelection(); + setSelection((current) => + current.projectId === nextSelection.projectId && + current.hasConversationId === nextSelection.hasConversationId + ? current + : nextSelection + ); + }, []); + + useEffect(() => { + if (homeLocationHref !== null) { + syncFromHomeLocation(); + } + }, [homeLocationHref, syncFromHomeLocation]); + + useEffect(() => { + const events = ["projectselected", "conversationselected", "newchat", "popstate"] as const; + + for (const event of events) { + window.addEventListener(event, syncFromHomeLocation); + } + + return () => { + for (const event of events) { + window.removeEventListener(event, syncFromHomeLocation); + } + }; + }, [syncFromHomeLocation]); + + if (selection.projectId && !selection.hasConversationId) { + return ; + } + + return ; +} diff --git a/frontend/src/components/ChangePasswordDialog.tsx b/frontend/src/components/ChangePasswordDialog.tsx deleted file mode 100644 index 7135f8a54..000000000 --- a/frontend/src/components/ChangePasswordDialog.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState, useEffect } from "react"; -import { useOpenSecret } from "@opensecret/react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Alert, AlertDescription } from "@/components/ui/alert"; - -interface ChangePasswordDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function ChangePasswordDialog({ open, onOpenChange }: ChangePasswordDialogProps) { - const os = useOpenSecret(); - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - - const resetForm = () => { - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - setError(null); - setSuccess(false); - setIsLoading(false); - }; - - useEffect(() => { - if (!open) { - resetForm(); - } - }, [open]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setSuccess(false); - - if (newPassword !== confirmPassword) { - setError( - "Passwords do not match. Please make sure your new password and confirmation match." - ); - return; - } - - setIsLoading(true); - try { - await os.changePassword(currentPassword, newPassword); - setSuccess(true); - setTimeout(() => { - onOpenChange(false); - }, 2000); // Close the dialog after 2 seconds - } catch (error) { - console.error("Failed to change password:", error); - setError("Failed to change password. Please check your current password and try again."); - } finally { - setIsLoading(false); - } - }; - - return ( - - - - Change Password - - Enter your current password and a new password to update your account. - - -
- {error && ( - - {error} - - )} - {success && ( - - - Password changed successfully. This dialog will close shortly. - - - )} -
- - setCurrentPassword(e.target.value)} - required - autoComplete="current-password" - /> -
-
- - setNewPassword(e.target.value)} - required - minLength={8} - autoComplete="new-password" - /> -
-
- - setConfirmPassword(e.target.value)} - required - minLength={8} - autoComplete="new-password" - /> -
- - - -
-
-
- ); -} diff --git a/frontend/src/components/DeepLinkHandler.tsx b/frontend/src/components/DeepLinkHandler.tsx index 8a7dd6ce1..7fd51816d 100644 --- a/frontend/src/components/DeepLinkHandler.tsx +++ b/frontend/src/components/DeepLinkHandler.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { isTauri } from "@/utils/platform"; import { listen } from "@tauri-apps/api/event"; +import { getSafeInternalRedirect } from "@/utils/internalRedirect"; // For direct deep link handling, we'll listen to our custom event // If we had the types installed, we would use: @@ -18,7 +19,7 @@ export function DeepLinkHandler() { // Listen for the custom event we emit from Rust unlisten = await listen("deep-link-received", (event) => { const url = event.payload; - console.log("[Deep Link] Received URL:", url); + console.log("[Deep Link] Received callback"); try { // Parse the URL to extract parameters @@ -32,6 +33,8 @@ export function DeepLinkHandler() { // Handle auth deep links const accessToken = urlObj.searchParams.get("access_token"); const refreshToken = urlObj.searchParams.get("refresh_token"); + const next = urlObj.searchParams.get("next"); + const safeNext = getSafeInternalRedirect(next) ?? "/"; if (accessToken && refreshToken) { console.log("[Deep Link] Auth tokens received"); @@ -41,23 +44,29 @@ export function DeepLinkHandler() { localStorage.setItem("refresh_token", refreshToken); // Refresh the app state to reflect the logged-in status - window.location.href = "/"; // Reload the app + window.location.href = safeNext; // Reload the app at the requested internal route } else { console.error("[Deep Link] Missing tokens in auth deep link"); } } else if ( firstPathPart === "payment" || firstPathPart === "payment-success" || + firstPathPart === "payment-success-credits" || firstPathPart === "payment-canceled" || urlObj.searchParams.has("payment_success") || - urlObj.searchParams.has("success") + urlObj.searchParams.has("success") || + urlObj.searchParams.has("canceled") || + urlObj.searchParams.has("payment_canceled") ) { // Handle payment deep links from various sources const isSuccess = firstPathPart === "payment-success" || + firstPathPart === "payment-success-credits" || urlObj.searchParams.get("success") === "true" || urlObj.searchParams.get("payment_success") === "true"; + const isCreditSuccess = firstPathPart === "payment-success-credits"; + const isCanceled = firstPathPart === "payment-canceled" || urlObj.searchParams.get("canceled") === "true" || @@ -71,7 +80,11 @@ export function DeepLinkHandler() { }); // Use window.location instead of navigate - if (isSuccess) { + if (isCreditSuccess) { + // Keep the established root callback contract; the home route bridges it into + // the dedicated API credits settings page. + window.location.href = "/?credits_success=true"; + } else if (isSuccess) { // Navigate to the success page or show a success message window.location.href = "/pricing?success=true"; } else if (isCanceled) { diff --git a/frontend/src/components/DeleteAccountDialog.tsx b/frontend/src/components/DeleteAccountDialog.tsx deleted file mode 100644 index 6d1525e98..000000000 --- a/frontend/src/components/DeleteAccountDialog.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { useRef, useState } from "react"; -import { AlertDestructive } from "@/components/AlertDestructive"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle -} from "@/components/ui/alert-dialog"; -import { useQueryClient } from "@tanstack/react-query"; -import { generateSecureSecret, hashSecret, useOpenSecret } from "@opensecret/react"; -import { getBillingService } from "@/billing/billingService"; -import { Loader2 } from "lucide-react"; -import { clearAgentDataForUser } from "@/services/agentRuntimeService"; - -interface DeleteAccountDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function DeleteAccountDialog({ open, onOpenChange }: DeleteAccountDialogProps) { - const os = useOpenSecret(); - const queryClient = useQueryClient(); - const [step, setStep] = useState<"request" | "confirm">("request"); - const [uuid, setUuid] = useState(""); - const [secret, setSecret] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [isAccountDeleted, setIsAccountDeleted] = useState(false); - const [error, setError] = useState(null); - const [confirmText, setConfirmText] = useState(""); - const cleanupBlockRef = useRef> | null>(null); - - // Initial request for account deletion - const handleRequestDeletion = async () => { - if (confirmText !== "DELETE") { - setError("Please type DELETE to confirm"); - return; - } - - setIsLoading(true); - setError(null); - - try { - // Generate a random secret and store it - const generatedSecret = generateSecureSecret(); - const hashedSecret = await hashSecret(generatedSecret); - - // Request account deletion - await os.requestAccountDeletion(hashedSecret); - - // Store the secret and move to confirmation step - setSecret(generatedSecret); - setStep("confirm"); - } catch (err) { - setError("Failed to request account deletion. Please try again."); - console.error(err); - } finally { - setIsLoading(false); - } - }; - - // Confirm deletion with the UUID from email - const handleConfirmDeletion = async () => { - setIsLoading(true); - setError(null); - let deletionConfirmed = isAccountDeleted; - let agentDataCleared = cleanupBlockRef.current !== null; - let proxyReset = false; - - try { - const userId = os.auth.user?.user.id; - - if (!cleanupBlockRef.current) { - // Local Agent data is cleared before the irreversible remote action. - // The returned block stays held until the account flow either succeeds - // or fails while the user still owns the account. - cleanupBlockRef.current = await clearAgentDataForUser(userId); - agentDataCleared = true; - } - - // Credential reset is also required before remote deletion so a crash - // cannot leave a deleted account's proxy key on disk. - const { proxyService } = await import("@/services/proxyService"); - await proxyService.stopAndResetProxy(userId, os.deleteApiKey); - proxyReset = true; - - if (!deletionConfirmed) { - await os.confirmAccountDeletion(uuid, secret); - deletionConfirmed = true; - setIsAccountDeleted(true); - cleanupBlockRef.current.retainUntilNextSession(); - } - - // Clear all tokens and storage - try { - // Clear billing token - getBillingService().clearToken(); - } catch (error) { - console.error("Error clearing billing token:", error); - // Fallback to direct session storage removal - sessionStorage.removeItem("maple_billing_token"); - } - - // Sign out - await os.signOut(); - queryClient.clear(); - - // Force page refresh to go to logged out state - window.location.href = "/"; - } catch (err) { - // If remote deletion did not happen, the authenticated user remains and - // must be able to start a fresh Agent runtime after this attempt. - if (!deletionConfirmed) { - cleanupBlockRef.current?.release(); - cleanupBlockRef.current = null; - } - setError( - !agentDataCleared - ? "Maple couldn't safely stop and clear local Agent Mode data. Your account was not deleted; please retry." - : !proxyReset - ? "Local Agent Mode history was cleared, but Maple couldn't reset its proxy credentials. Your account was not deleted; please retry." - : !deletionConfirmed - ? "Local Agent Mode history was cleared, but account deletion was not confirmed. Verify the code and retry if you still want to delete your account." - : "Your account data was deleted, but Maple couldn't finish signing out. Please retry." - ); - console.error(err); - setIsLoading(false); - } - }; - - const handleCancel = () => { - if (isAccountDeleted) return; - setStep("request"); - setError(null); - setConfirmText(""); - setUuid(""); - onOpenChange(false); - }; - - return ( - { - if (!nextOpen && isAccountDeleted) return; - onOpenChange(nextOpen); - }} - > - - - - {step === "request" ? "Delete Account" : "Confirm Account Deletion"} - - - {step === "request" - ? "This action cannot be undone. This will permanently delete your account and all your data." - : "Please check your email for a confirmation code. Submitting it clears local Agent Mode history and proxy credentials before the final account deletion request, even if the code is rejected."} - - - -
- {step === "request" && ( -
-
-

- Warning: This will permanently delete your account and all associated data. You - will lose access to any paid features, chat history, and settings. -

-
-
- - setConfirmText(e.target.value)} - className="w-full" - /> -
-
- )} - - {step === "confirm" && ( -
- - setUuid(e.target.value)} - placeholder="Enter code from email" - className="w-full" - /> -
- )} - - {error && } -
- - - - Cancel - - {step === "request" ? ( - - ) : ( - - )} - -
-
- ); -} diff --git a/frontend/src/components/PreferencesDialog.tsx b/frontend/src/components/PreferencesDialog.tsx deleted file mode 100644 index 6fb42da25..000000000 --- a/frontend/src/components/PreferencesDialog.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState, useEffect } from "react"; -import { useOpenSecret } from "@opensecret/react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Alert, AlertDescription } from "@/components/ui/alert"; - -interface PreferencesDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function PreferencesDialog({ open, onOpenChange }: PreferencesDialogProps) { - const os = useOpenSecret(); - const [prompt, setPrompt] = useState(""); - const [instructionId, setInstructionId] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - - useEffect(() => { - if (open) { - loadPreferences(); - } else { - setError(null); - setSuccess(false); - } - }, [open]); - - const loadPreferences = async () => { - setIsLoading(true); - setError(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 { - // No default instruction exists yet - setInstructionId(null); - setPrompt(""); - } - } catch (error) { - console.error("Failed to load preferences:", error); - setError("Failed to load preferences. Please try again."); - } finally { - setIsLoading(false); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setSuccess(false); - - setIsSaving(true); - try { - if (instructionId) { - // If prompt is empty, delete the instruction - if (prompt.trim() === "") { - await os.deleteInstruction(instructionId); - setInstructionId(null); - setPrompt(""); - } else { - // Update existing instruction - await os.updateInstruction(instructionId, { - prompt: prompt - }); - } - } else { - // Only create new instruction if prompt is not empty - if (prompt.trim() !== "") { - const newInstruction = await os.createInstruction({ - name: "User Preferences", - prompt: prompt, - is_default: true - }); - setInstructionId(newInstruction.id); - } - } - setSuccess(true); - } catch (error) { - console.error("Failed to save preferences:", error); - setError("Failed to save preferences. Please try again."); - } finally { - setIsSaving(false); - } - }; - - return ( - - - - User Preferences - - Customize your default system prompt for AI conversations. - - -
- {error && ( - - {error} - - )} - {success && ( - - Preferences saved successfully. - - )} -
- -