-
-
-
+
+
+
+ {(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 (
-
- );
-}
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 (
-
- );
-}
diff --git a/frontend/src/components/UpgradePromptDialog.tsx b/frontend/src/components/UpgradePromptDialog.tsx
index ef27340e1..eac04b295 100644
--- a/frontend/src/components/UpgradePromptDialog.tsx
+++ b/frontend/src/components/UpgradePromptDialog.tsx
@@ -46,7 +46,7 @@ export function UpgradePromptDialog({
const handleBuyCredits = () => {
onOpenChange(false);
- navigate({ to: "/", search: { api_settings: true } });
+ navigate({ to: "/settings/api" });
};
const handleNewChat = () => {
diff --git a/frontend/src/components/VerificationModal.tsx b/frontend/src/components/VerificationModal.tsx
index 8a67ee55b..c138a07dc 100644
--- a/frontend/src/components/VerificationModal.tsx
+++ b/frontend/src/components/VerificationModal.tsx
@@ -8,7 +8,7 @@ import {
} from "@/components/ui/dialog";
import { useQueryClient } from "@tanstack/react-query";
import { useOpenSecret } from "@opensecret/react";
-import { useNavigate } from "@tanstack/react-router";
+import { useRouter } from "@tanstack/react-router";
import { useState, useEffect } from "react";
import { Loader2, CheckCircle, LogOut } from "lucide-react";
import { Input } from "./ui/input";
@@ -16,11 +16,12 @@ import { Label } from "./ui/label";
import { AlertDestructive } from "./AlertDestructive";
import { stopAgentRuntimeForUser } from "@/services/agentRuntimeService";
import { getBillingService } from "@/billing/billingService";
+import { navigateToSafeInternalRedirect } from "@/utils/internalRedirect";
export function VerificationModal() {
const os = useOpenSecret();
const queryClient = useQueryClient();
- const navigate = useNavigate();
+ const router = useRouter();
const [isOpen, setIsOpen] = useState(() => {
if (!os.auth.user) return false;
// Skip email verification for guest users and in local development
@@ -90,9 +91,7 @@ export function VerificationModal() {
// Check for a pending redirect (e.g. team invite page) after email verification
const pendingRedirect = sessionStorage.getItem("post_auth_redirect");
sessionStorage.removeItem("post_auth_redirect");
- if (pendingRedirect && pendingRedirect.startsWith("/") && !pendingRedirect.startsWith("//")) {
- navigate({ to: pendingRedirect });
- }
+ navigateToSafeInternalRedirect(router.history, pendingRedirect);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
diff --git a/frontend/src/components/apikeys/ApiKeyDashboard.tsx b/frontend/src/components/apikeys/ApiKeyDashboard.tsx
deleted file mode 100644
index 22a29071f..000000000
--- a/frontend/src/components/apikeys/ApiKeyDashboard.tsx
+++ /dev/null
@@ -1,282 +0,0 @@
-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 {
- Plus,
- Loader2,
- Sparkles,
- Zap,
- Shield,
- Rocket,
- Server,
- Key,
- CreditCard
-} from "lucide-react";
-import { CreateApiKeyDialog } from "./CreateApiKeyDialog";
-import { ApiKeysList } from "./ApiKeysList";
-import { ApiCreditsSection } from "./ApiCreditsSection";
-import { ProxyConfigSection } from "./ProxyConfigSection";
-import { useOpenSecret } from "@opensecret/react";
-import { useQuery } from "@tanstack/react-query";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { useLocalState } from "@/state/useLocalState";
-import { useNavigate } from "@tanstack/react-router";
-import { isTauriDesktop } from "@/utils/platform";
-import { hasApiAccess } from "@/billing/billingAccess";
-
-interface ApiKey {
- name: string;
- created_at: string;
-}
-
-interface ApiKeyDashboardProps {
- showCreditSuccessMessage?: boolean;
-}
-
-export function ApiKeyDashboard({ showCreditSuccessMessage = false }: ApiKeyDashboardProps) {
- const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
- const isTauriDesktopPlatform = isTauriDesktop();
- const { listApiKeys, auth, createApiKey } = useOpenSecret();
- const { billingStatus } = useLocalState();
- const navigate = useNavigate();
-
- // Check if user has API access (Pro, Team, or Max plans only - not Starter)
- const isBillingLoading = billingStatus === null;
- const userHasApiAccess = hasApiAccess(billingStatus);
-
- // Fetch API keys
- const {
- data: apiKeys,
- isLoading,
- error,
- refetch
- } = useQuery({
- queryKey: ["apiKeys"],
- queryFn: async () => {
- const response = await listApiKeys();
- // Sort by creation date (newest first)
- 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 => {
- // Create a new API key for the proxy directly
- try {
- const response = await createApiKey(name);
- // Refetch to update the list
- await refetch();
- return response.key;
- } catch (error) {
- console.error("Failed to create API key for proxy:", error);
- throw error;
- }
- };
-
- // 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.
-
-
- >
- );
- }
-
- // Show upgrade prompt for users without API access (Free and Starter plans)
- if (!userHasApiAccess) {
- return (
- <>
-
-
-
- Unlock API Access
-
-
- Upgrade to a paid plan to access powerful API features
-
-
-
-
-
-
-
-
-
-
-
-
Programmatic Access
-
- Integrate Maple directly into your applications and workflows
-
-
-
-
-
-
-
-
-
-
Secure API Keys
-
- Create and manage multiple API keys with granular control
-
-
-
-
-
-
-
-
-
-
Extend Your Subscription
-
- Purchase extra credits to extend your usage when plan credits run out
-
-
-
-
-
-
-
-
- Starting at just $20/month with
- the Pro plan
-
-
-
-
-
- Use your plan credits via API, and purchase extra credits to extend your usage
-
-
-
- >
- );
- }
-
- return (
- <>
-
- API Access
-
- Manage API keys and configure access to Maple services.{" "}
-
- Read more
-
-
-
-
-
-
-
-
- Credits
-
-
-
- API Keys
-
- {isTauriDesktopPlatform && (
-
-
- Local Proxy
-
- )}
-
-
-
-
-
-
-
-
-
-
API Keys
-
- {/* Create button */}
-
-
- {/* API Keys list */}
- {apiKeys && apiKeys.length > 0 && (
-
- )}
-
- {/* Info text */}
-
-
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/apikeys/ApiKeyManagementDialog.tsx b/frontend/src/components/apikeys/ApiKeyManagementDialog.tsx
deleted file mode 100644
index 84448a40d..000000000
--- a/frontend/src/components/apikeys/ApiKeyManagementDialog.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Dialog, DialogContent } from "@/components/ui/dialog";
-import { ApiKeyDashboard } from "./ApiKeyDashboard";
-
-interface ApiKeyManagementDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- showCreditSuccessMessage?: boolean;
-}
-
-export function ApiKeyManagementDialog({
- open,
- onOpenChange,
- showCreditSuccessMessage = false
-}: ApiKeyManagementDialogProps) {
- return (
-
- );
-}
diff --git a/frontend/src/components/apikeys/ApiKeysList.tsx b/frontend/src/components/apikeys/ApiKeysList.tsx
deleted file mode 100644
index f097eeb18..000000000
--- a/frontend/src/components/apikeys/ApiKeysList.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Trash2, Calendar, Loader2 } from "lucide-react";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle
-} from "@/components/ui/alert-dialog";
-import { useOpenSecret } from "@opensecret/react";
-
-interface ApiKey {
- name: string;
- created_at: string;
-}
-
-interface ApiKeysListProps {
- apiKeys: ApiKey[];
- onKeyDeleted: () => void;
-}
-
-export function ApiKeysList({ apiKeys, onKeyDeleted }: ApiKeysListProps) {
- const [deleteKeyName, setDeleteKeyName] = useState(null);
- const [isDeleting, setIsDeleting] = useState(false);
- const { deleteApiKey } = useOpenSecret();
-
- const handleDelete = async () => {
- if (!deleteKeyName) return;
-
- setIsDeleting(true);
- try {
- await deleteApiKey(deleteKeyName);
- console.log(`API key "${deleteKeyName}" deleted successfully`);
- onKeyDeleted();
- } catch (error) {
- console.error("Failed to delete API key:", error);
- } finally {
- setIsDeleting(false);
- setDeleteKeyName(null);
- }
- };
-
- const formatDate = (dateString: string) => {
- const date = new Date(dateString);
- return new Intl.DateTimeFormat("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit"
- }).format(date);
- };
-
- return (
- <>
-
-
- {apiKeys.map((key) => (
-
-
-
{key.name}
-
-
- Created {formatDate(key.created_at)}
-
-
-
-
- ))}
-
-
-
- {/* Delete confirmation dialog */}
- setDeleteKeyName(null)}>
-
-
- Delete API Key
-
- Are you sure you want to delete the API key "{deleteKeyName}"? This action cannot be
- undone and any applications using this key will stop working immediately.
-
-
-
- Cancel
-
- {isDeleting ? (
- <>
-
- Deleting...
- >
- ) : (
- "Delete"
- )}
-
-
-
-
- >
- );
-}
diff --git a/frontend/src/components/apikeys/CreateApiKeyDialog.tsx b/frontend/src/components/apikeys/CreateApiKeyDialog.tsx
deleted file mode 100644
index c741346ae..000000000
--- a/frontend/src/components/apikeys/CreateApiKeyDialog.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-import { useState } from "react";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Copy, CheckCircle, Loader2, AlertCircle } from "lucide-react";
-import { useOpenSecret } from "@opensecret/react";
-
-interface CreateApiKeyDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onKeyCreated: () => void;
-}
-
-export function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDialogProps) {
- const [keyName, setKeyName] = useState("");
- const [isCreating, setIsCreating] = useState(false);
- const [createdKey, setCreatedKey] = useState(null);
- const [copied, setCopied] = useState(false);
- const [error, setError] = useState(null);
- const { createApiKey } = useOpenSecret();
-
- const handleCreate = async () => {
- const trimmedName = keyName.trim();
-
- // Validation
- if (!trimmedName) {
- setError("Please enter a name for your API key");
- return;
- }
-
- if (trimmedName.length > 100) {
- setError("API key name must be 100 characters or less");
- return;
- }
-
- setIsCreating(true);
- setError(null);
-
- try {
- const response = await createApiKey(trimmedName);
- setCreatedKey(response.key);
- console.log("API key created successfully");
- } catch (error) {
- console.error("Failed to create API key:", error);
-
- // Handle specific error cases with user-friendly messages
- const err = error as { status?: number; message?: string };
- if (err?.status === 409 || err?.message?.toLowerCase().includes("conflict")) {
- setError(
- `An API key named "${trimmedName}" already exists. Please choose a different name.`
- );
- } else if (err?.status === 400 || err?.message?.toLowerCase().includes("invalid")) {
- setError(
- "Invalid API key name. Please use only letters, numbers, spaces, hyphens, and underscores."
- );
- } else if (err?.status === 401 || err?.message?.toLowerCase().includes("unauthorized")) {
- setError("You are not authorized to create API keys. Please check your subscription plan.");
- } else if (err?.status === 429 || err?.message?.toLowerCase().includes("limit")) {
- setError(
- "You've reached the maximum number of API keys. Please delete an existing key first."
- );
- } else {
- setError(err?.message || "Failed to create API key. Please try again.");
- }
- } finally {
- setIsCreating(false);
- }
- };
-
- const handleCopy = async () => {
- if (!createdKey) return;
-
- try {
- await navigator.clipboard.writeText(createdKey);
- setCopied(true);
- console.log("API key copied to clipboard");
- setTimeout(() => setCopied(false), 2000);
- } catch (error) {
- console.error("Failed to copy:", error);
- }
- };
-
- const handleClose = () => {
- if (createdKey) {
- onKeyCreated();
- }
- // Reset state
- setKeyName("");
- setCreatedKey(null);
- setCopied(false);
- setError(null);
- onOpenChange(false);
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/components/settings/AboutSettings.tsx b/frontend/src/components/settings/AboutSettings.tsx
new file mode 100644
index 000000000..b3809f9a1
--- /dev/null
+++ b/frontend/src/components/settings/AboutSettings.tsx
@@ -0,0 +1,57 @@
+import { Link } from "@tanstack/react-router";
+import { ExternalLink, FileText, Info, Mail, Shield } from "lucide-react";
+import packageJson from "../../../package.json";
+import { Button } from "@/components/ui/button";
+import { openExternalUrl } from "@/utils/openUrl";
+import { SettingsPage, SettingsSection } from "./SettingsPage";
+
+type ExternalRowProps = {
+ label: string;
+ url: string;
+ icon: typeof Shield;
+};
+
+function ExternalRow({ label, url, icon: Icon }: ExternalRowProps) {
+ return (
+
+ );
+}
+
+export function AboutSettings() {
+ return (
+
+
+
+
+
+ A private AI workspace built for secure research and collaboration.
+
+
Version {packageJson.version}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/AccountSettings.tsx b/frontend/src/components/settings/AccountSettings.tsx
new file mode 100644
index 000000000..d6e5557e8
--- /dev/null
+++ b/frontend/src/components/settings/AccountSettings.tsx
@@ -0,0 +1,206 @@
+import { useState } from "react";
+import { Link } from "@tanstack/react-router";
+import { useOpenSecret } from "@opensecret/react";
+import {
+ CheckCircle,
+ ChevronRight,
+ MessageSquareText,
+ Monitor,
+ Moon,
+ ShieldCheck,
+ Sun,
+ Trash2,
+ XCircle
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useTheme } from "@/contexts/ThemeContext";
+import { useLocalState } from "@/state/useLocalState";
+import { SettingsPage, SettingsSection } from "./SettingsPage";
+
+type SettingsLinkRowProps = {
+ to: "/settings/preferences" | "/settings/security" | "/settings/delete-account";
+ title: string;
+ description: string;
+ icon: typeof MessageSquareText;
+ danger?: boolean;
+};
+
+function SettingsLinkRow({ to, title, description, icon: Icon, danger }: SettingsLinkRowProps) {
+ return (
+
+
+
+
+
+
+ {title}
+
+
{description}
+
+
+
+ );
+}
+
+export function AccountSettings() {
+ const os = useOpenSecret();
+ const { billingStatus } = useLocalState();
+ const { theme, setTheme } = useTheme();
+ const [verificationStatus, setVerificationStatus] = useState<"unverified" | "pending">(
+ "unverified"
+ );
+
+ const user = os.auth.user?.user;
+ const isEmailUser = user?.login_method === "email";
+ const isGuestUser = user?.login_method?.toLowerCase() === "guest";
+
+ const handleResendVerification = async () => {
+ try {
+ await os.requestNewVerificationEmail();
+ setVerificationStatus("pending");
+ } catch (error) {
+ console.error("Failed to resend verification email:", error);
+ }
+ };
+
+ const periodLabel =
+ billingStatus?.payment_provider === "subscription_pass" ||
+ billingStatus?.payment_provider === "zaprite"
+ ? "Expires"
+ : "Renews";
+
+ return (
+
+
+
+ {!isGuestUser && (
+
+
+
+
+ {user?.email_verified ? (
+
+ ) : (
+
+ )}
+
+ {!user?.email_verified && (
+
+ {verificationStatus === "unverified" ? (
+ <>
+ Unverified —{" "}
+
+ >
+ ) : (
+ "Verification email sent. Check your inbox."
+ )}
+
+ )}
+
+ )}
+
+
+
+
+
+ {billingStatus ? `${billingStatus.product_name} Plan` : "Loading..."}
+
+ {billingStatus?.current_period_end && (
+
+ {periodLabel} on{" "}
+ {new Date(Number(billingStatus.current_period_end) * 1000).toLocaleDateString(
+ undefined,
+ { year: "numeric", month: "long", day: "numeric" }
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(isEmailUser || isGuestUser) && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/BillingSettings.tsx b/frontend/src/components/settings/BillingSettings.tsx
new file mode 100644
index 000000000..395b92c92
--- /dev/null
+++ b/frontend/src/components/settings/BillingSettings.tsx
@@ -0,0 +1,110 @@
+import { useState } from "react";
+import { Link } from "@tanstack/react-router";
+import { CreditCard, KeyRound, Loader2, Sparkles } from "lucide-react";
+import { openBillingPortal } from "@/billing/billingPortal";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { useLocalState } from "@/state/useLocalState";
+import { SettingsPage, SettingsSection } from "./SettingsPage";
+
+export function BillingSettings() {
+ const { billingStatus } = useLocalState();
+ const [isPortalLoading, setIsPortalLoading] = useState(false);
+ const [portalError, setPortalError] = useState(null);
+
+ const productName = billingStatus?.product_name ?? "";
+ const normalizedProductName = productName.toLowerCase();
+ const hasStripeAccount = !!billingStatus?.stripe_customer_id;
+ const isPaidPlan = ["starter", "pro", "max", "team"].some((plan) =>
+ normalizedProductName.includes(plan)
+ );
+ const showManage = isPaidPlan && hasStripeAccount;
+ const showUpgrade =
+ !normalizedProductName.includes("max") && !normalizedProductName.includes("team");
+
+ const handleManageSubscription = async () => {
+ if (!showManage) return;
+ setIsPortalLoading(true);
+ setPortalError(null);
+ try {
+ await openBillingPortal();
+ } catch (error) {
+ console.error("Error opening billing portal:", error);
+ setPortalError(
+ "Unable to open subscription management. Please try again or contact support@trymaple.ai."
+ );
+ } finally {
+ setIsPortalLoading(false);
+ }
+ };
+
+ const periodLabel =
+ billingStatus?.payment_provider === "subscription_pass" ||
+ billingStatus?.payment_provider === "zaprite"
+ ? "Expires"
+ : "Renews";
+
+ return (
+
+
+
+
+
+
+
+ {billingStatus ? `${billingStatus.product_name} Plan` : "Loading plan..."}
+
+
+ {billingStatus?.current_period_end && (
+
+ {periodLabel} on{" "}
+ {new Date(Number(billingStatus.current_period_end) * 1000).toLocaleDateString(
+ undefined,
+ { year: "numeric", month: "long", day: "numeric" }
+ )}
+
+ )}
+
+
+ {showUpgrade && (
+
+ )}
+ {showManage && (
+
+ )}
+
+
+ {portalError && (
+
+ {portalError}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/DeleteAccountSettings.tsx b/frontend/src/components/settings/DeleteAccountSettings.tsx
new file mode 100644
index 000000000..498dad848
--- /dev/null
+++ b/frontend/src/components/settings/DeleteAccountSettings.tsx
@@ -0,0 +1,203 @@
+import { useRef, useState } from "react";
+import { Link, useBlocker } from "@tanstack/react-router";
+import { useQueryClient } from "@tanstack/react-query";
+import { generateSecureSecret, hashSecret, useOpenSecret } from "@opensecret/react";
+import { AlertTriangle, Loader2 } from "lucide-react";
+import { getBillingService } from "@/billing/billingService";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import { clearAgentDataForUser } from "@/services/agentRuntimeService";
+import { SettingsPage, SettingsSection } from "./SettingsPage";
+
+export function DeleteAccountSettings() {
+ const os = useOpenSecret();
+ const queryClient = useQueryClient();
+ const [step, setStep] = useState<"request" | "confirm">("request");
+ const [confirmationCode, setConfirmationCode] = useState("");
+ const [secret, setSecret] = useState("");
+ const [confirmText, setConfirmText] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [isAccountDeleted, setIsAccountDeleted] = useState(false);
+ const [error, setError] = useState(null);
+ const cleanupBlockRef = useRef> | null>(null);
+ const allowProgrammaticUnloadRef = useRef(false);
+ const isNavigationLocked = isLoading || isAccountDeleted;
+
+ useBlocker({
+ shouldBlockFn: () => isNavigationLocked,
+ disabled: !isNavigationLocked,
+ enableBeforeUnload: () => isNavigationLocked && !allowProgrammaticUnloadRef.current
+ });
+ useSettingsNavigationLock(isNavigationLocked);
+
+ const handleRequestDeletion = async () => {
+ if (confirmText !== "DELETE") {
+ setError("Please type DELETE to confirm.");
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ try {
+ const generatedSecret = generateSecureSecret();
+ await os.requestAccountDeletion(await hashSecret(generatedSecret));
+ setSecret(generatedSecret);
+ setStep("confirm");
+ } catch (requestError) {
+ console.error(requestError);
+ setError("Failed to request account deletion. Please try again.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ 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) {
+ // Clear local Agent data before the irreversible remote deletion.
+ cleanupBlockRef.current = await clearAgentDataForUser(userId);
+ agentDataCleared = true;
+ }
+
+ // Proxy credential reset is required before remote deletion so a crash
+ // cannot leave a deleted account's key on disk.
+ const { proxyService } = await import("@/services/proxyService");
+ await proxyService.stopAndResetProxy(userId, os.deleteApiKey);
+ proxyReset = true;
+
+ if (!deletionConfirmed) {
+ await os.confirmAccountDeletion(confirmationCode, secret);
+ deletionConfirmed = true;
+ setIsAccountDeleted(true);
+ cleanupBlockRef.current.retainUntilNextSession();
+ }
+
+ try {
+ getBillingService().clearToken();
+ } catch (clearError) {
+ console.error("Error clearing billing token:", clearError);
+ sessionStorage.removeItem("maple_billing_token");
+ }
+
+ await os.signOut();
+ queryClient.clear();
+ allowProgrammaticUnloadRef.current = true;
+ window.location.href = "/";
+ } catch (confirmError) {
+ console.error(confirmError);
+ // If remote deletion did not happen, the authenticated user must be able
+ // to start a fresh Agent runtime after this attempt.
+ if (!deletionConfirmed) {
+ cleanupBlockRef.current?.release();
+ cleanupBlockRef.current = null;
+ }
+ setError(
+ !agentDataCleared
+ ? "Maple could not safely stop and clear local Agent Mode data. Your account was not deleted; please retry."
+ : deletionConfirmed
+ ? "Your account data was deleted, but Maple could not finish resetting local credentials or signing out. Please retry."
+ : !proxyReset
+ ? "Local Agent Mode history was cleared, but Maple could not reset its proxy credentials. Your account was not deleted; please retry."
+ : "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."
+ );
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ This action cannot be undone. You will lose access to paid features, chat history, and
+ settings.
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {step === "request" ? (
+
+
+ setConfirmText(event.target.value)}
+ autoComplete="off"
+ />
+
+ ) : (
+
+
+ setConfirmationCode(event.target.value)}
+ placeholder="Enter code from email"
+ autoComplete="one-time-code"
+ />
+
+ )}
+
+
+ {isNavigationLocked ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/HistorySettings.tsx b/frontend/src/components/settings/HistorySettings.tsx
new file mode 100644
index 000000000..21e415290
--- /dev/null
+++ b/frontend/src/components/settings/HistorySettings.tsx
@@ -0,0 +1,107 @@
+import { useState } from "react";
+import { useBlocker, useNavigate } from "@tanstack/react-router";
+import { useQueryClient } from "@tanstack/react-query";
+import { useOpenSecret } from "@opensecret/react";
+import { Loader2, Trash2 } from "lucide-react";
+import { AlertDestructive } from "@/components/AlertDestructive";
+import { Button } from "@/components/ui/button";
+import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import { clearAgentHistoryForUser } from "@/services/agentRuntimeService";
+import { SettingsPage, SettingsSection } from "./SettingsPage";
+
+export function HistorySettings() {
+ const os = useOpenSecret();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useBlocker({
+ shouldBlockFn: () => isDeleting,
+ disabled: !isDeleting,
+ enableBeforeUnload: isDeleting
+ });
+ useSettingsNavigationLock(isDeleting);
+
+ const handleDeleteHistory = async () => {
+ setError(null);
+ setIsDeleting(true);
+ let operationBlock: Awaited> | null = null;
+ try {
+ const conversations = await os.listConversations({ limit: 1 });
+ if (conversations.data?.length) {
+ await os.deleteConversations();
+ }
+
+ operationBlock = await clearAgentHistoryForUser(os.auth.user?.user.id);
+
+ queryClient.invalidateQueries({ queryKey: ["conversations"] });
+ queryClient.invalidateQueries({ queryKey: ["pinnedConversations"] });
+ queryClient.invalidateQueries({ queryKey: ["projectConversations"] });
+ queryClient.invalidateQueries({ queryKey: ["conversationProjects"] });
+ queryClient.invalidateQueries({ queryKey: ["conversationProject"] });
+ try {
+ await navigate({ to: "/", ignoreBlocker: true });
+ } 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 (deleteError) {
+ console.error("Error deleting chat history:", deleteError);
+ setError("Maple could not delete all chat history. Please try again.");
+ } finally {
+ operationBlock?.release();
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+
+
+
+ {error &&
}
+ {isConfirming ? (
+
+
Delete your entire chat history?
+
This action cannot be undone.
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/PreferencesSettings.tsx b/frontend/src/components/settings/PreferencesSettings.tsx
new file mode 100644
index 000000000..10c9bb8e1
--- /dev/null
+++ b/frontend/src/components/settings/PreferencesSettings.tsx
@@ -0,0 +1,132 @@
+import { useEffect, useState } from "react";
+import { useBlocker } from "@tanstack/react-router";
+import { useOpenSecret } from "@opensecret/react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import { SettingsPage, SettingsSection } from "./SettingsPage";
+
+export function PreferencesSettings() {
+ const os = useOpenSecret();
+ const [prompt, setPrompt] = useState("");
+ const [instructionId, setInstructionId] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+ useBlocker({
+ shouldBlockFn: () => isSaving,
+ disabled: !isSaving,
+ enableBeforeUnload: isSaving
+ });
+ useSettingsNavigationLock(isSaving);
+
+ useEffect(() => {
+ let active = true;
+
+ const loadPreferences = async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const response = await os.listInstructions({ limit: 100 });
+ if (!active) return;
+ const defaultInstruction = response.data.find((instruction) => instruction.is_default);
+ setInstructionId(defaultInstruction?.id ?? null);
+ setPrompt(defaultInstruction?.prompt ?? "");
+ } catch (loadError) {
+ console.error("Failed to load preferences:", loadError);
+ if (active) setError("Failed to load preferences. Please try again.");
+ } finally {
+ if (active) setIsLoading(false);
+ }
+ };
+
+ void loadPreferences();
+ return () => {
+ active = false;
+ };
+ }, [os]);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setError(null);
+ setSuccess(false);
+ setIsSaving(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);
+ }
+ setSuccess(true);
+ } catch (saveError) {
+ console.error("Failed to save preferences:", saveError);
+ setError("Failed to save preferences. Please try again.");
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/SecuritySettings.tsx b/frontend/src/components/settings/SecuritySettings.tsx
new file mode 100644
index 000000000..6345f3124
--- /dev/null
+++ b/frontend/src/components/settings/SecuritySettings.tsx
@@ -0,0 +1,146 @@
+import { useState } from "react";
+import { Link, useBlocker } from "@tanstack/react-router";
+import { useOpenSecret } from "@opensecret/react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import { SettingsPage, SettingsSection } from "./SettingsPage";
+
+export function SecuritySettings() {
+ 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);
+
+ useBlocker({
+ shouldBlockFn: () => isLoading,
+ disabled: !isLoading,
+ enableBeforeUnload: isLoading
+ });
+ useSettingsNavigationLock(isLoading);
+
+ const loginMethod = os.auth.user?.user.login_method?.toLowerCase();
+ const canChangePassword = loginMethod === "email" || loginMethod === "guest";
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.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);
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ } catch (changeError) {
+ console.error("Failed to change password:", changeError);
+ setError("Failed to change password. Please check your current password and try again.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ {canChangePassword ? (
+
+ ) : (
+
+
+ This account signs in through an external provider, so it does not have a Maple
+ password to change.
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsLayout.tsx b/frontend/src/components/settings/SettingsLayout.tsx
new file mode 100644
index 000000000..e7e0375d0
--- /dev/null
+++ b/frontend/src/components/settings/SettingsLayout.tsx
@@ -0,0 +1,502 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Link, Outlet, useLocation, useRouter } from "@tanstack/react-router";
+import { useOpenSecret } from "@opensecret/react";
+import {
+ ArrowLeft,
+ BookOpen,
+ CreditCard,
+ Database,
+ KeyRound,
+ LogOut,
+ Menu,
+ MessageSquareText,
+ Settings,
+ ShieldCheck,
+ UserRound,
+ UsersRound,
+ X
+} from "lucide-react";
+import { useEffect, useRef, useState, type ComponentType } from "react";
+import { getBillingService } from "@/billing/billingService";
+import { MapleWordmark } from "@/components/MapleWordmark";
+import { SettingsNavigationLockProvider } from "@/components/settings/SettingsNavigationLockProvider";
+import { useCompactSettingsLayout } from "@/components/settings/useCompactSettingsLayout";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { usePersistentHomeNavigation } from "@/contexts/PersistentHomeNavigationContext";
+import {
+ useSettingsNavigationLock,
+ useSettingsNavigationLockState
+} from "@/contexts/SettingsNavigationLockContext";
+import { stopAgentRuntimeForUser } from "@/services/agentRuntimeService";
+import { useLocalState } from "@/state/useLocalState";
+import type { TeamStatus } from "@/types/team";
+import { isIOS } from "@/utils/platform";
+import { getTeamSeatMismatch } from "@/utils/teamSeats";
+import { cn } from "@/utils/utils";
+import packageJson from "../../../package.json";
+
+type SettingsNavItem = {
+ label: string;
+ to:
+ | "/settings/account"
+ | "/settings/preferences"
+ | "/settings/security"
+ | "/settings/billing"
+ | "/settings/team"
+ | "/settings/api"
+ | "/settings/history"
+ | "/settings/about";
+ icon: ComponentType<{ className?: string }>;
+ badge?: string;
+ badgeTone?: "warning" | "danger";
+};
+
+function SettingsNavLink({
+ item,
+ onSelect,
+ replace
+}: {
+ item: SettingsNavItem;
+ onSelect?: () => void;
+ replace?: boolean;
+}) {
+ const Icon = item.icon;
+ const isNavigationLocked = useSettingsNavigationLockState();
+ const attentionLabel =
+ item.badge === "Paused"
+ ? "Team usage paused"
+ : item.badge === "Setup"
+ ? "Team setup required"
+ : undefined;
+
+ return (
+ {
+ if (isNavigationLocked) {
+ event.preventDefault();
+ return;
+ }
+
+ onSelect?.();
+ }}
+ activeProps={{
+ className:
+ "bg-[hsl(var(--sidebar-chrome))] text-foreground shadow-sm dark:bg-[hsl(var(--sidebar-chrome-hover))]"
+ }}
+ inactiveProps={{
+ className: "text-muted-foreground hover:bg-background/70 hover:text-foreground"
+ }}
+ className={cn(
+ "group flex min-h-11 items-center justify-start gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
+ isNavigationLocked && "cursor-not-allowed opacity-50"
+ )}
+ >
+
+
+
+ {item.label}
+ {item.badge && (
+
+ {item.badge}
+
+ )}
+
+ );
+}
+
+function SettingsLayoutContent() {
+ const os = useOpenSecret();
+ const router = useRouter();
+ const location = useLocation();
+ const queryClient = useQueryClient();
+ const { returnToHome } = usePersistentHomeNavigation();
+ const { billingStatus, setBillingStatus } = useLocalState();
+ const isNavigationLocked = useSettingsNavigationLockState();
+ const isCompactViewport = useCompactSettingsLayout();
+ const isSettingsRoot = location.pathname === "/settings" || location.pathname === "/settings/";
+ const isAuthReady = !os.auth.loading && !!os.auth.user;
+ const [isDrawerOpen, setIsDrawerOpen] = useState(() => isCompactViewport && isSettingsRoot);
+ const previousPathnameRef = useRef(location.pathname);
+ const drawerRef = useRef(null);
+ const drawerCloseButtonRef = useRef(null);
+ const menuButtonRef = useRef(null);
+ const mainRef = useRef(null);
+ const [isSigningOut, setIsSigningOut] = useState(false);
+ const [signOutError, setSignOutError] = useState(null);
+
+ useSettingsNavigationLock(isSigningOut);
+
+ useEffect(() => {
+ if (!os.auth.loading && !os.auth.user) {
+ void router.navigate({
+ to: "/login",
+ search: { next: location.href },
+ replace: true
+ });
+ }
+ }, [location.href, os.auth.loading, os.auth.user, router]);
+
+ useEffect(() => {
+ const pathChanged = previousPathnameRef.current !== location.pathname;
+ previousPathnameRef.current = location.pathname;
+
+ if (!isCompactViewport) {
+ setIsDrawerOpen(false);
+ return;
+ }
+
+ if (isSettingsRoot) {
+ setIsDrawerOpen(true);
+ } else if (pathChanged) {
+ setIsDrawerOpen(false);
+ }
+ }, [isCompactViewport, isSettingsRoot, location.pathname]);
+
+ useEffect(() => {
+ if (!isAuthReady) return;
+
+ const drawer = drawerRef.current;
+ const main = mainRef.current;
+
+ if (drawer) {
+ drawer.inert = isCompactViewport && !isDrawerOpen;
+ }
+ if (main) {
+ main.inert = isCompactViewport && isDrawerOpen;
+ }
+
+ return () => {
+ if (drawer) drawer.inert = false;
+ if (main) main.inert = false;
+ };
+ }, [isAuthReady, isCompactViewport, isDrawerOpen]);
+
+ useEffect(() => {
+ if (!isAuthReady || !isCompactViewport || !isDrawerOpen) return;
+
+ const frame = window.requestAnimationFrame(() => drawerCloseButtonRef.current?.focus());
+ return () => window.cancelAnimationFrame(frame);
+ }, [isAuthReady, isCompactViewport, isDrawerOpen]);
+
+ useEffect(() => {
+ if (!isCompactViewport || !isDrawerOpen) return;
+
+ const dismissOnEscape = (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ setIsDrawerOpen(false);
+ window.requestAnimationFrame(() => menuButtonRef.current?.focus());
+ };
+
+ window.addEventListener("keydown", dismissOnEscape);
+ return () => window.removeEventListener("keydown", dismissOnEscape);
+ }, [isCompactViewport, isDrawerOpen]);
+
+ const { data: currentBillingStatus } = useQuery({
+ queryKey: ["billingStatus"],
+ queryFn: async () => {
+ const status = await getBillingService().getBillingStatus();
+ setBillingStatus(status);
+ return status;
+ },
+ enabled: !!os.auth.user
+ });
+
+ const resolvedBillingStatus = currentBillingStatus ?? billingStatus;
+ const productName = resolvedBillingStatus?.product_name?.toLowerCase() ?? "";
+ const isTeamPlan = productName.includes("team");
+
+ const { data: teamStatus } = useQuery({
+ queryKey: ["teamStatus"],
+ queryFn: () => getBillingService().getTeamStatus(),
+ enabled: !!os.auth.user && isTeamPlan
+ });
+
+ const isIOSPlatform = isIOS();
+ const { data: products, isError: productsError } = useQuery({
+ queryKey: ["products-version-check", isIOSPlatform],
+ queryFn: () => getBillingService().getProducts(`v${packageJson.version}`),
+ enabled: isIOSPlatform && !!os.auth.user
+ });
+
+ if (os.auth.loading || !os.auth.user) {
+ return null;
+ }
+
+ const teamSeatMismatch = getTeamSeatMismatch(teamStatus);
+ const needsTeamSetup = !!teamStatus?.has_team_subscription && teamStatus.team_created === false;
+ const showApiManagement =
+ !isIOSPlatform ||
+ productsError ||
+ !!products?.some((product) => product.is_available !== false);
+
+ const sections: Array<{ label: string; items: SettingsNavItem[] }> = [
+ {
+ label: "Personal",
+ items: [
+ { label: "Account", to: "/settings/account", icon: UserRound },
+ { label: "Preferences", to: "/settings/preferences", icon: MessageSquareText },
+ { label: "Security", to: "/settings/security", icon: ShieldCheck }
+ ]
+ },
+ {
+ label: "Plan",
+ items: [
+ { label: "Billing", to: "/settings/billing", icon: CreditCard },
+ ...(isTeamPlan
+ ? [
+ {
+ label: "Team",
+ to: "/settings/team" as const,
+ icon: UsersRound,
+ badge: teamSeatMismatch ? "Paused" : needsTeamSetup ? "Setup" : undefined,
+ badgeTone: teamSeatMismatch ? ("danger" as const) : ("warning" as const)
+ }
+ ]
+ : [])
+ ]
+ },
+ {
+ label: "Developer",
+ items: showApiManagement
+ ? [{ label: "API & credits", to: "/settings/api", icon: KeyRound }]
+ : []
+ },
+ {
+ label: "Data",
+ items: [{ label: "Chat history", to: "/settings/history", icon: Database }]
+ },
+ {
+ label: "Maple",
+ items: [{ label: "About", to: "/settings/about", icon: BookOpen }]
+ }
+ ];
+
+ const signOut = async () => {
+ if (isNavigationLocked || isSigningOut) return;
+
+ setSignOutError(null);
+ setIsSigningOut(true);
+ let operationBlock: Awaited> | null = null;
+ let signedOut = false;
+
+ // Never sign out while this account may still have Agent tools executing.
+ try {
+ operationBlock = await stopAgentRuntimeForUser(os.auth.user?.user.id);
+ } catch (error) {
+ console.error("Error stopping Agent Mode:", error);
+ setSignOutError("Maple could not stop Agent Mode. Please try logging out again.");
+ setIsSigningOut(false);
+ return;
+ }
+
+ try {
+ // Credential reset is required before logout so the next account cannot
+ // inherit this user's local proxy key.
+ const { proxyService } = await import("@/services/proxyService");
+ await proxyService.stopAndResetProxy(os.auth.user?.user.id, os.deleteApiKey);
+
+ try {
+ getBillingService().clearToken();
+ } catch (error) {
+ console.error("Error clearing billing token:", error);
+ sessionStorage.removeItem("maple_billing_token");
+ }
+
+ await os.signOut();
+ signedOut = true;
+ queryClient.clear();
+ await router.invalidate();
+ await router.navigate({ to: "/" });
+ } catch (error) {
+ console.error("Error during sign out:", error);
+ if (signedOut) {
+ window.location.href = "/";
+ return;
+ }
+ setSignOutError(
+ "Maple could not securely reset Agent Mode or finish logging out. Please try again."
+ );
+ } finally {
+ if (!signedOut) {
+ operationBlock.release();
+ setIsSigningOut(false);
+ } else {
+ operationBlock.retainUntilNextSession();
+ }
+ }
+ };
+
+ const closeSettings = () => {
+ if (isNavigationLocked || isSigningOut) return;
+ returnToHome();
+ };
+
+ const openDrawer = () => {
+ if (isNavigationLocked || isSigningOut) return;
+ setIsDrawerOpen(true);
+ };
+
+ const closeDrawer = () => {
+ setIsDrawerOpen(false);
+ window.requestAnimationFrame(() => menuButtonRef.current?.focus());
+ };
+
+ return (
+
+
+
+
+ {isCompactViewport && (
+
+ )}
+
+
+
+ );
+}
+
+export function SettingsLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsNavigationLockProvider.tsx b/frontend/src/components/settings/SettingsNavigationLockProvider.tsx
new file mode 100644
index 000000000..ebc8b54e5
--- /dev/null
+++ b/frontend/src/components/settings/SettingsNavigationLockProvider.tsx
@@ -0,0 +1,34 @@
+import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
+import {
+ SettingsNavigationLockContext,
+ type SettingsNavigationLockContextValue
+} from "@/contexts/SettingsNavigationLockContext";
+
+export function SettingsNavigationLockProvider({ children }: { children: ReactNode }) {
+ const locksRef = useRef(new Set());
+ const [lockCount, setLockCount] = useState(0);
+
+ const setLock = useCallback((id: symbol, locked: boolean) => {
+ const locks = locksRef.current;
+ const wasLocked = locks.has(id);
+
+ if (locked && !wasLocked) {
+ locks.add(id);
+ setLockCount(locks.size);
+ } else if (!locked && wasLocked) {
+ locks.delete(id);
+ setLockCount(locks.size);
+ }
+ }, []);
+
+ const value = useMemo(
+ () => ({ isNavigationLocked: lockCount > 0, setLock }),
+ [lockCount, setLock]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx
new file mode 100644
index 000000000..fac2fb8c2
--- /dev/null
+++ b/frontend/src/components/settings/SettingsPage.tsx
@@ -0,0 +1,62 @@
+import type { ReactNode } from "react";
+import { cn } from "@/utils/utils";
+
+type SettingsPageProps = {
+ title: string;
+ description: string;
+ children: ReactNode;
+ actions?: ReactNode;
+};
+
+export function SettingsPage({ title, description, children, actions }: SettingsPageProps) {
+ return (
+
+
+
+
{title}
+
+ {description}
+
+
+ {actions && {actions}
}
+
+
{children}
+
+ );
+}
+
+type SettingsSectionProps = {
+ title?: string;
+ description?: string;
+ children: ReactNode;
+ className?: string;
+ tone?: "default" | "danger";
+};
+
+export function SettingsSection({
+ title,
+ description,
+ children,
+ className,
+ tone = "default"
+}: SettingsSectionProps) {
+ return (
+
+ {(title || description) && (
+
+ {title &&
{title}
}
+ {description && (
+
{description}
+ )}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/settings/api/ApiCreditsSettings.tsx b/frontend/src/components/settings/api/ApiCreditsSettings.tsx
new file mode 100644
index 000000000..2dffe8c7e
--- /dev/null
+++ b/frontend/src/components/settings/api/ApiCreditsSettings.tsx
@@ -0,0 +1,9 @@
+import { ApiCreditsSection } from "@/components/apikeys/ApiCreditsSection";
+
+type ApiCreditsSettingsProps = {
+ showCreditSuccessMessage?: boolean;
+};
+
+export function ApiCreditsSettings({ showCreditSuccessMessage = false }: ApiCreditsSettingsProps) {
+ return ;
+}
diff --git a/frontend/src/components/settings/api/ApiKeysSettings.tsx b/frontend/src/components/settings/api/ApiKeysSettings.tsx
new file mode 100644
index 000000000..85af74def
--- /dev/null
+++ b/frontend/src/components/settings/api/ApiKeysSettings.tsx
@@ -0,0 +1,170 @@
+import { useState } from "react";
+import { Link, useBlocker } from "@tanstack/react-router";
+import { useOpenSecret } from "@opensecret/react";
+import { AlertCircle, Calendar, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import { SettingsSection } from "../SettingsPage";
+import { useApiKeys } from "./useApiKeys";
+
+function formatDate(dateString: string) {
+ return new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ }).format(new Date(dateString));
+}
+
+export function ApiKeysSettings() {
+ const { deleteApiKey } = useOpenSecret();
+ const { data: apiKeys, isLoading, error, refetch } = useApiKeys();
+ const [pendingDelete, setPendingDelete] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [deleteError, setDeleteError] = useState(null);
+
+ useBlocker({
+ shouldBlockFn: () => isDeleting,
+ disabled: !isDeleting,
+ enableBeforeUnload: isDeleting
+ });
+ useSettingsNavigationLock(isDeleting);
+
+ const handleDelete = async () => {
+ if (!pendingDelete) return;
+
+ setIsDeleting(true);
+ setDeleteError(null);
+ try {
+ await deleteApiKey(pendingDelete);
+ await refetch();
+ setPendingDelete(null);
+ } catch (deleteFailure) {
+ console.error("Failed to delete API key:", deleteFailure);
+ setDeleteError("Failed to delete this API key. Please try again.");
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {isLoading && (
+
+
+ Loading API keys...
+
+ )}
+
+ {error && (
+
+
+ Failed to load API keys. Please try again.
+
+ )}
+
+ {!isLoading && !error && apiKeys?.length === 0 && (
+
+
+
No API keys yet
+
+ Create a key when you are ready to connect an application or workflow.
+
+
+ )}
+
+ {!!apiKeys?.length && (
+
+ {apiKeys.map((apiKey) => {
+ const isConfirmingDelete = pendingDelete === apiKey.name;
+
+ return (
+
+
+
+
{apiKey.name}
+
+
+ Created {formatDate(apiKey.created_at)}
+
+
+ {!isConfirmingDelete && (
+
+ )}
+
+
+ {isConfirmingDelete && (
+
+
Delete this API key?
+
+ Applications using this key will stop working immediately. This cannot be
+ undone.
+
+ {deleteError && (
+
{deleteError}
+ )}
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
API keys allow you to integrate Maple into applications and workflows.
+
Keep keys secure and never share them publicly. Treat them like passwords.
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/api/ApiSettingsLayout.tsx b/frontend/src/components/settings/api/ApiSettingsLayout.tsx
new file mode 100644
index 000000000..0734c007e
--- /dev/null
+++ b/frontend/src/components/settings/api/ApiSettingsLayout.tsx
@@ -0,0 +1,222 @@
+import { Link, Outlet } from "@tanstack/react-router";
+import { useQuery } from "@tanstack/react-query";
+import {
+ AlertCircle,
+ CreditCard,
+ KeyRound,
+ Loader2,
+ RotateCw,
+ Server,
+ Sparkles
+} from "lucide-react";
+import { hasApiAccess } from "@/billing/billingAccess";
+import { getBillingService } from "@/billing/billingService";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { useLocalState } from "@/state/useLocalState";
+import { openExternalUrl } from "@/utils/openUrl";
+import { isIOS, isTauriDesktop } from "@/utils/platform";
+import { cn } from "@/utils/utils";
+import { SettingsPage, SettingsSection } from "../SettingsPage";
+import packageJson from "../../../../package.json";
+
+type ApiNavLinkProps = {
+ to: "/settings/api" | "/settings/api/keys" | "/settings/api/proxy";
+ label: string;
+ icon: typeof CreditCard;
+ exact?: boolean;
+};
+
+function ApiNavLink({ to, label, icon: Icon, exact = false }: ApiNavLinkProps) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function ApiSettingsLoading() {
+ return (
+
+
+
+
+ Loading API settings...
+
+
+
+ );
+}
+
+export function ApiSettingsLayout() {
+ const { billingStatus, setBillingStatus } = useLocalState();
+ const isIOSPlatform = isIOS();
+ const isTauriDesktopPlatform = isTauriDesktop();
+
+ const {
+ data: currentBillingStatus,
+ isLoading: billingStatusLoading,
+ isError: billingStatusError,
+ refetch: refetchBillingStatus
+ } = useQuery({
+ queryKey: ["billingStatus"],
+ queryFn: async () => {
+ const status = await getBillingService().getBillingStatus();
+ setBillingStatus(status);
+ return status;
+ }
+ });
+
+ const {
+ data: products,
+ isLoading: productsLoading,
+ isError: productsError,
+ refetch: refetchProducts
+ } = useQuery({
+ queryKey: ["products-version-check", isIOSPlatform],
+ queryFn: () => getBillingService().getProducts(`v${packageJson.version}`),
+ enabled: isIOSPlatform
+ });
+
+ const resolvedBillingStatus = currentBillingStatus ?? billingStatus;
+
+ if (billingStatusError && resolvedBillingStatus === null) {
+ return (
+
+
+
+
+ Unable to load your billing status.
+
+
+
+
+ );
+ }
+
+ if (
+ (billingStatusLoading && resolvedBillingStatus === null) ||
+ (isIOSPlatform && productsLoading)
+ ) {
+ return ;
+ }
+
+ if (isIOSPlatform && productsError) {
+ return (
+
+
+
+
+ Unable to confirm API availability for this app version.
+
+
+
+
+ );
+ }
+
+ const userHasApiAccess = hasApiAccess(resolvedBillingStatus);
+ const isApprovedIOSVersion =
+ !isIOSPlatform || !!products?.some((product) => product.is_available !== false);
+
+ if (!isApprovedIOSVersion) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!userHasApiAccess) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const navColumnClass = isTauriDesktopPlatform ? "grid-cols-3" : "grid-cols-2";
+
+ return (
+
+ void openExternalUrl("https://blog.trymaple.ai/maple-proxy-documentation/")
+ }
+ className="text-sm font-medium text-[hsl(var(--maple-primary-strong))] underline underline-offset-4 hover:text-[hsl(var(--maple-primary))]"
+ >
+ API documentation
+
+ }
+ >
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/api/CreateApiKeySettings.tsx b/frontend/src/components/settings/api/CreateApiKeySettings.tsx
new file mode 100644
index 000000000..79a281c1d
--- /dev/null
+++ b/frontend/src/components/settings/api/CreateApiKeySettings.tsx
@@ -0,0 +1,192 @@
+import { useState } from "react";
+import { useBlocker, useNavigate } from "@tanstack/react-router";
+import { useQueryClient } from "@tanstack/react-query";
+import { useOpenSecret } from "@opensecret/react";
+import { AlertCircle, CheckCircle, Copy, Loader2 } from "lucide-react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import { SettingsSection } from "../SettingsPage";
+
+export function CreateApiKeySettings() {
+ const { createApiKey } = useOpenSecret();
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const [keyName, setKeyName] = useState("");
+ const [createdKey, setCreatedKey] = useState(null);
+ const [isCreating, setIsCreating] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [error, setError] = useState(null);
+
+ useBlocker({
+ shouldBlockFn: () => isCreating || createdKey !== null,
+ disabled: !isCreating && createdKey === null,
+ enableBeforeUnload: isCreating || createdKey !== null
+ });
+ useSettingsNavigationLock(isCreating || createdKey !== null);
+
+ const handleCreate = async () => {
+ const trimmedName = keyName.trim();
+
+ if (!trimmedName) {
+ setError("Please enter a name for your API key");
+ return;
+ }
+
+ if (trimmedName.length > 100) {
+ setError("API key name must be 100 characters or less");
+ return;
+ }
+
+ setIsCreating(true);
+ setError(null);
+ try {
+ const response = await createApiKey(trimmedName);
+ setCreatedKey(response.key);
+ } catch (createFailure) {
+ console.error("Failed to create API key:", createFailure);
+ const apiError = createFailure as { status?: number; message?: string };
+ if (apiError.status === 409 || apiError.message?.toLowerCase().includes("conflict")) {
+ setError(`An API key named "${trimmedName}" already exists. Choose a different name.`);
+ } else if (apiError.status === 400 || apiError.message?.toLowerCase().includes("invalid")) {
+ setError(
+ "Invalid API key name. Use only letters, numbers, spaces, hyphens, and underscores."
+ );
+ } else if (
+ apiError.status === 401 ||
+ apiError.message?.toLowerCase().includes("unauthorized")
+ ) {
+ setError("You are not authorized to create API keys. Check your subscription plan.");
+ } else if (apiError.status === 429 || apiError.message?.toLowerCase().includes("limit")) {
+ setError("You have reached the API key limit. Delete an existing key first.");
+ } else {
+ setError(apiError.message || "Failed to create API key. Please try again.");
+ }
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const handleCopy = async () => {
+ if (!createdKey) return;
+
+ try {
+ await navigator.clipboard.writeText(createdKey);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (copyFailure) {
+ console.error("Failed to copy API key:", copyFailure);
+ }
+ };
+
+ const handleDone = async () => {
+ await queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
+ await navigate({ to: "/settings/api/keys", replace: true, ignoreBlocker: true });
+ };
+
+ return (
+
+ {!createdKey ? (
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+ {
+ setKeyName(event.target.value);
+ setError(null);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && !isCreating) {
+ event.preventDefault();
+ void handleCreate();
+ }
+ }}
+ placeholder="e.g. Production App, Development"
+ maxLength={100}
+ disabled={isCreating}
+ autoFocus
+ />
+
+ {keyName.trim().length}/100 characters
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+ Copy this key now. You will not be able to view it again after selecting Done.
+
+
+
+
+
+
+ event.currentTarget.select()}
+ />
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/settings/api/LocalProxySettings.tsx b/frontend/src/components/settings/api/LocalProxySettings.tsx
new file mode 100644
index 000000000..957b5c064
--- /dev/null
+++ b/frontend/src/components/settings/api/LocalProxySettings.tsx
@@ -0,0 +1,43 @@
+import { useOpenSecret } from "@opensecret/react";
+import { AlertCircle, Loader2 } from "lucide-react";
+import { ProxyConfigSection } from "@/components/apikeys/ProxyConfigSection";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { SettingsSection } from "../SettingsPage";
+import { useApiKeys } from "./useApiKeys";
+
+export function LocalProxySettings() {
+ const { createApiKey } = useOpenSecret();
+ const { data: apiKeys, isLoading, error, refetch } = useApiKeys();
+
+ const handleRequestNewApiKey = async (name: string) => {
+ try {
+ const response = await createApiKey(name);
+ await refetch();
+ return response.key;
+ } catch (createFailure) {
+ console.error("Failed to create API key for proxy:", createFailure);
+ throw createFailure;
+ }
+ };
+
+ return (
+
+ {isLoading ? (
+
+
+ Loading proxy settings...
+
+ ) : error ? (
+
+
+ Failed to load API keys. Please try again.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/settings/api/useApiKeys.ts b/frontend/src/components/settings/api/useApiKeys.ts
new file mode 100644
index 000000000..b9926be70
--- /dev/null
+++ b/frontend/src/components/settings/api/useApiKeys.ts
@@ -0,0 +1,22 @@
+import { useQuery } from "@tanstack/react-query";
+import { useOpenSecret } from "@opensecret/react";
+
+export type ApiKeySummary = {
+ name: string;
+ created_at: string;
+};
+
+export function useApiKeys() {
+ const { auth, listApiKeys } = useOpenSecret();
+
+ return 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
+ });
+}
diff --git a/frontend/src/components/settings/team/TeamInviteSettings.tsx b/frontend/src/components/settings/team/TeamInviteSettings.tsx
new file mode 100644
index 000000000..4559faa22
--- /dev/null
+++ b/frontend/src/components/settings/team/TeamInviteSettings.tsx
@@ -0,0 +1,336 @@
+import { useState } from "react";
+import { Link, useBlocker } from "@tanstack/react-router";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useOpenSecret } from "@opensecret/react";
+import {
+ AlertCircle,
+ ArrowLeft,
+ CreditCard,
+ Info,
+ Loader2,
+ RotateCw,
+ UserPlus
+} from "lucide-react";
+import { openBillingPortal } from "@/billing/billingPortal";
+import { getBillingService } from "@/billing/billingService";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ useSettingsNavigationLock,
+ useSettingsNavigationLockState
+} from "@/contexts/SettingsNavigationLockContext";
+import { useLocalState } from "@/state/useLocalState";
+import type { TeamStatus } from "@/types/team";
+import { getTeamSeatMismatch } from "@/utils/teamSeats";
+import { SettingsPage, SettingsSection } from "../SettingsPage";
+
+function BackToTeamButton() {
+ const isNavigationLocked = useSettingsNavigationLockState();
+
+ if (isNavigationLocked) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export function TeamInviteSettings() {
+ const os = useOpenSecret();
+ const queryClient = useQueryClient();
+ const { billingStatus } = useLocalState();
+ const [emails, setEmails] = useState("");
+ const [isInviting, setIsInviting] = useState(false);
+ const [error, setError] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(null);
+ const [isPortalLoading, setIsPortalLoading] = useState(false);
+
+ useBlocker({
+ shouldBlockFn: () => isInviting,
+ disabled: !isInviting,
+ enableBeforeUnload: isInviting
+ });
+ useSettingsNavigationLock(isInviting);
+ const isNavigationLocked = useSettingsNavigationLockState();
+
+ const {
+ data: teamStatus,
+ isLoading,
+ isError,
+ refetch
+ } = useQuery({
+ queryKey: ["teamStatus"],
+ queryFn: () => getBillingService().getTeamStatus(),
+ enabled: !!os.auth.user,
+ refetchOnWindowFocus: true
+ });
+
+ if (isLoading || !teamStatus) {
+ return (
+ }
+ >
+ {isError ? (
+
+
+
+ Unable to load your team information.
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+ );
+ }
+
+ const isAdmin = teamStatus.role === "admin" || teamStatus.is_team_admin === true;
+ const seatMismatch = getTeamSeatMismatch(teamStatus);
+ const seatsAvailable = Math.max(0, teamStatus.seats_available ?? 0);
+ const canOpenBillingPortal = billingStatus
+ ? !!billingStatus.stripe_customer_id
+ : !!teamStatus.has_team_subscription;
+
+ const handleManageSubscription = async () => {
+ if (!canOpenBillingPortal) return;
+
+ setError(null);
+ setIsPortalLoading(true);
+
+ try {
+ await openBillingPortal();
+ await queryClient.invalidateQueries({ queryKey: ["billingStatus"] });
+ await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
+ } catch (portalError) {
+ console.error("Failed to open billing portal:", portalError);
+ setError(
+ "Unable to open subscription management. Please try again or contact support@trymaple.ai."
+ );
+ } finally {
+ setIsPortalLoading(false);
+ }
+ };
+
+ const handleInvite = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ const emailList = emails
+ .split(/[\n,]/)
+ .map((email) => email.trim())
+ .filter((email) => email.length > 0);
+
+ if (emailList.length === 0) {
+ setError("Please enter at least one email address");
+ return;
+ }
+
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ const invalidEmails = emailList.filter((email) => !emailRegex.test(email));
+
+ if (invalidEmails.length > 0) {
+ setError(`Invalid email format: ${invalidEmails.join(", ")}`);
+ return;
+ }
+
+ if (emailList.length > seatsAvailable) {
+ setError(
+ `Cannot invite ${emailList.length} members. Only ${seatsAvailable} ${
+ seatsAvailable === 1 ? "seat is" : "seats are"
+ } available.`
+ );
+ return;
+ }
+
+ setIsInviting(true);
+ setError(null);
+ setSuccessMessage(null);
+
+ try {
+ const response = await getBillingService().inviteTeamMembers({ emails: emailList });
+ await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
+ await queryClient.invalidateQueries({ queryKey: ["teamMembers"] });
+
+ const inviteCount = response.invites.length;
+ setSuccessMessage(
+ `Successfully sent ${inviteCount} ${inviteCount === 1 ? "invite" : "invites"}`
+ );
+ setEmails("");
+ } catch (inviteError) {
+ console.error("Failed to invite members:", inviteError);
+ setError(inviteError instanceof Error ? inviteError.message : "Failed to send invites");
+ } finally {
+ setIsInviting(false);
+ }
+ };
+
+ if (!teamStatus.team_created || !isAdmin) {
+ return (
+ }
+ >
+
+
+
+ {!teamStatus.team_created
+ ? "Set up your team before inviting members."
+ : "You must be a team admin to send invitations."}
+
+
+
+ );
+ }
+
+ if (seatMismatch) {
+ return (
+ }
+ >
+
+
+
+ Team usage is paused while the team has more members than paid seats. Add seats or
+ remove members before inviting anyone else.
+
+
+ {canOpenBillingPortal && (
+
+ )}
+ {error && (
+
+
+ {error}
+
+ )}
+
+ );
+ }
+
+ return (
+ }
+ >
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/team/TeamMembersSettings.tsx b/frontend/src/components/settings/team/TeamMembersSettings.tsx
new file mode 100644
index 000000000..d2f7e83c8
--- /dev/null
+++ b/frontend/src/components/settings/team/TeamMembersSettings.tsx
@@ -0,0 +1,316 @@
+import { useState } from "react";
+import { useBlocker } from "@tanstack/react-router";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useOpenSecret } from "@opensecret/react";
+import { AlertCircle, Clock, Crown, Loader2, RotateCw, UserMinus, X } from "lucide-react";
+import { getBillingService } from "@/billing/billingService";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import type { TeamInvite, TeamMember, TeamStatus } from "@/types/team";
+
+type PendingAction =
+ | { type: "remove"; member: TeamMember }
+ | { type: "revoke"; invite: TeamInvite }
+ | null;
+
+function getTimeRemaining(expiresAt: string) {
+ const now = new Date();
+ const expires = new Date(expiresAt);
+ const diff = expires.getTime() - now.getTime();
+
+ if (diff <= 0) return "Expired";
+
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+ const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
+
+ if (days > 0) {
+ return `${days} day${days > 1 ? "s" : ""} remaining`;
+ }
+
+ return `${hours} hour${hours > 1 ? "s" : ""} remaining`;
+}
+
+export function TeamMembersSettings({ teamStatus }: { teamStatus: TeamStatus }) {
+ const os = useOpenSecret();
+ const queryClient = useQueryClient();
+ const [pendingAction, setPendingAction] = useState(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [actionError, setActionError] = useState(null);
+
+ useBlocker({
+ shouldBlockFn: () => isProcessing,
+ disabled: !isProcessing,
+ enableBeforeUnload: isProcessing
+ });
+ useSettingsNavigationLock(isProcessing);
+
+ const currentUserEmail = os.auth.user?.user.email;
+ const isAdmin = teamStatus.role === "admin" || teamStatus.is_team_admin === true;
+
+ const {
+ data: membersData,
+ isLoading,
+ isError,
+ refetch
+ } = useQuery({
+ queryKey: ["teamMembers"],
+ queryFn: () => getBillingService().getTeamMembers(),
+ enabled: teamStatus.team_created && isAdmin
+ });
+
+ const members = membersData?.members ?? [];
+ const pendingInvites = membersData?.pending_invites ?? [];
+
+ const closeConfirmation = () => {
+ if (isProcessing) return;
+ setPendingAction(null);
+ setActionError(null);
+ };
+
+ const handleRemoveMember = async (member: TeamMember) => {
+ setIsProcessing(true);
+ setActionError(null);
+
+ try {
+ await getBillingService().removeTeamMember(member.user_id);
+ await queryClient.invalidateQueries({ queryKey: ["teamMembers"] });
+ await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
+ setPendingAction(null);
+ } catch (error) {
+ console.error("Failed to remove member:", error);
+ setActionError(error instanceof Error ? error.message : "Failed to remove team member");
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const handleRevokeInvite = async (invite: TeamInvite) => {
+ setIsProcessing(true);
+ setActionError(null);
+
+ try {
+ await getBillingService().revokeTeamInvite(invite.invite_id);
+ await queryClient.invalidateQueries({ queryKey: ["teamMembers"] });
+ await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
+ setPendingAction(null);
+ } catch (error) {
+ console.error("Failed to revoke invite:", error);
+ setActionError(error instanceof Error ? error.message : "Failed to revoke invitation");
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ if (!isAdmin) return null;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+
+ Unable to load team members.
+
+
+
+ );
+ }
+
+ return (
+
+
+
Team members
+
+ {members.length} active {members.length === 1 ? "member" : "members"}
+ {pendingInvites.length > 0 && ` • ${pendingInvites.length} pending invites`}
+
+
+
+
+ {members.map((member) => {
+ const isCurrentUser = member.email === currentUserEmail;
+ const memberIsAdmin = member.role === "admin";
+ const isConfirming =
+ pendingAction?.type === "remove" && pendingAction.member.user_id === member.user_id;
+
+ return (
+
+
+
+
+
{member.email}
+ {memberIsAdmin && (
+
+
+ Admin
+
+ )}
+ {isCurrentUser && (
+
+ You
+
+ )}
+
+
+ Joined {new Date(member.joined_at).toLocaleDateString()}
+
+
+
+ {!isCurrentUser && (
+
+ )}
+
+
+ {isConfirming && (
+
+
Remove team member?
+
+ Are you sure you want to remove {member.email} from the team? They will lose
+ access to all team resources immediately.
+
+ {actionError && (
+
+
+ {actionError}
+
+ )}
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+ {pendingInvites.length > 0 && (
+ <>
+
+
+
Pending invites
+ {pendingInvites.map((invite) => {
+ const isConfirming =
+ pendingAction?.type === "revoke" &&
+ pendingAction.invite.invite_id === invite.invite_id;
+
+ return (
+
+
+
+
{invite.email}
+
+
+ {getTimeRemaining(invite.expires_at)}
+
+
+
+
+
+ {isConfirming && (
+
+
Revoke invitation?
+
+ Are you sure you want to revoke the invitation for {invite.email}? They will
+ no longer be able to join the team using this invitation.
+
+ {actionError && (
+
+
+ {actionError}
+
+ )}
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+ >
+ )}
+
+ {members.length === 0 && pendingInvites.length === 0 && (
+
+ No team members yet. Start by inviting your team.
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/settings/team/TeamSettings.tsx b/frontend/src/components/settings/team/TeamSettings.tsx
new file mode 100644
index 000000000..c23ad645d
--- /dev/null
+++ b/frontend/src/components/settings/team/TeamSettings.tsx
@@ -0,0 +1,595 @@
+import { useRef, useState } from "react";
+import { Link, useBlocker } from "@tanstack/react-router";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useOpenSecret } from "@opensecret/react";
+import {
+ AlertCircle,
+ AlertTriangle,
+ Check,
+ CreditCard,
+ Crown,
+ Loader2,
+ LogOut,
+ Pencil,
+ RotateCw,
+ User,
+ UserPlus,
+ Users,
+ X
+} from "lucide-react";
+import { openBillingPortal } from "@/billing/billingPortal";
+import { getBillingService } from "@/billing/billingService";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import { useLocalState } from "@/state/useLocalState";
+import type { TeamStatus } from "@/types/team";
+import {
+ formatTeamSeatMismatchMessage,
+ getTeamSeatCounts,
+ getTeamSeatMismatch
+} from "@/utils/teamSeats";
+import { SettingsPage, SettingsSection } from "../SettingsPage";
+import { TeamMembersSettings } from "./TeamMembersSettings";
+
+function TeamSetup({ teamStatus }: { teamStatus: TeamStatus }) {
+ const queryClient = useQueryClient();
+ const [teamName, setTeamName] = useState("");
+ const [isCreating, setIsCreating] = useState(false);
+ const [error, setError] = useState(null);
+
+ useBlocker({
+ shouldBlockFn: () => isCreating,
+ disabled: !isCreating,
+ enableBeforeUnload: isCreating
+ });
+ useSettingsNavigationLock(isCreating);
+
+ const handleCreateTeam = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (!teamName.trim()) {
+ setError("Please enter a team name");
+ return;
+ }
+
+ setIsCreating(true);
+ setError(null);
+
+ try {
+ await getBillingService().createTeam({ name: teamName.trim() });
+ await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
+ } catch (createError) {
+ console.error("Failed to create team:", createError);
+ setError(createError instanceof Error ? createError.message : "Failed to create team");
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+function TeamMemberDashboard({ teamStatus }: { teamStatus: TeamStatus }) {
+ const queryClient = useQueryClient();
+ const [showLeaveConfirmation, setShowLeaveConfirmation] = useState(false);
+ const [isLeaving, setIsLeaving] = useState(false);
+ const [leaveError, setLeaveError] = useState(null);
+ const seatMismatch = getTeamSeatMismatch(teamStatus);
+
+ useBlocker({
+ shouldBlockFn: () => isLeaving,
+ disabled: !isLeaving,
+ enableBeforeUnload: isLeaving
+ });
+ useSettingsNavigationLock(isLeaving);
+
+ const handleLeaveTeam = async () => {
+ setIsLeaving(true);
+ setLeaveError(null);
+ let shouldReload = false;
+
+ try {
+ await getBillingService().leaveTeam();
+ await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
+ shouldReload = true;
+ } catch (error) {
+ console.error("Failed to leave team:", error);
+ setLeaveError(error instanceof Error ? error.message : "Failed to leave team");
+ } finally {
+ setIsLeaving(false);
+ }
+
+ if (shouldReload) {
+ // Let the navigation blocker unregister before performing the intentional refresh.
+ window.setTimeout(() => window.location.reload(), 0);
+ }
+ };
+
+ return (
+
+ {seatMismatch && (
+
+
+
+ Team usage is paused
+ {formatTeamSeatMismatchMessage(seatMismatch, "member")}
+
+
+ )}
+
+
+
+
+
{teamStatus.team_name}
+ {teamStatus.created_at && (
+
+ Member since {new Date(teamStatus.created_at).toLocaleDateString()}
+
+ )}
+
+
+
+ Member
+
+
+
+
+
+ {!showLeaveConfirmation ? (
+
+ ) : (
+
+
Leave team?
+
+ Are you sure you want to leave the team? You will lose access to all team resources
+ and will need to be invited again to rejoin.
+
+ {leaveError && (
+
+
+ {leaveError}
+
+ )}
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+function TeamAdminDashboard({ teamStatus }: { teamStatus: TeamStatus }) {
+ const queryClient = useQueryClient();
+ const { billingStatus } = useLocalState();
+ const membersSectionRef = useRef(null);
+ const [isEditingName, setIsEditingName] = useState(false);
+ const [editedName, setEditedName] = useState("");
+ const [isSavingName, setIsSavingName] = useState(false);
+ const [nameError, setNameError] = useState(null);
+ const [isPortalLoading, setIsPortalLoading] = useState(false);
+ const [portalError, setPortalError] = useState(null);
+
+ useBlocker({
+ shouldBlockFn: () => isSavingName,
+ disabled: !isSavingName,
+ enableBeforeUnload: isSavingName
+ });
+ useSettingsNavigationLock(isSavingName);
+
+ const seatCounts = getTeamSeatCounts(teamStatus);
+ const seatMismatch = getTeamSeatMismatch(teamStatus);
+ const seatsUsed = seatCounts.memberCount ?? 0;
+ const seatsPurchased = seatCounts.billedSeatCount ?? 0;
+ const seatUsagePercentage = seatsPurchased > 0 ? (seatsUsed / seatsPurchased) * 100 : 0;
+ const canOpenBillingPortal = billingStatus
+ ? !!billingStatus.stripe_customer_id
+ : !!teamStatus.has_team_subscription;
+
+ const startEditingName = () => {
+ setEditedName(teamStatus.team_name ?? "");
+ setNameError(null);
+ setIsEditingName(true);
+ };
+
+ const cancelEditingName = () => {
+ setEditedName("");
+ setNameError(null);
+ setIsEditingName(false);
+ };
+
+ const saveTeamName = async () => {
+ const trimmedName = editedName.trim();
+
+ if (!trimmedName) {
+ setNameError("Team name cannot be empty");
+ return;
+ }
+
+ if (trimmedName.length > 100) {
+ setNameError("Team name must be 100 characters or less");
+ return;
+ }
+
+ if (trimmedName === teamStatus.team_name) {
+ cancelEditingName();
+ return;
+ }
+
+ setIsSavingName(true);
+ setNameError(null);
+
+ try {
+ await getBillingService().updateTeamName(trimmedName);
+ await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
+ await queryClient.invalidateQueries({ queryKey: ["billingStatus"] });
+ cancelEditingName();
+ } catch (error) {
+ console.error("Failed to update team name:", error);
+ setNameError(error instanceof Error ? error.message : "Failed to update team name");
+ } finally {
+ setIsSavingName(false);
+ }
+ };
+
+ const handleOpenBilling = async () => {
+ if (!canOpenBillingPortal) return;
+
+ setPortalError(null);
+ setIsPortalLoading(true);
+
+ try {
+ await openBillingPortal();
+ await queryClient.invalidateQueries({ queryKey: ["billingStatus"] });
+ await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
+ } catch (error) {
+ console.error("Failed to open billing portal:", error);
+ setPortalError(
+ "Unable to open subscription management. Please try again or contact support@trymaple.ai."
+ );
+ } finally {
+ setIsPortalLoading(false);
+ }
+ };
+
+ return (
+
+ {seatMismatch && (
+
+
+
+ Team usage is paused
+ {formatTeamSeatMismatchMessage(seatMismatch, "admin")}
+
+
+ {canOpenBillingPortal && (
+
+ )}
+
+ {portalError && {portalError}
}
+
+
+ )}
+
+
+
+
+
+ {isEditingName ? (
+
+
+
setEditedName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") saveTeamName();
+ if (event.key === "Escape") cancelEditingName();
+ }}
+ maxLength={100}
+ autoFocus
+ disabled={isSavingName}
+ aria-label="Team name"
+ className="min-w-0"
+ />
+
+ {isSavingName ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ {editedName.trim().length}/100 characters
+
+ {nameError && {nameError}}
+
+
+ ) : (
+
+
{teamStatus.team_name}
+
+
+ )}
+ {!isEditingName && teamStatus.created_at && (
+
+ Created {new Date(teamStatus.created_at).toLocaleDateString()}
+
+ )}
+
+ {!isEditingName && (
+
+
+ Admin
+
+ )}
+
+
+
+
+ Seat usage
+
+ {seatsUsed}/{seatsPurchased} ({Math.round(seatUsagePercentage)}%)
+
+
+
+
+
+
+ {seatMismatch ? (
+
+ ) : (
+
+ )}
+ {canOpenBillingPortal && (
+
+ )}
+
+ {!seatMismatch && portalError && (
+
+
+ {portalError}
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function TeamSettings() {
+ const os = useOpenSecret();
+ const {
+ data: teamStatus,
+ isLoading,
+ isError,
+ refetch
+ } = useQuery({
+ queryKey: ["teamStatus"],
+ queryFn: () => getBillingService().getTeamStatus(),
+ enabled: !!os.auth.user,
+ refetchOnWindowFocus: true
+ });
+
+ if (isLoading || !teamStatus) {
+ return (
+
+ {isError ? (
+
+
+
+ Unable to load your team information.
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+ );
+ }
+
+ if (teamStatus.has_team_subscription && !teamStatus.team_created) {
+ return ;
+ }
+
+ if (!teamStatus.team_created) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const isAdmin = teamStatus.role === "admin" || teamStatus.is_team_admin === true;
+ return isAdmin ? (
+
+ ) : (
+
+ );
+}
diff --git a/frontend/src/components/settings/useCompactSettingsLayout.ts b/frontend/src/components/settings/useCompactSettingsLayout.ts
new file mode 100644
index 000000000..b42820dab
--- /dev/null
+++ b/frontend/src/components/settings/useCompactSettingsLayout.ts
@@ -0,0 +1,8 @@
+import { useIsLandscapeMobile, useIsMobile } from "@/utils/utils";
+
+export function useCompactSettingsLayout() {
+ const isMobile = useIsMobile();
+ const isLandscapeMobile = useIsLandscapeMobile();
+
+ return isMobile || isLandscapeMobile;
+}
diff --git a/frontend/src/components/team/TeamDashboard.tsx b/frontend/src/components/team/TeamDashboard.tsx
deleted file mode 100644
index 6b0febe27..000000000
--- a/frontend/src/components/team/TeamDashboard.tsx
+++ /dev/null
@@ -1,374 +0,0 @@
-import { useRef, 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";
-import {
- UserPlus,
- AlertTriangle,
- Crown,
- User,
- Pencil,
- Check,
- X,
- Loader2,
- CreditCard,
- Users
-} from "lucide-react";
-import { TeamInviteDialog } from "./TeamInviteDialog";
-import { TeamMembersList } from "./TeamMembersList";
-import { getBillingService } from "@/billing/billingService";
-import { openBillingPortal } from "@/billing/billingPortal";
-import { useLocalState } from "@/state/useLocalState";
-import {
- formatTeamSeatMismatchMessage,
- getTeamSeatCounts,
- getTeamSeatMismatch
-} from "@/utils/teamSeats";
-import { useQueryClient } from "@tanstack/react-query";
-import type { TeamStatus } from "@/types/team";
-
-interface TeamDashboardProps {
- teamStatus?: TeamStatus;
-}
-
-export function TeamDashboard({ teamStatus }: TeamDashboardProps) {
- const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
- const [isEditingName, setIsEditingName] = useState(false);
- const [editedName, setEditedName] = useState("");
- const [isSavingName, setIsSavingName] = useState(false);
- const [nameError, setNameError] = useState(null);
- const [isPortalLoading, setIsPortalLoading] = useState(false);
- const [portalError, setPortalError] = useState(null);
- const membersSectionRef = useRef(null);
- const queryClient = useQueryClient();
- const { billingStatus } = useLocalState();
-
- if (!teamStatus) {
- return (
- <>
-
- Team Dashboard
- Loading team information...
-
- >
- );
- }
-
- const seatCounts = getTeamSeatCounts(teamStatus);
- const seatMismatch = getTeamSeatMismatch(teamStatus);
- const seatsUsed = seatCounts.memberCount ?? 0;
- const seatsPurchased = seatCounts.billedSeatCount ?? 0;
- const isAdmin = teamStatus.role === "admin" || teamStatus.is_team_admin === true;
- const seatUsagePercentage = seatsPurchased > 0 ? (seatsUsed / seatsPurchased) * 100 : 0;
- const canOpenBillingPortal = billingStatus
- ? !!billingStatus.stripe_customer_id
- : !!teamStatus.has_team_subscription;
-
- const handleStartEdit = () => {
- setEditedName(teamStatus.team_name || "");
- setIsEditingName(true);
- setNameError(null);
- };
-
- const handleCancelEdit = () => {
- setIsEditingName(false);
- setEditedName("");
- setNameError(null);
- };
-
- const handleSaveName = async () => {
- const trimmedName = editedName.trim();
-
- // Validation
- if (!trimmedName) {
- setNameError("Team name cannot be empty");
- return;
- }
-
- if (trimmedName.length > 100) {
- setNameError("Team name must be 100 characters or less");
- return;
- }
-
- if (trimmedName === teamStatus.team_name) {
- handleCancelEdit();
- return;
- }
-
- setIsSavingName(true);
- setNameError(null);
-
- try {
- const billingService = getBillingService();
- await billingService.updateTeamName(trimmedName);
-
- // Invalidate queries to refresh the data
- await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
- await queryClient.invalidateQueries({ queryKey: ["billingStatus"] });
-
- setIsEditingName(false);
- setEditedName("");
- } catch (error) {
- console.error("Failed to update team name:", error);
- setNameError(error instanceof Error ? error.message : "Failed to update team name");
- } finally {
- setIsSavingName(false);
- }
- };
-
- const handleOpenBilling = async () => {
- if (!canOpenBillingPortal) return;
-
- try {
- setPortalError(null);
- setIsPortalLoading(true);
- await openBillingPortal();
- await queryClient.invalidateQueries({ queryKey: ["billingStatus"] });
- await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
- } catch (error) {
- console.error("Failed to open billing portal:", error);
- setPortalError(
- "Unable to open subscription management. Please try again or contact support@trymaple.ai."
- );
- } finally {
- setIsPortalLoading(false);
- }
- };
-
- const handleReviewMembers = () => {
- membersSectionRef.current?.scrollIntoView({ block: "nearest" });
- };
-
- // Simplified view for non-admin members
- if (!isAdmin) {
- return (
- <>
-
- Team Information
-
-
-
- {seatMismatch && (
-
-
-
-
-
Team usage is paused
-
- {formatTeamSeatMismatchMessage(seatMismatch, "member")}
-
-
-
-
- )}
-
- {/* Compact team info */}
-
-
-
-
{teamStatus.team_name}
- {teamStatus.created_at && (
-
- Member since {new Date(teamStatus.created_at).toLocaleDateString()}
-
- )}
-
-
-
- Member
-
-
-
-
- {/* Leave team section */}
-
-
- Need to leave this team? You'll need an invitation to rejoin.
-
-
-
-
- >
- );
- }
-
- // Full admin view
- return (
- <>
-
- Team Dashboard
-
-
-
- {seatMismatch && (
-
-
-
-
-
-
Team usage is paused
-
- {formatTeamSeatMismatchMessage(seatMismatch, "admin")}
-
-
-
-
- {canOpenBillingPortal && (
-
- )}
-
- {portalError &&
{portalError}
}
-
-
-
- )}
-
- {/* Compact header with all info */}
-
-
-
- {isEditingName ? (
-
-
-
setEditedName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleSaveName();
- if (e.key === "Escape") handleCancelEdit();
- }}
- className="h-7 text-sm font-medium"
- maxLength={100}
- autoFocus
- disabled={isSavingName}
- />
- {isSavingName ? (
-
-
-
- ) : (
- <>
-
-
- >
- )}
-
-
-
- {editedName.trim().length}/100 characters
-
- {nameError && {nameError}}
-
-
- ) : (
-
-
{teamStatus.team_name}
-
-
- )}
- {!isEditingName && teamStatus.created_at && (
-
- Created {new Date(teamStatus.created_at).toLocaleDateString()}
-
- )}
-
- {!isEditingName && (
-
-
- Admin
-
- )}
-
-
- {/* Seat usage bar */}
-
-
- Seat Usage
-
- {seatsUsed}/{seatsPurchased} ({Math.round(seatUsagePercentage)}%)
-
-
-
-
-
-
- {/* Action buttons */}
- {isAdmin && (
-
- )}
-
- {/* Members list */}
-
-
-
-
-
- {/* Invite dialog */}
-
- >
- );
-}
diff --git a/frontend/src/components/team/TeamInviteDialog.tsx b/frontend/src/components/team/TeamInviteDialog.tsx
deleted file mode 100644
index dd59266dd..000000000
--- a/frontend/src/components/team/TeamInviteDialog.tsx
+++ /dev/null
@@ -1,245 +0,0 @@
-import { useState } from "react";
-import { useQueryClient } from "@tanstack/react-query";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import { Textarea } from "@/components/ui/textarea";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Loader2, AlertCircle, UserPlus, Info, CreditCard } from "lucide-react";
-import { getBillingService } from "@/billing/billingService";
-import { openBillingPortal } from "@/billing/billingPortal";
-import { useLocalState } from "@/state/useLocalState";
-import type { TeamStatus } from "@/types/team";
-
-interface TeamInviteDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- teamStatus?: TeamStatus;
-}
-
-export function TeamInviteDialog({ open, onOpenChange, teamStatus }: TeamInviteDialogProps) {
- const [emails, setEmails] = useState("");
- const [isInviting, setIsInviting] = useState(false);
- const [error, setError] = useState(null);
- const [successMessage, setSuccessMessage] = useState(null);
- const [isPortalLoading, setIsPortalLoading] = useState(false);
- const queryClient = useQueryClient();
- const { billingStatus } = useLocalState();
- const seatsAvailable = Math.max(0, teamStatus?.seats_available || 0);
- const canOpenBillingPortal = billingStatus
- ? !!billingStatus.stripe_customer_id
- : !!teamStatus?.has_team_subscription;
-
- const handleManageSubscription = async () => {
- if (!canOpenBillingPortal) return;
-
- try {
- setError(null);
- setIsPortalLoading(true);
- await openBillingPortal();
- await queryClient.invalidateQueries({ queryKey: ["billingStatus"] });
- await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
- } catch (error) {
- console.error("Failed to open billing portal:", error);
- setError(
- "Unable to open subscription management. Please try again or contact support@trymaple.ai."
- );
- } finally {
- setIsPortalLoading(false);
- }
- };
-
- const handleInvite = async (e: React.FormEvent) => {
- e.preventDefault();
-
- const emailList = emails
- .split(/[\n,]/)
- .map((email) => email.trim())
- .filter((email) => email.length > 0);
-
- if (emailList.length === 0) {
- setError("Please enter at least one email address");
- return;
- }
-
- // Validate email format
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- const invalidEmails = emailList.filter((email) => !emailRegex.test(email));
- if (invalidEmails.length > 0) {
- setError(`Invalid email format: ${invalidEmails.join(", ")}`);
- return;
- }
-
- // Check seat availability
- if (emailList.length > seatsAvailable) {
- setError(
- `Cannot invite ${emailList.length} members. Only ${seatsAvailable} ${
- seatsAvailable === 1 ? "seat is" : "seats are"
- } available.`
- );
- return;
- }
-
- setIsInviting(true);
- setError(null);
- setSuccessMessage(null);
-
- try {
- const billingService = getBillingService();
- const response = await billingService.inviteTeamMembers({ emails: emailList });
-
- // Invalidate team status and members queries
- await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
- await queryClient.invalidateQueries({ queryKey: ["teamMembers"] });
-
- const inviteCount = response.invites.length;
- setSuccessMessage(
- `Successfully sent ${inviteCount} ${inviteCount === 1 ? "invite" : "invites"}`
- );
-
- // Clear the form
- setEmails("");
- } catch (err) {
- console.error("Failed to invite members:", err);
- setError(err instanceof Error ? err.message : "Failed to send invites");
- } finally {
- setIsInviting(false);
- }
- };
-
- const handleOpenChange = (newOpen: boolean) => {
- if (!isInviting) {
- if (newOpen && !open) {
- // Reset form when opening
- setEmails("");
- setError(null);
- setSuccessMessage(null);
- } else if (!newOpen && open) {
- // When closing, delay clearing success message to prevent flash
- onOpenChange(newOpen);
- setTimeout(() => {
- setEmails("");
- setError(null);
- setSuccessMessage(null);
- }, 300); // Wait for dialog close animation
- return;
- }
- onOpenChange(newOpen);
- }
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/components/team/TeamManagementDialog.tsx b/frontend/src/components/team/TeamManagementDialog.tsx
deleted file mode 100644
index bb8709bea..000000000
--- a/frontend/src/components/team/TeamManagementDialog.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useState, useEffect } from "react";
-import { Dialog, DialogContent } from "@/components/ui/dialog";
-import { TeamSetupDialog } from "./TeamSetupDialog";
-import { TeamDashboard } from "./TeamDashboard";
-import type { TeamStatus } from "@/types/team";
-
-interface TeamManagementDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- teamStatus?: TeamStatus;
-}
-
-export function TeamManagementDialog({
- open,
- onOpenChange,
- teamStatus
-}: TeamManagementDialogProps) {
- const [showSetupDialog, setShowSetupDialog] = useState(false);
-
- // Determine if we should show the setup dialog
- const needsSetup = teamStatus?.has_team_subscription && !teamStatus?.team_created;
-
- // Check if team needs to be set up when dialog opens
- useEffect(() => {
- if (open && needsSetup) {
- setShowSetupDialog(true);
- } else if (!needsSetup) {
- // Reset state when team is created
- setShowSetupDialog(false);
- }
- }, [open, needsSetup]);
-
- const handleTeamCreated = () => {
- // Don't update state here - let the effect handle it when teamStatus updates
- // This prevents race conditions without setTimeout
- };
-
- // Show setup dialog if explicitly set or if team needs setup
- if (showSetupDialog && needsSetup) {
- return (
-
- );
- }
-
- // Otherwise show the team dashboard
- return (
-
- );
-}
diff --git a/frontend/src/components/team/TeamMembersList.tsx b/frontend/src/components/team/TeamMembersList.tsx
deleted file mode 100644
index 51571b38b..000000000
--- a/frontend/src/components/team/TeamMembersList.tsx
+++ /dev/null
@@ -1,468 +0,0 @@
-import { useState } from "react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import { Separator } from "@/components/ui/separator";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle
-} from "@/components/ui/alert-dialog";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger
-} from "@/components/ui/dropdown-menu";
-import {
- Crown,
- Clock,
- MoreVertical,
- UserMinus,
- X,
- LogOut,
- Loader2,
- AlertCircle
-} from "lucide-react";
-import { getBillingService } from "@/billing/billingService";
-import { useOpenSecret } from "@opensecret/react";
-import type { TeamStatus, TeamMember, TeamInvite } from "@/types/team";
-
-interface TeamMembersListProps {
- teamStatus?: TeamStatus;
-}
-
-export function TeamMembersList({ teamStatus }: TeamMembersListProps) {
- const os = useOpenSecret();
- const queryClient = useQueryClient();
- const [removeMemberDialog, setRemoveMemberDialog] = useState<{
- open: boolean;
- member?: TeamMember;
- }>({ open: false });
- const [revokeInviteDialog, setRevokeInviteDialog] = useState<{
- open: boolean;
- invite?: TeamInvite;
- }>({ open: false });
- const [leaveTeamDialog, setLeaveTeamDialog] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const [errorMessage, setErrorMessage] = useState(null);
-
- const currentUserEmail = os.auth.user?.user.email;
- const isAdmin = teamStatus?.role === "admin" || teamStatus?.is_team_admin === true;
-
- // Fetch team members only for admins
- const { data: membersData, isLoading } = useQuery({
- queryKey: ["teamMembers"],
- queryFn: async () => {
- const billingService = getBillingService();
- return await billingService.getTeamMembers();
- },
- enabled: !!teamStatus?.team_created && isAdmin
- });
-
- const members = membersData?.members || [];
- const pendingInvites = membersData?.pending_invites || [];
-
- const handleRemoveMember = async (userId: string) => {
- setIsProcessing(true);
- setErrorMessage(null);
- try {
- const billingService = getBillingService();
- await billingService.removeTeamMember(userId);
-
- // Invalidate queries
- await queryClient.invalidateQueries({ queryKey: ["teamMembers"] });
- await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
-
- setRemoveMemberDialog({ open: false });
- } catch (error) {
- console.error("Failed to remove member:", error);
- const message = error instanceof Error ? error.message : "Failed to remove team member";
- setErrorMessage(message);
- // Keep dialog open to show error
- } finally {
- setIsProcessing(false);
- }
- };
-
- const handleRevokeInvite = async (inviteId: string) => {
- setIsProcessing(true);
- setErrorMessage(null);
- try {
- const billingService = getBillingService();
- await billingService.revokeTeamInvite(inviteId);
-
- // Invalidate queries
- await queryClient.invalidateQueries({ queryKey: ["teamMembers"] });
- await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
-
- setRevokeInviteDialog({ open: false });
- } catch (error) {
- console.error("Failed to revoke invite:", error);
- const message = error instanceof Error ? error.message : "Failed to revoke invitation";
- setErrorMessage(message);
- } finally {
- setIsProcessing(false);
- }
- };
-
- const handleLeaveTeam = async () => {
- setIsProcessing(true);
- setErrorMessage(null);
- try {
- const billingService = getBillingService();
- await billingService.leaveTeam();
-
- // Invalidate queries and refresh page
- await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
- window.location.reload();
- } catch (error) {
- console.error("Failed to leave team:", error);
- const message = error instanceof Error ? error.message : "Failed to leave team";
- setErrorMessage(message);
- } finally {
- setIsProcessing(false);
- }
- };
-
- const getTimeRemaining = (expiresAt: string) => {
- const now = new Date();
- const expires = new Date(expiresAt);
- const diff = expires.getTime() - now.getTime();
-
- if (diff <= 0) return "Expired";
-
- const days = Math.floor(diff / (1000 * 60 * 60 * 24));
- const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
-
- if (days > 0) {
- return `${days} day${days > 1 ? "s" : ""} remaining`;
- }
- return `${hours} hour${hours > 1 ? "s" : ""} remaining`;
- };
-
- // Simplified view for non-admin members
- if (!isAdmin) {
- return (
- <>
-
-
- {/* Leave Team Dialog */}
- {
- if (!open) setErrorMessage(null);
- setLeaveTeamDialog(open);
- }}
- >
-
-
- Leave team?
-
- Are you sure you want to leave the team? You will lose access to all team resources
- and will need to be invited again to rejoin.
-
-
- {errorMessage && (
-
-
- {errorMessage}
-
- )}
-
- Cancel
-
- {isProcessing ? (
- <>
-
- Leaving...
- >
- ) : (
- "Leave Team"
- )}
-
-
-
-
- >
- );
- }
-
- // Admin view with full member list
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- return (
- <>
-
-
-
Team Members
-
- {members.length} active {members.length === 1 ? "member" : "members"}
- {pendingInvites.length > 0 && ` • ${pendingInvites.length} pending invites`}
-
-
- {/* Active Members */}
-
- {members.map((member: TeamMember) => {
- const isCurrentUser = member.email === currentUserEmail;
- const memberIsAdmin = member.role === "admin";
-
- return (
-
-
-
-
{member.email}
-
- {memberIsAdmin && (
-
-
- Admin
-
- )}
- {isCurrentUser && (
-
- You
-
- )}
-
-
-
- Joined {new Date(member.joined_at).toLocaleDateString()}
-
-
-
- {!isCurrentUser && isAdmin && (
-
-
-
-
-
- setRemoveMemberDialog({ open: true, member })}
- className="text-destructive"
- >
-
- Remove from team
-
-
-
- )}
-
- {isCurrentUser && !isAdmin && (
-
- )}
-
- );
- })}
-
-
- {/* Pending Invites */}
- {pendingInvites.length > 0 && (
- <>
-
-
-
Pending Invites
- {pendingInvites.map((invite: TeamInvite) => (
-
-
-
{invite.email}
-
-
- {getTimeRemaining(invite.expires_at)}
-
-
-
- {isAdmin && (
-
- )}
-
- ))}
-
- >
- )}
-
- {members.length === 0 && pendingInvites.length === 0 && (
-
- No team members yet. Start by inviting your team!
-
- )}
-
-
- {/* Remove Member Dialog */}
- {
- if (!open) setErrorMessage(null);
- setRemoveMemberDialog({ open });
- }}
- >
-
-
- Remove team member?
-
- Are you sure you want to remove {removeMemberDialog.member?.email} from the team? They
- will lose access to all team resources immediately.
-
-
- {errorMessage && (
-
-
- {errorMessage}
-
- )}
-
- Cancel
-
- removeMemberDialog.member && handleRemoveMember(removeMemberDialog.member.user_id)
- }
- disabled={isProcessing}
- className="bg-destructive text-destructive-onFilled hover:bg-destructive/90"
- >
- {isProcessing ? (
- <>
-
- Removing...
- >
- ) : (
- "Remove member"
- )}
-
-
-
-
-
- {/* Revoke Invite Dialog */}
- {
- if (!open) setErrorMessage(null);
- setRevokeInviteDialog({ open });
- }}
- >
-
-
- Revoke invitation?
-
- Are you sure you want to revoke the invitation for {revokeInviteDialog.invite?.email}?
- They will no longer be able to join the team using this invitation.
-
-
- {errorMessage && (
-
-
- {errorMessage}
-
- )}
-
- Cancel
-
- revokeInviteDialog.invite && handleRevokeInvite(revokeInviteDialog.invite.invite_id)
- }
- disabled={isProcessing}
- className="bg-destructive text-destructive-onFilled hover:bg-destructive/90"
- >
- {isProcessing ? (
- <>
-
- Revoking...
- >
- ) : (
- "Revoke invitation"
- )}
-
-
-
-
-
- {/* Leave Team Dialog */}
- {
- if (!open) setErrorMessage(null);
- setLeaveTeamDialog(open);
- }}
- >
-
-
- Leave team?
-
- Are you sure you want to leave the team? You will lose access to all team resources
- and will need to be invited again to rejoin.
-
-
- {errorMessage && (
-
-
- {errorMessage}
-
- )}
-
- Cancel
-
- {isProcessing ? (
- <>
-
- Leaving...
- >
- ) : (
- "Leave team"
- )}
-
-
-
-
- >
- );
-}
diff --git a/frontend/src/components/team/TeamSeatMismatchAlert.tsx b/frontend/src/components/team/TeamSeatMismatchAlert.tsx
index aa4fb095c..72b8fd04f 100644
--- a/frontend/src/components/team/TeamSeatMismatchAlert.tsx
+++ b/frontend/src/components/team/TeamSeatMismatchAlert.tsx
@@ -1,16 +1,15 @@
-import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
+import { useNavigate } from "@tanstack/react-router";
import { useOpenSecret } from "@opensecret/react";
import { AlertTriangle, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getBillingService } from "@/billing/billingService";
import type { TeamStatus } from "@/types/team";
import { formatTeamSeatMismatchMessage, getTeamSeatMismatch } from "@/utils/teamSeats";
-import { TeamManagementDialog } from "./TeamManagementDialog";
export function TeamSeatMismatchAlert() {
const os = useOpenSecret();
- const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false);
+ const navigate = useNavigate();
const { data: teamStatus } = useQuery({
queryKey: ["teamStatus"],
@@ -37,35 +36,32 @@ export function TeamSeatMismatchAlert() {
: "Seat count mismatch";
return (
- <>
-
-
-
-
-
-
-
Team usage paused
-
{summary}
-
- {formatTeamSeatMismatchMessage(mismatch, isAdmin ? "admin" : "member")}
-
-
-
-
-
+
+
+
+
+
+
+
Team usage paused
+
{summary}
+
+ {formatTeamSeatMismatchMessage(mismatch, isAdmin ? "admin" : "member")}
+
+
+
+
-
-
- >
+
);
}
diff --git a/frontend/src/components/team/TeamSetupDialog.tsx b/frontend/src/components/team/TeamSetupDialog.tsx
deleted file mode 100644
index 6925eb0cb..000000000
--- a/frontend/src/components/team/TeamSetupDialog.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import { useState } from "react";
-import { useQueryClient } from "@tanstack/react-query";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Loader2, AlertCircle } from "lucide-react";
-import { getBillingService } from "@/billing/billingService";
-import type { TeamStatus } from "@/types/team";
-
-interface TeamSetupDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- teamStatus?: TeamStatus;
- onTeamCreated?: () => void;
-}
-
-export function TeamSetupDialog({
- open,
- onOpenChange,
- teamStatus,
- onTeamCreated
-}: TeamSetupDialogProps) {
- const [teamName, setTeamName] = useState("");
- const [isCreating, setIsCreating] = useState(false);
- const [error, setError] = useState
(null);
- const queryClient = useQueryClient();
-
- // Don't show dialog if team is already created
- if (teamStatus?.team_created) {
- return null;
- }
-
- const handleCreateTeam = async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!teamName.trim()) {
- setError("Please enter a team name");
- return;
- }
-
- setIsCreating(true);
- setError(null);
-
- try {
- const billingService = getBillingService();
- await billingService.createTeam({ name: teamName.trim() });
-
- // Invalidate team status query to refetch
- await queryClient.invalidateQueries({ queryKey: ["teamStatus"] });
-
- // Call the success callback
- onTeamCreated?.();
-
- // Don't close the dialog - let the parent switch to dashboard view
- } catch (err) {
- console.error("Failed to create team:", err);
- setError(err instanceof Error ? err.message : "Failed to create team");
- } finally {
- setIsCreating(false);
- }
- };
-
- const handleOpenChange = (newOpen: boolean) => {
- // Don't allow closing while creating
- if (!isCreating) {
- onOpenChange(newOpen);
- // Reset form when closing
- if (!newOpen) {
- setTeamName("");
- setError(null);
- }
- }
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/contexts/PersistentHomeNavigationContext.ts b/frontend/src/contexts/PersistentHomeNavigationContext.ts
new file mode 100644
index 000000000..69d486243
--- /dev/null
+++ b/frontend/src/contexts/PersistentHomeNavigationContext.ts
@@ -0,0 +1,18 @@
+import { createContext, useContext } from "react";
+
+export type PersistentHomeNavigation = {
+ homeHref: string;
+ returnToHome: (options?: { replace?: boolean }) => void;
+};
+
+export const PersistentHomeNavigationContext = createContext(null);
+
+export function usePersistentHomeNavigation() {
+ const context = useContext(PersistentHomeNavigationContext);
+ if (!context) {
+ throw new Error(
+ "usePersistentHomeNavigation must be used within PersistentHomeNavigationProvider"
+ );
+ }
+ return context;
+}
diff --git a/frontend/src/contexts/SettingsNavigationLockContext.ts b/frontend/src/contexts/SettingsNavigationLockContext.ts
new file mode 100644
index 000000000..668a52204
--- /dev/null
+++ b/frontend/src/contexts/SettingsNavigationLockContext.ts
@@ -0,0 +1,36 @@
+import { createContext, useContext, useLayoutEffect, useRef } from "react";
+
+export type SettingsNavigationLockContextValue = {
+ isNavigationLocked: boolean;
+ setLock: (id: symbol, locked: boolean) => void;
+};
+
+export const SettingsNavigationLockContext =
+ createContext(null);
+
+export function useSettingsNavigationLock(locked: boolean) {
+ const context = useContext(SettingsNavigationLockContext);
+ const lockId = useRef(Symbol("settings-navigation-lock"));
+ const setLock = context?.setLock;
+
+ useLayoutEffect(() => {
+ if (!setLock) return;
+ const id = lockId.current;
+ setLock(id, locked);
+ return () => setLock(id, false);
+ }, [locked, setLock]);
+
+ if (!context) {
+ throw new Error("useSettingsNavigationLock must be used within settings");
+ }
+}
+
+export function useSettingsNavigationLockState() {
+ const context = useContext(SettingsNavigationLockContext);
+
+ if (!context) {
+ throw new Error("useSettingsNavigationLockState must be used within settings");
+ }
+
+ return context.isNavigationLocked;
+}
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index 4de9e41cc..ac18632e9 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -11,10 +11,12 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as TermsRouteImport } from './routes/terms'
import { Route as SignupRouteImport } from './routes/signup'
+import { Route as SettingsRouteImport } from './routes/settings'
import { Route as RedeemRouteImport } from './routes/redeem'
import { Route as ProofRouteImport } from './routes/proof'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as PricingRouteImport } from './routes/pricing'
+import { Route as PaymentSuccessCreditsRouteImport } from './routes/payment-success-credits'
import { Route as PaymentSuccessRouteImport } from './routes/payment-success'
import { Route as PaymentCanceledRouteImport } from './routes/payment-canceled'
import { Route as PasswordResetRouteImport } from './routes/password-reset'
@@ -24,10 +26,27 @@ import { Route as DesktopAuthRouteImport } from './routes/desktop-auth'
import { Route as AgentRouteImport } from './routes/agent'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as SettingsIndexRouteImport } from './routes/settings.index'
import { Route as VerifyCodeRouteImport } from './routes/verify.$code'
+import { Route as SettingsTeamRouteImport } from './routes/settings.team'
+import { Route as SettingsSecurityRouteImport } from './routes/settings.security'
+import { Route as SettingsPreferencesRouteImport } from './routes/settings.preferences'
+import { Route as SettingsHistoryRouteImport } from './routes/settings.history'
+import { Route as SettingsDeleteAccountRouteImport } from './routes/settings.delete-account'
+import { Route as SettingsBillingRouteImport } from './routes/settings.billing'
+import { Route as SettingsApiRouteImport } from './routes/settings.api'
+import { Route as SettingsAccountRouteImport } from './routes/settings.account'
+import { Route as SettingsAboutRouteImport } from './routes/settings.about'
import { Route as PasswordResetConfirmRouteImport } from './routes/password-reset.confirm'
+import { Route as SettingsTeamIndexRouteImport } from './routes/settings.team.index'
+import { Route as SettingsApiIndexRouteImport } from './routes/settings.api.index'
import { Route as TeamInviteInviteIdRouteImport } from './routes/team.invite.$inviteId'
+import { Route as SettingsTeamInviteRouteImport } from './routes/settings.team.invite'
+import { Route as SettingsApiProxyRouteImport } from './routes/settings.api.proxy'
+import { Route as SettingsApiKeysRouteImport } from './routes/settings.api.keys'
import { Route as AuthProviderCallbackRouteImport } from './routes/auth.$provider.callback'
+import { Route as SettingsApiKeysIndexRouteImport } from './routes/settings.api.keys.index'
+import { Route as SettingsApiKeysNewRouteImport } from './routes/settings.api.keys.new'
const TermsRoute = TermsRouteImport.update({
id: '/terms',
@@ -39,6 +58,11 @@ const SignupRoute = SignupRouteImport.update({
path: '/signup',
getParentRoute: () => rootRouteImport,
} as any)
+const SettingsRoute = SettingsRouteImport.update({
+ id: '/settings',
+ path: '/settings',
+ getParentRoute: () => rootRouteImport,
+} as any)
const RedeemRoute = RedeemRouteImport.update({
id: '/redeem',
path: '/redeem',
@@ -59,6 +83,11 @@ const PricingRoute = PricingRouteImport.update({
path: '/pricing',
getParentRoute: () => rootRouteImport,
} as any)
+const PaymentSuccessCreditsRoute = PaymentSuccessCreditsRouteImport.update({
+ id: '/payment-success-credits',
+ path: '/payment-success-credits',
+ getParentRoute: () => rootRouteImport,
+} as any)
const PaymentSuccessRoute = PaymentSuccessRouteImport.update({
id: '/payment-success',
path: '/payment-success',
@@ -104,26 +133,111 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const SettingsIndexRoute = SettingsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => SettingsRoute,
+} as any)
const VerifyCodeRoute = VerifyCodeRouteImport.update({
id: '/verify/$code',
path: '/verify/$code',
getParentRoute: () => rootRouteImport,
} as any)
+const SettingsTeamRoute = SettingsTeamRouteImport.update({
+ id: '/team',
+ path: '/team',
+ getParentRoute: () => SettingsRoute,
+} as any)
+const SettingsSecurityRoute = SettingsSecurityRouteImport.update({
+ id: '/security',
+ path: '/security',
+ getParentRoute: () => SettingsRoute,
+} as any)
+const SettingsPreferencesRoute = SettingsPreferencesRouteImport.update({
+ id: '/preferences',
+ path: '/preferences',
+ getParentRoute: () => SettingsRoute,
+} as any)
+const SettingsHistoryRoute = SettingsHistoryRouteImport.update({
+ id: '/history',
+ path: '/history',
+ getParentRoute: () => SettingsRoute,
+} as any)
+const SettingsDeleteAccountRoute = SettingsDeleteAccountRouteImport.update({
+ id: '/delete-account',
+ path: '/delete-account',
+ getParentRoute: () => SettingsRoute,
+} as any)
+const SettingsBillingRoute = SettingsBillingRouteImport.update({
+ id: '/billing',
+ path: '/billing',
+ getParentRoute: () => SettingsRoute,
+} as any)
+const SettingsApiRoute = SettingsApiRouteImport.update({
+ id: '/api',
+ path: '/api',
+ getParentRoute: () => SettingsRoute,
+} as any)
+const SettingsAccountRoute = SettingsAccountRouteImport.update({
+ id: '/account',
+ path: '/account',
+ getParentRoute: () => SettingsRoute,
+} as any)
+const SettingsAboutRoute = SettingsAboutRouteImport.update({
+ id: '/about',
+ path: '/about',
+ getParentRoute: () => SettingsRoute,
+} as any)
const PasswordResetConfirmRoute = PasswordResetConfirmRouteImport.update({
id: '/confirm',
path: '/confirm',
getParentRoute: () => PasswordResetRoute,
} as any)
+const SettingsTeamIndexRoute = SettingsTeamIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => SettingsTeamRoute,
+} as any)
+const SettingsApiIndexRoute = SettingsApiIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => SettingsApiRoute,
+} as any)
const TeamInviteInviteIdRoute = TeamInviteInviteIdRouteImport.update({
id: '/team/invite/$inviteId',
path: '/team/invite/$inviteId',
getParentRoute: () => rootRouteImport,
} as any)
+const SettingsTeamInviteRoute = SettingsTeamInviteRouteImport.update({
+ id: '/invite',
+ path: '/invite',
+ getParentRoute: () => SettingsTeamRoute,
+} as any)
+const SettingsApiProxyRoute = SettingsApiProxyRouteImport.update({
+ id: '/proxy',
+ path: '/proxy',
+ getParentRoute: () => SettingsApiRoute,
+} as any)
+const SettingsApiKeysRoute = SettingsApiKeysRouteImport.update({
+ id: '/keys',
+ path: '/keys',
+ getParentRoute: () => SettingsApiRoute,
+} as any)
const AuthProviderCallbackRoute = AuthProviderCallbackRouteImport.update({
id: '/auth/$provider/callback',
path: '/auth/$provider/callback',
getParentRoute: () => rootRouteImport,
} as any)
+const SettingsApiKeysIndexRoute = SettingsApiKeysIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => SettingsApiKeysRoute,
+} as any)
+const SettingsApiKeysNewRoute = SettingsApiKeysNewRouteImport.update({
+ id: '/new',
+ path: '/new',
+ getParentRoute: () => SettingsApiKeysRoute,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -135,16 +249,35 @@ export interface FileRoutesByFullPath {
'/password-reset': typeof PasswordResetRouteWithChildren
'/payment-canceled': typeof PaymentCanceledRoute
'/payment-success': typeof PaymentSuccessRoute
+ '/payment-success-credits': typeof PaymentSuccessCreditsRoute
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/proof': typeof ProofRoute
'/redeem': typeof RedeemRoute
+ '/settings': typeof SettingsRouteWithChildren
'/signup': typeof SignupRoute
'/terms': typeof TermsRoute
'/password-reset/confirm': typeof PasswordResetConfirmRoute
+ '/settings/about': typeof SettingsAboutRoute
+ '/settings/account': typeof SettingsAccountRoute
+ '/settings/api': typeof SettingsApiRouteWithChildren
+ '/settings/billing': typeof SettingsBillingRoute
+ '/settings/delete-account': typeof SettingsDeleteAccountRoute
+ '/settings/history': typeof SettingsHistoryRoute
+ '/settings/preferences': typeof SettingsPreferencesRoute
+ '/settings/security': typeof SettingsSecurityRoute
+ '/settings/team': typeof SettingsTeamRouteWithChildren
'/verify/$code': typeof VerifyCodeRoute
+ '/settings/': typeof SettingsIndexRoute
'/auth/$provider/callback': typeof AuthProviderCallbackRoute
+ '/settings/api/keys': typeof SettingsApiKeysRouteWithChildren
+ '/settings/api/proxy': typeof SettingsApiProxyRoute
+ '/settings/team/invite': typeof SettingsTeamInviteRoute
'/team/invite/$inviteId': typeof TeamInviteInviteIdRoute
+ '/settings/api/': typeof SettingsApiIndexRoute
+ '/settings/team/': typeof SettingsTeamIndexRoute
+ '/settings/api/keys/new': typeof SettingsApiKeysNewRoute
+ '/settings/api/keys/': typeof SettingsApiKeysIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -156,6 +289,7 @@ export interface FileRoutesByTo {
'/password-reset': typeof PasswordResetRouteWithChildren
'/payment-canceled': typeof PaymentCanceledRoute
'/payment-success': typeof PaymentSuccessRoute
+ '/payment-success-credits': typeof PaymentSuccessCreditsRoute
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/proof': typeof ProofRoute
@@ -163,9 +297,23 @@ export interface FileRoutesByTo {
'/signup': typeof SignupRoute
'/terms': typeof TermsRoute
'/password-reset/confirm': typeof PasswordResetConfirmRoute
+ '/settings/about': typeof SettingsAboutRoute
+ '/settings/account': typeof SettingsAccountRoute
+ '/settings/billing': typeof SettingsBillingRoute
+ '/settings/delete-account': typeof SettingsDeleteAccountRoute
+ '/settings/history': typeof SettingsHistoryRoute
+ '/settings/preferences': typeof SettingsPreferencesRoute
+ '/settings/security': typeof SettingsSecurityRoute
'/verify/$code': typeof VerifyCodeRoute
+ '/settings': typeof SettingsIndexRoute
'/auth/$provider/callback': typeof AuthProviderCallbackRoute
+ '/settings/api/proxy': typeof SettingsApiProxyRoute
+ '/settings/team/invite': typeof SettingsTeamInviteRoute
'/team/invite/$inviteId': typeof TeamInviteInviteIdRoute
+ '/settings/api': typeof SettingsApiIndexRoute
+ '/settings/team': typeof SettingsTeamIndexRoute
+ '/settings/api/keys/new': typeof SettingsApiKeysNewRoute
+ '/settings/api/keys': typeof SettingsApiKeysIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -178,16 +326,35 @@ export interface FileRoutesById {
'/password-reset': typeof PasswordResetRouteWithChildren
'/payment-canceled': typeof PaymentCanceledRoute
'/payment-success': typeof PaymentSuccessRoute
+ '/payment-success-credits': typeof PaymentSuccessCreditsRoute
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/proof': typeof ProofRoute
'/redeem': typeof RedeemRoute
+ '/settings': typeof SettingsRouteWithChildren
'/signup': typeof SignupRoute
'/terms': typeof TermsRoute
'/password-reset/confirm': typeof PasswordResetConfirmRoute
+ '/settings/about': typeof SettingsAboutRoute
+ '/settings/account': typeof SettingsAccountRoute
+ '/settings/api': typeof SettingsApiRouteWithChildren
+ '/settings/billing': typeof SettingsBillingRoute
+ '/settings/delete-account': typeof SettingsDeleteAccountRoute
+ '/settings/history': typeof SettingsHistoryRoute
+ '/settings/preferences': typeof SettingsPreferencesRoute
+ '/settings/security': typeof SettingsSecurityRoute
+ '/settings/team': typeof SettingsTeamRouteWithChildren
'/verify/$code': typeof VerifyCodeRoute
+ '/settings/': typeof SettingsIndexRoute
'/auth/$provider/callback': typeof AuthProviderCallbackRoute
+ '/settings/api/keys': typeof SettingsApiKeysRouteWithChildren
+ '/settings/api/proxy': typeof SettingsApiProxyRoute
+ '/settings/team/invite': typeof SettingsTeamInviteRoute
'/team/invite/$inviteId': typeof TeamInviteInviteIdRoute
+ '/settings/api/': typeof SettingsApiIndexRoute
+ '/settings/team/': typeof SettingsTeamIndexRoute
+ '/settings/api/keys/new': typeof SettingsApiKeysNewRoute
+ '/settings/api/keys/': typeof SettingsApiKeysIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -201,16 +368,35 @@ export interface FileRouteTypes {
| '/password-reset'
| '/payment-canceled'
| '/payment-success'
+ | '/payment-success-credits'
| '/pricing'
| '/privacy'
| '/proof'
| '/redeem'
+ | '/settings'
| '/signup'
| '/terms'
| '/password-reset/confirm'
+ | '/settings/about'
+ | '/settings/account'
+ | '/settings/api'
+ | '/settings/billing'
+ | '/settings/delete-account'
+ | '/settings/history'
+ | '/settings/preferences'
+ | '/settings/security'
+ | '/settings/team'
| '/verify/$code'
+ | '/settings/'
| '/auth/$provider/callback'
+ | '/settings/api/keys'
+ | '/settings/api/proxy'
+ | '/settings/team/invite'
| '/team/invite/$inviteId'
+ | '/settings/api/'
+ | '/settings/team/'
+ | '/settings/api/keys/new'
+ | '/settings/api/keys/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -222,6 +408,7 @@ export interface FileRouteTypes {
| '/password-reset'
| '/payment-canceled'
| '/payment-success'
+ | '/payment-success-credits'
| '/pricing'
| '/privacy'
| '/proof'
@@ -229,9 +416,23 @@ export interface FileRouteTypes {
| '/signup'
| '/terms'
| '/password-reset/confirm'
+ | '/settings/about'
+ | '/settings/account'
+ | '/settings/billing'
+ | '/settings/delete-account'
+ | '/settings/history'
+ | '/settings/preferences'
+ | '/settings/security'
| '/verify/$code'
+ | '/settings'
| '/auth/$provider/callback'
+ | '/settings/api/proxy'
+ | '/settings/team/invite'
| '/team/invite/$inviteId'
+ | '/settings/api'
+ | '/settings/team'
+ | '/settings/api/keys/new'
+ | '/settings/api/keys'
id:
| '__root__'
| '/'
@@ -243,16 +444,35 @@ export interface FileRouteTypes {
| '/password-reset'
| '/payment-canceled'
| '/payment-success'
+ | '/payment-success-credits'
| '/pricing'
| '/privacy'
| '/proof'
| '/redeem'
+ | '/settings'
| '/signup'
| '/terms'
| '/password-reset/confirm'
+ | '/settings/about'
+ | '/settings/account'
+ | '/settings/api'
+ | '/settings/billing'
+ | '/settings/delete-account'
+ | '/settings/history'
+ | '/settings/preferences'
+ | '/settings/security'
+ | '/settings/team'
| '/verify/$code'
+ | '/settings/'
| '/auth/$provider/callback'
+ | '/settings/api/keys'
+ | '/settings/api/proxy'
+ | '/settings/team/invite'
| '/team/invite/$inviteId'
+ | '/settings/api/'
+ | '/settings/team/'
+ | '/settings/api/keys/new'
+ | '/settings/api/keys/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -265,10 +485,12 @@ export interface RootRouteChildren {
PasswordResetRoute: typeof PasswordResetRouteWithChildren
PaymentCanceledRoute: typeof PaymentCanceledRoute
PaymentSuccessRoute: typeof PaymentSuccessRoute
+ PaymentSuccessCreditsRoute: typeof PaymentSuccessCreditsRoute
PricingRoute: typeof PricingRoute
PrivacyRoute: typeof PrivacyRoute
ProofRoute: typeof ProofRoute
RedeemRoute: typeof RedeemRoute
+ SettingsRoute: typeof SettingsRouteWithChildren
SignupRoute: typeof SignupRoute
TermsRoute: typeof TermsRoute
VerifyCodeRoute: typeof VerifyCodeRoute
@@ -292,6 +514,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport
}
+ '/settings': {
+ id: '/settings'
+ path: '/settings'
+ fullPath: '/settings'
+ preLoaderRoute: typeof SettingsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/redeem': {
id: '/redeem'
path: '/redeem'
@@ -320,6 +549,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PricingRouteImport
parentRoute: typeof rootRouteImport
}
+ '/payment-success-credits': {
+ id: '/payment-success-credits'
+ path: '/payment-success-credits'
+ fullPath: '/payment-success-credits'
+ preLoaderRoute: typeof PaymentSuccessCreditsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/payment-success': {
id: '/payment-success'
path: '/payment-success'
@@ -383,6 +619,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/settings/': {
+ id: '/settings/'
+ path: '/'
+ fullPath: '/settings/'
+ preLoaderRoute: typeof SettingsIndexRouteImport
+ parentRoute: typeof SettingsRoute
+ }
'/verify/$code': {
id: '/verify/$code'
path: '/verify/$code'
@@ -390,6 +633,69 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof VerifyCodeRouteImport
parentRoute: typeof rootRouteImport
}
+ '/settings/team': {
+ id: '/settings/team'
+ path: '/team'
+ fullPath: '/settings/team'
+ preLoaderRoute: typeof SettingsTeamRouteImport
+ parentRoute: typeof SettingsRoute
+ }
+ '/settings/security': {
+ id: '/settings/security'
+ path: '/security'
+ fullPath: '/settings/security'
+ preLoaderRoute: typeof SettingsSecurityRouteImport
+ parentRoute: typeof SettingsRoute
+ }
+ '/settings/preferences': {
+ id: '/settings/preferences'
+ path: '/preferences'
+ fullPath: '/settings/preferences'
+ preLoaderRoute: typeof SettingsPreferencesRouteImport
+ parentRoute: typeof SettingsRoute
+ }
+ '/settings/history': {
+ id: '/settings/history'
+ path: '/history'
+ fullPath: '/settings/history'
+ preLoaderRoute: typeof SettingsHistoryRouteImport
+ parentRoute: typeof SettingsRoute
+ }
+ '/settings/delete-account': {
+ id: '/settings/delete-account'
+ path: '/delete-account'
+ fullPath: '/settings/delete-account'
+ preLoaderRoute: typeof SettingsDeleteAccountRouteImport
+ parentRoute: typeof SettingsRoute
+ }
+ '/settings/billing': {
+ id: '/settings/billing'
+ path: '/billing'
+ fullPath: '/settings/billing'
+ preLoaderRoute: typeof SettingsBillingRouteImport
+ parentRoute: typeof SettingsRoute
+ }
+ '/settings/api': {
+ id: '/settings/api'
+ path: '/api'
+ fullPath: '/settings/api'
+ preLoaderRoute: typeof SettingsApiRouteImport
+ parentRoute: typeof SettingsRoute
+ }
+ '/settings/account': {
+ id: '/settings/account'
+ path: '/account'
+ fullPath: '/settings/account'
+ preLoaderRoute: typeof SettingsAccountRouteImport
+ parentRoute: typeof SettingsRoute
+ }
+ '/settings/about': {
+ id: '/settings/about'
+ path: '/about'
+ fullPath: '/settings/about'
+ preLoaderRoute: typeof SettingsAboutRouteImport
+ parentRoute: typeof SettingsRoute
+ }
'/password-reset/confirm': {
id: '/password-reset/confirm'
path: '/confirm'
@@ -397,6 +703,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PasswordResetConfirmRouteImport
parentRoute: typeof PasswordResetRoute
}
+ '/settings/team/': {
+ id: '/settings/team/'
+ path: '/'
+ fullPath: '/settings/team/'
+ preLoaderRoute: typeof SettingsTeamIndexRouteImport
+ parentRoute: typeof SettingsTeamRoute
+ }
+ '/settings/api/': {
+ id: '/settings/api/'
+ path: '/'
+ fullPath: '/settings/api/'
+ preLoaderRoute: typeof SettingsApiIndexRouteImport
+ parentRoute: typeof SettingsApiRoute
+ }
'/team/invite/$inviteId': {
id: '/team/invite/$inviteId'
path: '/team/invite/$inviteId'
@@ -404,6 +724,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TeamInviteInviteIdRouteImport
parentRoute: typeof rootRouteImport
}
+ '/settings/team/invite': {
+ id: '/settings/team/invite'
+ path: '/invite'
+ fullPath: '/settings/team/invite'
+ preLoaderRoute: typeof SettingsTeamInviteRouteImport
+ parentRoute: typeof SettingsTeamRoute
+ }
+ '/settings/api/proxy': {
+ id: '/settings/api/proxy'
+ path: '/proxy'
+ fullPath: '/settings/api/proxy'
+ preLoaderRoute: typeof SettingsApiProxyRouteImport
+ parentRoute: typeof SettingsApiRoute
+ }
+ '/settings/api/keys': {
+ id: '/settings/api/keys'
+ path: '/keys'
+ fullPath: '/settings/api/keys'
+ preLoaderRoute: typeof SettingsApiKeysRouteImport
+ parentRoute: typeof SettingsApiRoute
+ }
'/auth/$provider/callback': {
id: '/auth/$provider/callback'
path: '/auth/$provider/callback'
@@ -411,6 +752,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthProviderCallbackRouteImport
parentRoute: typeof rootRouteImport
}
+ '/settings/api/keys/': {
+ id: '/settings/api/keys/'
+ path: '/'
+ fullPath: '/settings/api/keys/'
+ preLoaderRoute: typeof SettingsApiKeysIndexRouteImport
+ parentRoute: typeof SettingsApiKeysRoute
+ }
+ '/settings/api/keys/new': {
+ id: '/settings/api/keys/new'
+ path: '/new'
+ fullPath: '/settings/api/keys/new'
+ preLoaderRoute: typeof SettingsApiKeysNewRouteImport
+ parentRoute: typeof SettingsApiKeysRoute
+ }
}
}
@@ -426,6 +781,80 @@ const PasswordResetRouteWithChildren = PasswordResetRoute._addFileChildren(
PasswordResetRouteChildren,
)
+interface SettingsApiKeysRouteChildren {
+ SettingsApiKeysNewRoute: typeof SettingsApiKeysNewRoute
+ SettingsApiKeysIndexRoute: typeof SettingsApiKeysIndexRoute
+}
+
+const SettingsApiKeysRouteChildren: SettingsApiKeysRouteChildren = {
+ SettingsApiKeysNewRoute: SettingsApiKeysNewRoute,
+ SettingsApiKeysIndexRoute: SettingsApiKeysIndexRoute,
+}
+
+const SettingsApiKeysRouteWithChildren = SettingsApiKeysRoute._addFileChildren(
+ SettingsApiKeysRouteChildren,
+)
+
+interface SettingsApiRouteChildren {
+ SettingsApiKeysRoute: typeof SettingsApiKeysRouteWithChildren
+ SettingsApiProxyRoute: typeof SettingsApiProxyRoute
+ SettingsApiIndexRoute: typeof SettingsApiIndexRoute
+}
+
+const SettingsApiRouteChildren: SettingsApiRouteChildren = {
+ SettingsApiKeysRoute: SettingsApiKeysRouteWithChildren,
+ SettingsApiProxyRoute: SettingsApiProxyRoute,
+ SettingsApiIndexRoute: SettingsApiIndexRoute,
+}
+
+const SettingsApiRouteWithChildren = SettingsApiRoute._addFileChildren(
+ SettingsApiRouteChildren,
+)
+
+interface SettingsTeamRouteChildren {
+ SettingsTeamInviteRoute: typeof SettingsTeamInviteRoute
+ SettingsTeamIndexRoute: typeof SettingsTeamIndexRoute
+}
+
+const SettingsTeamRouteChildren: SettingsTeamRouteChildren = {
+ SettingsTeamInviteRoute: SettingsTeamInviteRoute,
+ SettingsTeamIndexRoute: SettingsTeamIndexRoute,
+}
+
+const SettingsTeamRouteWithChildren = SettingsTeamRoute._addFileChildren(
+ SettingsTeamRouteChildren,
+)
+
+interface SettingsRouteChildren {
+ SettingsAboutRoute: typeof SettingsAboutRoute
+ SettingsAccountRoute: typeof SettingsAccountRoute
+ SettingsApiRoute: typeof SettingsApiRouteWithChildren
+ SettingsBillingRoute: typeof SettingsBillingRoute
+ SettingsDeleteAccountRoute: typeof SettingsDeleteAccountRoute
+ SettingsHistoryRoute: typeof SettingsHistoryRoute
+ SettingsPreferencesRoute: typeof SettingsPreferencesRoute
+ SettingsSecurityRoute: typeof SettingsSecurityRoute
+ SettingsTeamRoute: typeof SettingsTeamRouteWithChildren
+ SettingsIndexRoute: typeof SettingsIndexRoute
+}
+
+const SettingsRouteChildren: SettingsRouteChildren = {
+ SettingsAboutRoute: SettingsAboutRoute,
+ SettingsAccountRoute: SettingsAccountRoute,
+ SettingsApiRoute: SettingsApiRouteWithChildren,
+ SettingsBillingRoute: SettingsBillingRoute,
+ SettingsDeleteAccountRoute: SettingsDeleteAccountRoute,
+ SettingsHistoryRoute: SettingsHistoryRoute,
+ SettingsPreferencesRoute: SettingsPreferencesRoute,
+ SettingsSecurityRoute: SettingsSecurityRoute,
+ SettingsTeamRoute: SettingsTeamRouteWithChildren,
+ SettingsIndexRoute: SettingsIndexRoute,
+}
+
+const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
+ SettingsRouteChildren,
+)
+
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
@@ -436,10 +865,12 @@ const rootRouteChildren: RootRouteChildren = {
PasswordResetRoute: PasswordResetRouteWithChildren,
PaymentCanceledRoute: PaymentCanceledRoute,
PaymentSuccessRoute: PaymentSuccessRoute,
+ PaymentSuccessCreditsRoute: PaymentSuccessCreditsRoute,
PricingRoute: PricingRoute,
PrivacyRoute: PrivacyRoute,
ProofRoute: ProofRoute,
RedeemRoute: RedeemRoute,
+ SettingsRoute: SettingsRouteWithChildren,
SignupRoute: SignupRoute,
TermsRoute: TermsRoute,
VerifyCodeRoute: VerifyCodeRoute,
diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx
index 49fcab4e6..79ae00106 100644
--- a/frontend/src/routes/__root.tsx
+++ b/frontend/src/routes/__root.tsx
@@ -1,9 +1,16 @@
+import { useEffect, useLayoutEffect, useRef } from "react";
import { useOpenSecret } from "@opensecret/react";
import { OpenSecretContextType } from "@opensecret/react";
-import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
+import { createRootRouteWithContext, Outlet, useLocation } from "@tanstack/react-router";
+import {
+ AuthenticatedHomeContent,
+ PersistentHomeNavigationProvider
+} from "@/components/AuthenticatedHomeContent";
import { ExternalUrlConfirmHandler } from "@/components/ExternalUrlConfirmHandler";
-import { useLayoutEffect } from "react";
+import { TeamSeatMismatchAlert } from "@/components/team/TeamSeatMismatchAlert";
+import { VerificationModal } from "@/components/VerificationModal";
import { transitionAgentAuthUser } from "@/services/agentRuntimeService";
+import { getSafeInternalRedirect } from "@/utils/internalRedirect";
interface RootRouterContext {
os: OpenSecretContextType;
@@ -22,7 +29,7 @@ export const Route = createRootRouteWithContext()({
component: Root,
validateSearch: (search: Record): RootSearchParams => ({
login: typeof search.login === "string" ? search.login : undefined,
- next: typeof search.next === "string" ? search.next : undefined,
+ next: getSafeInternalRedirect(search.next),
selected_plan: typeof search.selected_plan === "string" ? search.selected_plan : undefined,
success: typeof search.success === "boolean" ? search.success : undefined,
canceled: typeof search.canceled === "boolean" ? search.canceled : undefined,
@@ -33,6 +40,13 @@ export const Route = createRootRouteWithContext()({
function Root() {
const { auth } = useOpenSecret();
const userId = auth.user?.user.id || null;
+ const location = useLocation();
+ const persistentHomeRef = useRef(null);
+
+ const isHomeRoute = location.pathname === "/";
+ const isSettingsRoute =
+ location.pathname === "/settings" || location.pathname.startsWith("/settings/");
+ const keepAuthenticatedHomeMounted = !!auth.user && (isHomeRoute || isSettingsRoute);
useLayoutEffect(() => {
// Queue cleanup before route-level passive effects initialize Agent Mode.
@@ -40,15 +54,44 @@ function Root() {
void transitionAgentAuthUser(userId).catch(() => {});
}, [userId]);
+ useEffect(() => {
+ const persistentHome = persistentHomeRef.current;
+ if (!persistentHome) return;
+
+ if (isSettingsRoute) {
+ persistentHome.setAttribute("inert", "");
+ } else {
+ persistentHome.removeAttribute("inert");
+ }
+ }, [isSettingsRoute, keepAuthenticatedHomeMounted]);
+
// TODO... put something here, but showing nothing looks nicer than "Loading..."
if (auth.loading) {
return <>>;
}
return (
- <>
-
+
+ {keepAuthenticatedHomeMounted && (
+
+ )}
+
+
+
+
+ {(isHomeRoute || isSettingsRoute) && }
+ {!isSettingsRoute && }
- >
+
);
}
diff --git a/frontend/src/routes/auth.$provider.callback.tsx b/frontend/src/routes/auth.$provider.callback.tsx
index 18580e409..640b6375a 100644
--- a/frontend/src/routes/auth.$provider.callback.tsx
+++ b/frontend/src/routes/auth.$provider.callback.tsx
@@ -1,4 +1,4 @@
-import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
+import { createFileRoute, useNavigate, useRouter, Link } from "@tanstack/react-router";
import { useEffect, useState, useRef } from "react";
import { useOpenSecret } from "@opensecret/react";
import { AlertDestructive } from "@/components/AlertDestructive";
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getBillingService } from "@/billing/billingService";
+import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "@/utils/internalRedirect";
export const Route = createFileRoute("/auth/$provider/callback")({
component: OAuthCallback
@@ -30,6 +31,7 @@ function OAuthCallback() {
const [error, setError] = useState(null);
const [nativeRedirectUrl, setNativeRedirectUrl] = useState(null);
const navigate = useNavigate();
+ const router = useRouter();
const { handleGitHubCallback, handleGoogleCallback, handleAppleCallback } = useOpenSecret();
const processedRef = useRef(false);
@@ -52,6 +54,16 @@ function OAuthCallback() {
deepLinkUrl += `&refresh_token=${encodeURIComponent(refreshToken)}`;
}
+ const selectedPlan = sessionStorage.getItem("selected_plan");
+ sessionStorage.removeItem("selected_plan");
+ const postAuthRedirect = sessionStorage.getItem("post_auth_redirect");
+ sessionStorage.removeItem("post_auth_redirect");
+ const safePostAuthRedirect = getSafeInternalRedirect(postAuthRedirect);
+
+ if (!selectedPlan && safePostAuthRedirect) {
+ deepLinkUrl += `&next=${encodeURIComponent(safePostAuthRedirect)}`;
+ }
+
// Store the URL in state so we can show a manual open button as fallback
setNativeRedirectUrl(deepLinkUrl);
@@ -69,10 +81,7 @@ function OAuthCallback() {
const postAuthRedirect = sessionStorage.getItem("post_auth_redirect");
sessionStorage.removeItem("post_auth_redirect");
- const safePostAuthRedirect =
- postAuthRedirect && postAuthRedirect.startsWith("/") && !postAuthRedirect.startsWith("//")
- ? postAuthRedirect
- : null;
+ const safePostAuthRedirect = getSafeInternalRedirect(postAuthRedirect);
setTimeout(() => {
if (selectedPlan) {
@@ -81,7 +90,7 @@ function OAuthCallback() {
search: { selected_plan: selectedPlan }
});
} else if (safePostAuthRedirect) {
- navigate({ to: safePostAuthRedirect });
+ navigateToSafeInternalRedirect(router.history, safePostAuthRedirect);
} else {
navigate({ to: "/" });
}
@@ -164,7 +173,7 @@ function OAuthCallback() {
};
processCallback();
- }, [handleGitHubCallback, handleGoogleCallback, handleAppleCallback, navigate, provider]);
+ }, [handleGitHubCallback, handleGoogleCallback, handleAppleCallback, navigate, provider, router]);
// After auth completes for a native app flow, show a button to open the app
if (nativeRedirectUrl) {
diff --git a/frontend/src/routes/desktop-auth.tsx b/frontend/src/routes/desktop-auth.tsx
index f73899e82..e4661057a 100644
--- a/frontend/src/routes/desktop-auth.tsx
+++ b/frontend/src/routes/desktop-auth.tsx
@@ -4,11 +4,13 @@ import { useOpenSecret } from "@opensecret/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { AppleAuthProvider } from "@/components/AppleAuthProvider";
+import { getSafeInternalRedirect } from "@/utils/internalRedirect";
// Define the search parameters interface
interface DesktopAuthSearchParams {
provider: string;
selected_plan?: string;
+ next?: string;
}
// This route handles OAuth flow for both desktop and mobile Tauri apps
@@ -22,7 +24,8 @@ export const Route = createFileRoute("/desktop-auth")({
}
return {
provider,
- selected_plan: typeof search.selected_plan === "string" ? search.selected_plan : undefined
+ selected_plan: typeof search.selected_plan === "string" ? search.selected_plan : undefined,
+ next: getSafeInternalRedirect(search.next)
};
}
});
@@ -30,7 +33,7 @@ export const Route = createFileRoute("/desktop-auth")({
function DesktopAuth() {
// Use the typed search params
const search = Route.useSearch();
- const { provider, selected_plan } = search;
+ const { provider, selected_plan, next } = search;
const navigate = useNavigate();
const os = useOpenSecret();
@@ -41,10 +44,16 @@ function DesktopAuth() {
localStorage.setItem("redirect-to-native", "true");
// Store selected plan if present
+ sessionStorage.removeItem("selected_plan");
if (selected_plan) {
sessionStorage.setItem("selected_plan", selected_plan);
}
+ sessionStorage.removeItem("post_auth_redirect");
+ if (next) {
+ sessionStorage.setItem("post_auth_redirect", next);
+ }
+
// For Apple, we don't need to do anything here - the AppleAuthProvider
// component will handle the authentication flow with popup
if (provider === "apple") {
@@ -73,7 +82,7 @@ function DesktopAuth() {
};
initiateAuth();
- }, [os, provider, selected_plan, navigate]);
+ }, [os, provider, selected_plan, next, navigate]);
// Special handling for Apple OAuth - use popup instead of redirect
if (provider === "apple") {
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx
index ea28bcb42..778d01fc9 100644
--- a/frontend/src/routes/index.tsx
+++ b/frontend/src/routes/index.tsx
@@ -1,21 +1,16 @@
import { useEffect, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { UnifiedChat } from "@/components/UnifiedChat";
-import { ProjectDetailView } from "@/components/ProjectDetailView";
import { AppEntryPage } from "@/components/AppEntryPage";
-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 { getBillingService } from "@/billing/billingService";
import { useLocalState } from "@/state/useLocalState";
-import type { TeamStatus } from "@/types/team";
import type { DiscountResponse } from "@/billing/billingApi";
import { appUrl } from "@/config/domains";
import { useRouteMeta } from "@/utils/routeMeta";
+import { getSafeInternalRedirect } from "@/utils/internalRedirect";
const appHomeUrl = appUrl("/");
@@ -30,7 +25,7 @@ type IndexSearchOptions = {
function validateSearch(search: Record): IndexSearchOptions {
return {
login: search?.login === "true" ? "true" : undefined,
- next: search.next ? (search.next as string) : undefined,
+ next: getSafeInternalRedirect(search.next),
team_setup: search?.team_setup === true || search?.team_setup === "true" ? true : undefined,
credits_success:
search?.credits_success === true || search?.credits_success === "true" ? true : undefined,
@@ -44,48 +39,6 @@ export const Route = createFileRoute("/")({
validateSearch
});
-function getActiveProjectIdFromSearch() {
- if (typeof window === "undefined") {
- return null;
- }
-
- return new URLSearchParams(window.location.search).get("project_id");
-}
-
-function AuthenticatedHomeContent() {
- const [activeProjectId, setActiveProjectId] = useState(() =>
- getActiveProjectIdFromSearch()
- );
-
- useEffect(() => {
- const syncFromLocation = () => {
- setActiveProjectId(getActiveProjectIdFromSearch());
- };
-
- window.addEventListener("projectselected", syncFromLocation);
- window.addEventListener("conversationselected", syncFromLocation as EventListener);
- window.addEventListener("newchat", syncFromLocation);
- window.addEventListener("popstate", syncFromLocation);
-
- return () => {
- window.removeEventListener("projectselected", syncFromLocation);
- window.removeEventListener("conversationselected", syncFromLocation as EventListener);
- window.removeEventListener("newchat", syncFromLocation);
- window.removeEventListener("popstate", syncFromLocation);
- };
- }, []);
-
- const hasConversationId =
- typeof window !== "undefined" &&
- new URLSearchParams(window.location.search).has("conversation_id");
-
- if (activeProjectId && !hasConversationId) {
- return ;
- }
-
- return ;
-}
-
function Index() {
const navigate = useNavigate();
const os = useOpenSecret();
@@ -102,10 +55,6 @@ function Index() {
const { login, next, team_setup, credits_success, api_settings } = 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);
@@ -131,16 +80,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"],
@@ -152,36 +91,29 @@ function Index() {
enabled: !!os.auth.user
});
- // Auto-open team dialog if team_setup is true
+ // Preserve legacy Team setup URLs by routing them into the dedicated Team settings page.
useEffect(() => {
- if (team_setup && os.auth.user && teamStatus) {
- setTeamDialogOpen(true);
- // Clear the query param to prevent re-opening on refresh
- navigate({ to: "/", replace: true });
+ if (team_setup && os.auth.user) {
+ navigate({ to: "/settings/team", replace: true });
}
- }, [team_setup, os.auth.user, teamStatus, navigate]);
+ }, [team_setup, os.auth.user, navigate]);
- // Handle credits_success - open API key dialog and refresh balance
+ // Preserve the existing credit callback while moving its success state into API 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);
+ navigate({
+ to: "/settings/api",
+ search: { credits_success: true },
+ replace: true
+ });
}
}, [credits_success, os.auth.user, navigate, queryClient]);
- // Handle api_settings - open API key dialog directly
+ // Preserve legacy links that opened API Management directly.
useEffect(() => {
if (api_settings && os.auth.user) {
- setApiKeyDialogOpen(true);
- // Clear the query param to prevent re-opening on refresh
- navigate({ to: "/", replace: true });
+ navigate({ to: "/settings/api", replace: true });
}
}, [api_settings, os.auth.user, navigate]);
@@ -231,32 +163,16 @@ function Index() {
return ;
}
- // Show unified chat for authenticated users
+ // The authenticated home surface is mounted by the root route so it can remain alive while
+ // the dedicated settings routes visually replace it.
return (
<>
-
-
{/* Modals */}
-
- {/* Team Management Dialog */}
-
-
- {/* API Key Management Dialog */}
-
-
{/* Promo Dialog - shows once per promo for free users */}
{discount?.active && (
diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx
index 7ea723804..73a6469ad 100644
--- a/frontend/src/routes/login.tsx
+++ b/frontend/src/routes/login.tsx
@@ -1,4 +1,4 @@
-import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
+import { createFileRoute, Link, useNavigate, useRouter } from "@tanstack/react-router";
import { useOpenSecret } from "@opensecret/react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -19,6 +19,7 @@ import { getBillingService } from "@/billing/billingService";
import { isIOS, isTauri } from "@/utils/platform";
import { appUrl } from "@/config/domains";
import { useRouteMeta } from "@/utils/routeMeta";
+import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "@/utils/internalRedirect";
type LoginSearchParams = {
next?: string;
@@ -29,7 +30,7 @@ type LoginSearchParams = {
export const Route = createFileRoute("/login")({
component: LoginPage,
validateSearch: (search: Record): LoginSearchParams => ({
- next: typeof search.next === "string" ? search.next : undefined,
+ next: getSafeInternalRedirect(search.next),
selected_plan: typeof search.selected_plan === "string" ? search.selected_plan : undefined,
code: typeof search.code === "string" ? search.code : undefined
})
@@ -46,6 +47,7 @@ function LoginPage() {
});
const navigate = useNavigate();
+ const router = useRouter();
const os = useOpenSecret();
const { next, selected_plan, code } = Route.useSearch();
const [loginMethod, setLoginMethod] = useState(null);
@@ -70,10 +72,12 @@ function LoginPage() {
search: { code }
});
} else {
- navigate({ to: next || "/" });
+ if (!navigateToSafeInternalRedirect(router.history, next)) {
+ navigate({ to: "/" });
+ }
}
}
- }, [os.auth.user, navigate, next, selected_plan, code]);
+ }, [os.auth.user, navigate, next, selected_plan, code, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -98,7 +102,9 @@ function LoginPage() {
search: { selected_plan }
});
} else {
- navigate({ to: next || "/" });
+ if (!navigateToSafeInternalRedirect(router.history, next)) {
+ navigate({ to: "/" });
+ }
}
window.scrollTo(0, 0);
}, 100);
@@ -132,6 +138,10 @@ function LoginPage() {
desktopAuthUrl += `&code=${encodeURIComponent(code)}`;
}
+ if (next) {
+ desktopAuthUrl += `&next=${encodeURIComponent(next)}`;
+ }
+
// Use the opener plugin by directly invoking the command
// This works for both desktop and mobile (iOS/Android)
console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl);
@@ -142,13 +152,15 @@ function LoginPage() {
} else {
// Web flow remains unchanged
const { auth_url } = await os.initiateGitHubAuth("");
+ sessionStorage.removeItem("selected_plan");
if (selected_plan) {
sessionStorage.setItem("selected_plan", selected_plan);
}
if (code) {
sessionStorage.setItem("redeem_code", code);
}
- if (next && next.startsWith("/") && !next.startsWith("//")) {
+ sessionStorage.removeItem("post_auth_redirect");
+ if (next) {
sessionStorage.setItem("post_auth_redirect", next);
}
window.location.href = auth_url;
@@ -177,6 +189,10 @@ function LoginPage() {
desktopAuthUrl += `&code=${encodeURIComponent(code)}`;
}
+ if (next) {
+ desktopAuthUrl += `&next=${encodeURIComponent(next)}`;
+ }
+
// Use the opener plugin by directly invoking the command
// This works for both desktop and mobile (iOS/Android)
console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl);
@@ -187,13 +203,15 @@ function LoginPage() {
} else {
// Web flow remains unchanged
const { auth_url } = await os.initiateGoogleAuth("");
+ sessionStorage.removeItem("selected_plan");
if (selected_plan) {
sessionStorage.setItem("selected_plan", selected_plan);
}
if (code) {
sessionStorage.setItem("redeem_code", code);
}
- if (next && next.startsWith("/") && !next.startsWith("//")) {
+ sessionStorage.removeItem("post_auth_redirect");
+ if (next) {
sessionStorage.setItem("post_auth_redirect", next);
}
window.location.href = auth_url;
@@ -232,7 +250,9 @@ function LoginPage() {
search: { code }
});
} else {
- navigate({ to: next || "/" });
+ if (!navigateToSafeInternalRedirect(router.history, next)) {
+ navigate({ to: "/" });
+ }
}
window.scrollTo(0, 0);
}, 100);
@@ -313,7 +333,9 @@ function LoginPage() {
search: { code }
});
} else {
- navigate({ to: next || "/" });
+ if (!navigateToSafeInternalRedirect(router.history, next)) {
+ navigate({ to: "/" });
+ }
}
} catch (backendError) {
console.error("[OAuth] Backend processing failed:", backendError);
@@ -345,6 +367,10 @@ function LoginPage() {
desktopAuthUrl += `&code=${encodeURIComponent(code)}`;
}
+ if (next) {
+ desktopAuthUrl += `&next=${encodeURIComponent(next)}`;
+ }
+
// Use the opener plugin by directly invoking the command
console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl);
invoke("plugin:opener|open_url", { url: desktopAuthUrl }).catch((error: Error) => {
@@ -407,7 +433,9 @@ function LoginPage() {
if (plan) {
navigate({ to: "/pricing", search: { selected_plan: plan } });
} else {
- navigate({ to: next || "/" });
+ if (!navigateToSafeInternalRedirect(router.history, next)) {
+ navigate({ to: "/" });
+ }
}
}}
selectedPlan={selected_plan}
diff --git a/frontend/src/routes/payment-success-credits.tsx b/frontend/src/routes/payment-success-credits.tsx
new file mode 100644
index 000000000..391aad000
--- /dev/null
+++ b/frontend/src/routes/payment-success-credits.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute, Navigate } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/payment-success-credits")({
+ component: PaymentSuccessCreditsPage
+});
+
+function PaymentSuccessCreditsPage() {
+ return ;
+}
diff --git a/frontend/src/routes/settings.about.tsx b/frontend/src/routes/settings.about.tsx
new file mode 100644
index 000000000..5508db514
--- /dev/null
+++ b/frontend/src/routes/settings.about.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { AboutSettings } from "@/components/settings/AboutSettings";
+
+export const Route = createFileRoute("/settings/about")({
+ component: AboutSettings
+});
diff --git a/frontend/src/routes/settings.account.tsx b/frontend/src/routes/settings.account.tsx
new file mode 100644
index 000000000..94e940d3b
--- /dev/null
+++ b/frontend/src/routes/settings.account.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { AccountSettings } from "@/components/settings/AccountSettings";
+
+export const Route = createFileRoute("/settings/account")({
+ component: AccountSettings
+});
diff --git a/frontend/src/routes/settings.api.index.tsx b/frontend/src/routes/settings.api.index.tsx
new file mode 100644
index 000000000..b25ffdd10
--- /dev/null
+++ b/frontend/src/routes/settings.api.index.tsx
@@ -0,0 +1,32 @@
+import { useEffect, useState } from "react";
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useQueryClient } from "@tanstack/react-query";
+import { ApiCreditsSettings } from "@/components/settings/api/ApiCreditsSettings";
+
+type ApiCreditsSearch = {
+ credits_success?: boolean;
+};
+
+export const Route = createFileRoute("/settings/api/")({
+ component: ApiCreditsRoute,
+ validateSearch: (search: Record): ApiCreditsSearch => ({
+ credits_success:
+ search.credits_success === true || search.credits_success === "true" ? true : undefined
+ })
+});
+
+function ApiCreditsRoute() {
+ const { credits_success } = Route.useSearch();
+ const [showCreditSuccessMessage] = useState(credits_success === true);
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (!credits_success) return;
+
+ void queryClient.invalidateQueries({ queryKey: ["apiCreditBalance"] });
+ void navigate({ to: "/settings/api", search: {}, replace: true });
+ }, [credits_success, navigate, queryClient]);
+
+ return ;
+}
diff --git a/frontend/src/routes/settings.api.keys.index.tsx b/frontend/src/routes/settings.api.keys.index.tsx
new file mode 100644
index 000000000..76394c7d1
--- /dev/null
+++ b/frontend/src/routes/settings.api.keys.index.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { ApiKeysSettings } from "@/components/settings/api/ApiKeysSettings";
+
+export const Route = createFileRoute("/settings/api/keys/")({
+ component: ApiKeysSettings
+});
diff --git a/frontend/src/routes/settings.api.keys.new.tsx b/frontend/src/routes/settings.api.keys.new.tsx
new file mode 100644
index 000000000..ec86e1686
--- /dev/null
+++ b/frontend/src/routes/settings.api.keys.new.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { CreateApiKeySettings } from "@/components/settings/api/CreateApiKeySettings";
+
+export const Route = createFileRoute("/settings/api/keys/new")({
+ component: CreateApiKeySettings
+});
diff --git a/frontend/src/routes/settings.api.keys.tsx b/frontend/src/routes/settings.api.keys.tsx
new file mode 100644
index 000000000..899038a43
--- /dev/null
+++ b/frontend/src/routes/settings.api.keys.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute, Outlet } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/settings/api/keys")({
+ component: ApiKeysLayout
+});
+
+function ApiKeysLayout() {
+ return ;
+}
diff --git a/frontend/src/routes/settings.api.proxy.tsx b/frontend/src/routes/settings.api.proxy.tsx
new file mode 100644
index 000000000..24d761d5c
--- /dev/null
+++ b/frontend/src/routes/settings.api.proxy.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute, Navigate } from "@tanstack/react-router";
+import { LocalProxySettings } from "@/components/settings/api/LocalProxySettings";
+import { isTauriDesktop } from "@/utils/platform";
+
+export const Route = createFileRoute("/settings/api/proxy")({
+ component: LocalProxyRoute
+});
+
+function LocalProxyRoute() {
+ if (!isTauriDesktop()) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/frontend/src/routes/settings.api.tsx b/frontend/src/routes/settings.api.tsx
new file mode 100644
index 000000000..e71f981b8
--- /dev/null
+++ b/frontend/src/routes/settings.api.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { ApiSettingsLayout } from "@/components/settings/api/ApiSettingsLayout";
+
+export const Route = createFileRoute("/settings/api")({
+ component: ApiSettingsLayout
+});
diff --git a/frontend/src/routes/settings.billing.tsx b/frontend/src/routes/settings.billing.tsx
new file mode 100644
index 000000000..f9e722f11
--- /dev/null
+++ b/frontend/src/routes/settings.billing.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { BillingSettings } from "@/components/settings/BillingSettings";
+
+export const Route = createFileRoute("/settings/billing")({
+ component: BillingSettings
+});
diff --git a/frontend/src/routes/settings.delete-account.tsx b/frontend/src/routes/settings.delete-account.tsx
new file mode 100644
index 000000000..897712232
--- /dev/null
+++ b/frontend/src/routes/settings.delete-account.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { DeleteAccountSettings } from "@/components/settings/DeleteAccountSettings";
+
+export const Route = createFileRoute("/settings/delete-account")({
+ component: DeleteAccountSettings
+});
diff --git a/frontend/src/routes/settings.history.tsx b/frontend/src/routes/settings.history.tsx
new file mode 100644
index 000000000..153972b91
--- /dev/null
+++ b/frontend/src/routes/settings.history.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { HistorySettings } from "@/components/settings/HistorySettings";
+
+export const Route = createFileRoute("/settings/history")({
+ component: HistorySettings
+});
diff --git a/frontend/src/routes/settings.index.tsx b/frontend/src/routes/settings.index.tsx
new file mode 100644
index 000000000..a97b63ecd
--- /dev/null
+++ b/frontend/src/routes/settings.index.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute, Navigate } from "@tanstack/react-router";
+import { AccountSettings } from "@/components/settings/AccountSettings";
+import { useCompactSettingsLayout } from "@/components/settings/useCompactSettingsLayout";
+
+export const Route = createFileRoute("/settings/")({
+ component: SettingsIndex
+});
+
+function SettingsIndex() {
+ const isCompactSettingsLayout = useCompactSettingsLayout();
+
+ if (!isCompactSettingsLayout) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/frontend/src/routes/settings.preferences.tsx b/frontend/src/routes/settings.preferences.tsx
new file mode 100644
index 000000000..8b264317a
--- /dev/null
+++ b/frontend/src/routes/settings.preferences.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { PreferencesSettings } from "@/components/settings/PreferencesSettings";
+
+export const Route = createFileRoute("/settings/preferences")({
+ component: PreferencesSettings
+});
diff --git a/frontend/src/routes/settings.security.tsx b/frontend/src/routes/settings.security.tsx
new file mode 100644
index 000000000..148b4214f
--- /dev/null
+++ b/frontend/src/routes/settings.security.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { SecuritySettings } from "@/components/settings/SecuritySettings";
+
+export const Route = createFileRoute("/settings/security")({
+ component: SecuritySettings
+});
diff --git a/frontend/src/routes/settings.team.index.tsx b/frontend/src/routes/settings.team.index.tsx
new file mode 100644
index 000000000..4f921969a
--- /dev/null
+++ b/frontend/src/routes/settings.team.index.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { TeamSettings } from "@/components/settings/team/TeamSettings";
+
+export const Route = createFileRoute("/settings/team/")({
+ component: TeamSettings
+});
diff --git a/frontend/src/routes/settings.team.invite.tsx b/frontend/src/routes/settings.team.invite.tsx
new file mode 100644
index 000000000..6b4c5371c
--- /dev/null
+++ b/frontend/src/routes/settings.team.invite.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { TeamInviteSettings } from "@/components/settings/team/TeamInviteSettings";
+
+export const Route = createFileRoute("/settings/team/invite")({
+ component: TeamInviteSettings
+});
diff --git a/frontend/src/routes/settings.team.tsx b/frontend/src/routes/settings.team.tsx
new file mode 100644
index 000000000..a32be1508
--- /dev/null
+++ b/frontend/src/routes/settings.team.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute, Outlet } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/settings/team")({
+ component: TeamSettingsRoute
+});
+
+function TeamSettingsRoute() {
+ return ;
+}
diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx
new file mode 100644
index 000000000..8e03298d7
--- /dev/null
+++ b/frontend/src/routes/settings.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute, redirect } from "@tanstack/react-router";
+import { SettingsLayout } from "@/components/settings/SettingsLayout";
+
+export const Route = createFileRoute("/settings")({
+ beforeLoad: ({ context, location }) => {
+ if (!context.os.auth.loading && !context.os.auth.user) {
+ throw redirect({
+ to: "/login",
+ search: { next: location.href },
+ replace: true
+ });
+ }
+ },
+ component: SettingsLayout
+});
diff --git a/frontend/src/routes/signup.tsx b/frontend/src/routes/signup.tsx
index 320b26e04..43628f5bf 100644
--- a/frontend/src/routes/signup.tsx
+++ b/frontend/src/routes/signup.tsx
@@ -1,4 +1,4 @@
-import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
+import { createFileRoute, Link, useNavigate, useRouter } from "@tanstack/react-router";
import { useOpenSecret, type LoginResponse } from "@opensecret/react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -22,6 +22,7 @@ import { GuestCredentialsDialog } from "@/components/GuestCredentialsDialog";
import { UserCircle } from "lucide-react";
import { appUrl } from "@/config/domains";
import { useRouteMeta } from "@/utils/routeMeta";
+import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "@/utils/internalRedirect";
type SignupSearchParams = {
next?: string;
@@ -32,7 +33,7 @@ type SignupSearchParams = {
export const Route = createFileRoute("/signup")({
component: SignupPage,
validateSearch: (search: Record): SignupSearchParams => ({
- next: typeof search.next === "string" ? search.next : undefined,
+ next: getSafeInternalRedirect(search.next),
selected_plan: typeof search.selected_plan === "string" ? search.selected_plan : undefined,
code: typeof search.code === "string" ? search.code : undefined
})
@@ -49,6 +50,7 @@ function SignupPage() {
});
const navigate = useNavigate();
+ const router = useRouter();
const os = useOpenSecret();
const { next, selected_plan, code } = Route.useSearch();
const [signUpMethod, setSignUpMethod] = useState(null);
@@ -76,10 +78,12 @@ function SignupPage() {
search: { code }
});
} else {
- navigate({ to: next || "/" });
+ if (!navigateToSafeInternalRedirect(router.history, next)) {
+ navigate({ to: "/" });
+ }
}
}
- }, [os.auth.user, navigate, next, selected_plan, code, showGuestCredentials]);
+ }, [os.auth.user, navigate, next, selected_plan, code, showGuestCredentials, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -109,7 +113,9 @@ function SignupPage() {
search: { code }
});
} else {
- navigate({ to: next || "/" });
+ if (!navigateToSafeInternalRedirect(router.history, next)) {
+ navigate({ to: "/" });
+ }
}
window.scrollTo(0, 0);
}, 100);
@@ -143,6 +149,10 @@ function SignupPage() {
desktopAuthUrl += `&code=${encodeURIComponent(code)}`;
}
+ if (next) {
+ desktopAuthUrl += `&next=${encodeURIComponent(next)}`;
+ }
+
// Use the opener plugin by directly invoking the command
// This works for both desktop and mobile (iOS/Android)
console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl);
@@ -153,13 +163,15 @@ function SignupPage() {
} else {
// Web flow remains unchanged
const { auth_url } = await os.initiateGitHubAuth("");
+ sessionStorage.removeItem("selected_plan");
if (selected_plan) {
sessionStorage.setItem("selected_plan", selected_plan);
}
if (code) {
sessionStorage.setItem("redeem_code", code);
}
- if (next && next.startsWith("/") && !next.startsWith("//")) {
+ sessionStorage.removeItem("post_auth_redirect");
+ if (next) {
sessionStorage.setItem("post_auth_redirect", next);
}
window.location.href = auth_url;
@@ -188,6 +200,10 @@ function SignupPage() {
desktopAuthUrl += `&code=${encodeURIComponent(code)}`;
}
+ if (next) {
+ desktopAuthUrl += `&next=${encodeURIComponent(next)}`;
+ }
+
// Use the opener plugin by directly invoking the command
// This works for both desktop and mobile (iOS/Android)
console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl);
@@ -198,13 +214,15 @@ function SignupPage() {
} else {
// Web flow remains unchanged
const { auth_url } = await os.initiateGoogleAuth("");
+ sessionStorage.removeItem("selected_plan");
if (selected_plan) {
sessionStorage.setItem("selected_plan", selected_plan);
}
if (code) {
sessionStorage.setItem("redeem_code", code);
}
- if (next && next.startsWith("/") && !next.startsWith("//")) {
+ sessionStorage.removeItem("post_auth_redirect");
+ if (next) {
sessionStorage.setItem("post_auth_redirect", next);
}
window.location.href = auth_url;
@@ -329,7 +347,9 @@ function SignupPage() {
search: { code }
});
} else {
- navigate({ to: next || "/" });
+ if (!navigateToSafeInternalRedirect(router.history, next)) {
+ navigate({ to: "/" });
+ }
}
} catch (backendError) {
console.error("[OAuth] Backend processing failed:", backendError);
@@ -361,6 +381,10 @@ function SignupPage() {
desktopAuthUrl += `&code=${encodeURIComponent(code)}`;
}
+ if (next) {
+ desktopAuthUrl += `&next=${encodeURIComponent(next)}`;
+ }
+
// Use the opener plugin by directly invoking the command
console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl);
invoke("plugin:opener|open_url", { url: desktopAuthUrl }).catch((error: Error) => {
@@ -423,7 +447,9 @@ function SignupPage() {
if (plan) {
navigate({ to: "/pricing", search: { selected_plan: plan } });
} else {
- navigate({ to: next || "/" });
+ if (!navigateToSafeInternalRedirect(router.history, next)) {
+ navigate({ to: "/" });
+ }
}
}}
selectedPlan={selected_plan}
diff --git a/frontend/src/routes/verify.$code.tsx b/frontend/src/routes/verify.$code.tsx
index ab681a06c..363c0d301 100644
--- a/frontend/src/routes/verify.$code.tsx
+++ b/frontend/src/routes/verify.$code.tsx
@@ -1,9 +1,10 @@
-import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useOpenSecret } from "@opensecret/react";
import { AlertDestructive } from "@/components/AlertDestructive";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
+import { navigateToSafeInternalRedirect } from "@/utils/internalRedirect";
export const Route = createFileRoute("/verify/$code")({
component: VerifyEmail
@@ -14,6 +15,7 @@ function VerifyEmail() {
const [isVerifying, setIsVerifying] = useState(true);
const [error, setError] = useState(null);
const navigate = useNavigate();
+ const router = useRouter();
const { verifyEmail, refetchUser } = useOpenSecret();
useEffect(() => {
@@ -28,13 +30,7 @@ function VerifyEmail() {
// Check for a pending redirect (e.g. team invite page)
const pendingRedirect = sessionStorage.getItem("post_auth_redirect");
sessionStorage.removeItem("post_auth_redirect");
- if (
- pendingRedirect &&
- pendingRedirect.startsWith("/") &&
- !pendingRedirect.startsWith("//")
- ) {
- navigate({ to: pendingRedirect });
- } else {
+ if (!navigateToSafeInternalRedirect(router.history, pendingRedirect)) {
navigate({ to: "/" });
}
}, 2000);
@@ -49,7 +45,7 @@ function VerifyEmail() {
}
}
verify();
- }, [code, navigate, verifyEmail, refetchUser]);
+ }, [code, navigate, verifyEmail, refetchUser, router]);
if (isVerifying) {
return (
diff --git a/frontend/src/utils/internalRedirect.test.ts b/frontend/src/utils/internalRedirect.test.ts
new file mode 100644
index 000000000..e19109f87
--- /dev/null
+++ b/frontend/src/utils/internalRedirect.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "bun:test";
+import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "./internalRedirect";
+
+describe("getSafeInternalRedirect", () => {
+ it("preserves an internal path, query, and hash", () => {
+ expect(getSafeInternalRedirect("/settings/api?credits_success=true#balance")).toBe(
+ "/settings/api?credits_success=true#balance"
+ );
+ });
+
+ it("rejects absolute and protocol-relative URLs", () => {
+ expect(getSafeInternalRedirect("https://evil.example/settings")).toBeUndefined();
+ expect(getSafeInternalRedirect("//evil.example/settings")).toBeUndefined();
+ });
+
+ it("rejects backslash paths that browsers can normalize to another origin", () => {
+ expect(getSafeInternalRedirect("/\\evil.example/settings")).toBeUndefined();
+ });
+
+ it("rejects paths that normalize to a protocol-relative URL", () => {
+ expect(getSafeInternalRedirect("/a/..//evil.example/settings")).toBeUndefined();
+ });
+
+ it("rejects missing and non-string values", () => {
+ expect(getSafeInternalRedirect(undefined)).toBeUndefined();
+ expect(getSafeInternalRedirect(42)).toBeUndefined();
+ });
+});
+
+describe("navigateToSafeInternalRedirect", () => {
+ it("preserves route search and hash through the router history", () => {
+ let pushedHref: string | undefined;
+ const history = {
+ push: (href: string) => {
+ pushedHref = href;
+ },
+ replace: () => {}
+ };
+
+ expect(
+ navigateToSafeInternalRedirect(history, "/settings/api?credits_success=true#balance")
+ ).toBe(true);
+ expect(pushedHref).toBe("/settings/api?credits_success=true#balance");
+ });
+
+ it("rejects unsafe redirects without navigating", () => {
+ let navigated = false;
+ const history = {
+ push: () => {
+ navigated = true;
+ },
+ replace: () => {
+ navigated = true;
+ }
+ };
+
+ expect(navigateToSafeInternalRedirect(history, "https://evil.example/settings")).toBe(false);
+ expect(navigated).toBe(false);
+ });
+});
diff --git a/frontend/src/utils/internalRedirect.ts b/frontend/src/utils/internalRedirect.ts
new file mode 100644
index 000000000..52ebff2d4
--- /dev/null
+++ b/frontend/src/utils/internalRedirect.ts
@@ -0,0 +1,44 @@
+const INTERNAL_REDIRECT_ORIGIN = "https://maple.internal";
+
+type InternalNavigationHistory = {
+ push: (href: string) => void;
+ replace: (href: string) => void;
+};
+
+export function getSafeInternalRedirect(value: unknown): string | undefined {
+ if (typeof value !== "string" || !value.startsWith("/") || value.includes("\\")) {
+ return undefined;
+ }
+
+ try {
+ const resolved = new URL(value, INTERNAL_REDIRECT_ORIGIN);
+ if (resolved.origin !== INTERNAL_REDIRECT_ORIGIN) {
+ return undefined;
+ }
+
+ const redirect = `${resolved.pathname}${resolved.search}${resolved.hash}`;
+ if (!redirect.startsWith("/") || redirect.startsWith("//") || redirect.includes("\\")) {
+ return undefined;
+ }
+
+ return redirect;
+ } catch {
+ return undefined;
+ }
+}
+
+export function navigateToSafeInternalRedirect(
+ history: InternalNavigationHistory,
+ value: unknown,
+ { replace = false }: { replace?: boolean } = {}
+): boolean {
+ const redirect = getSafeInternalRedirect(value);
+ if (!redirect) return false;
+
+ if (replace) {
+ history.replace(redirect);
+ } else {
+ history.push(redirect);
+ }
+ return true;
+}